Add scope-aware subscriptions using existing registrations
This commit is contained in:
@@ -9,6 +9,22 @@
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Category</label><select name="category_id" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"><option value="">Select category</option>{% for cat in categories %}<option value="{{ cat.id }}" {% if catalogue and catalogue.category_id == cat.id %}selected{% endif %}>{{ cat.name }}</option>{% endfor %}</select></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Recurrence Type</label><select name="recurrence_type" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"><option value="">Select recurrence</option>{% for value, label in recurrence_choices %}<option value="{{ value }}" {% if catalogue and catalogue.recurrence_type == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Engagement Type</label><select name="engagement_type" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">{% for value, label in engagement_type_choices %}<option value="{{ value }}" {% if catalogue and catalogue.engagement_type == value %}selected{% elif not catalogue and value == "non_assurance" %}selected{% endif %}>{{ label }}</option>{% endfor %}</select><p class="mt-1 text-xs text-slate-500">This value will be copied to client engagements when the service is assigned.</p></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Service Scope</label>
|
||||
<select name="service_scope_type" id="service-scope-type" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
{% for value, label in [('client','Client / PAN level'),('business_unit','Business Unit level'),('client_branch','Client Branch level'),('registration','Registration level')] %}
|
||||
<option value="{{ value }}" {% if catalogue and catalogue.service_scope_type == value %}selected{% elif not catalogue and value == 'client' %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div id="registration-type-field"><label class="mb-2 block text-sm font-medium text-slate-700">Required Registration Type</label>
|
||||
<select name="required_registration_type" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">Not applicable</option>
|
||||
{% for value in ['GST','TAN','PF','ESI','PT','IEC','FSSAI','UDYAM','OTHER'] %}
|
||||
<option value="{{ value }}" {% if catalogue and catalogue.required_registration_type == value %}selected{% endif %}>{{ value }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-slate-500">Used only for Registration-level services.</p>
|
||||
</div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Sort Order</label><input type="number" name="sort_order" value="{{ catalogue.sort_order if catalogue else 100 }}" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></div>
|
||||
<div class="flex items-center gap-6 pt-7 text-sm text-slate-700"><label class="inline-flex items-center gap-2"><input type="checkbox" name="is_active" {% if not catalogue or catalogue.is_active %}checked{% endif %}> Active</label><label class="inline-flex items-center gap-2"><input type="checkbox" name="is_client_requestable" {% if catalogue and catalogue.is_client_requestable %}checked{% endif %}> Client Requestable</label><label class="inline-flex items-center gap-2"><input type="checkbox" name="is_consultant_requestable" {% if catalogue and catalogue.is_consultant_requestable %}checked{% endif %}> Consultant Requestable</label></div>
|
||||
<div class="md:col-span-2"><label class="mb-2 block text-sm font-medium text-slate-700">Description</label><textarea name="description" rows="4" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">{{ catalogue.description if catalogue else '' }}</textarea></div>
|
||||
@@ -27,4 +43,12 @@
|
||||
<div class="md:col-span-2 flex items-center justify-end gap-3"><a href="/services/catalogue" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</a><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Save</button></div>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
const scope=document.getElementById('service-scope-type');
|
||||
const field=document.getElementById('registration-type-field');
|
||||
function refresh(){ field.classList.toggle('hidden', scope.value !== 'registration'); }
|
||||
scope.addEventListener('change', refresh); refresh();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -2,493 +2,161 @@
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
{% set _uiux_partner_role_text = (current_user_roles or [])|join('|')|lower %}
|
||||
{% if 'partner' in _uiux_partner_role_text %}
|
||||
{% include "ui/templates/components/partner_navigation_v2.html" %}
|
||||
{% endif %}
|
||||
{% if 'partner' in _uiux_partner_role_text %}{% include "ui/templates/components/partner_navigation_v2.html" %}{% endif %}
|
||||
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Bulk Subscription Setup</h2>
|
||||
<p class="text-sm text-slate-500">
|
||||
Choose one enabled firm service, create the subscription for multiple clients, and optionally generate period-wise engagements.
|
||||
</p>
|
||||
</div>
|
||||
<a href="/services/subscriptions"
|
||||
class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||
Back
|
||||
</a>
|
||||
<div><h2 class="text-xl font-semibold text-slate-900">Bulk Subscription Setup</h2>
|
||||
<p class="text-sm text-slate-500">The selected service determines whether you select Clients, Business Units, Client Branches or registrations.</p></div>
|
||||
<a href="/services/subscriptions" class="rounded-xl border border-slate-300 px-4 py-2 text-sm">Back</a>
|
||||
</div>
|
||||
|
||||
{% if error_message %}
|
||||
<div class="rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm font-medium text-rose-700">
|
||||
{{ error_message }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error_message %}<div class="rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">{{ error_message }}</div>{% endif %}
|
||||
{% if subscriptions_created or subscriptions_reused or engagements_created or engagements_skipped %}
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="rounded-2xl bg-white p-4 shadow-soft">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Subscriptions Created</div>
|
||||
<div class="mt-1 text-2xl font-semibold text-slate-900">{{ subscriptions_created }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-white p-4 shadow-soft">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Existing Reused</div>
|
||||
<div class="mt-1 text-2xl font-semibold text-slate-900">{{ subscriptions_reused }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-white p-4 shadow-soft">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Engagements Created</div>
|
||||
<div class="mt-1 text-2xl font-semibold text-slate-900">{{ engagements_created }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-white p-4 shadow-soft">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Duplicates Skipped</div>
|
||||
<div class="mt-1 text-2xl font-semibold text-slate-900">{{ engagements_skipped }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for label,value in [('Subscriptions Created',subscriptions_created),('Existing Reused',subscriptions_reused),('Engagements Created',engagements_created),('Duplicates Skipped',engagements_skipped)] %}
|
||||
<div class="rounded-2xl bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">{{ label }}</div><div class="mt-1 text-2xl font-semibold">{{ value }}</div></div>
|
||||
{% endfor %}
|
||||
</div>{% endif %}
|
||||
|
||||
<form method="post" action="/services/subscriptions/bulk" id="bulk-subscription-form" class="space-y-5">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div class="grid gap-4 rounded-2xl bg-white p-5 shadow-soft md:grid-cols-2 xl:grid-cols-3">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Financial Year</label>
|
||||
<input type="text"
|
||||
name="financial_year"
|
||||
id="bulk-financial-year"
|
||||
value="{{ financial_year or '2025-26' }}"
|
||||
required
|
||||
placeholder="2025-26"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-1 xl:col-span-2">
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Enabled Firm Service</label>
|
||||
<select name="service_catalogue_id"
|
||||
id="bulk-service"
|
||||
required
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<div><label class="mb-2 block text-sm font-medium">Financial Year</label><input name="financial_year" id="bulk-financial-year" value="{{ financial_year }}" required class="w-full rounded-xl border px-4 py-2 text-sm"></div>
|
||||
<div class="xl:col-span-2"><label class="mb-2 block text-sm font-medium">Enabled Firm Service</label>
|
||||
<select name="service_catalogue_id" id="bulk-service" required class="w-full rounded-xl border px-4 py-2 text-sm">
|
||||
<option value="">Select service</option>
|
||||
{% for selection in enabled_services %}
|
||||
<option value="{{ selection.catalogue.id }}"
|
||||
data-type="{{ selection.catalogue.engagement_type or 'non_assurance' }}"
|
||||
data-recurrence="{{ selection.catalogue.recurrence_type or '' }}">
|
||||
<option value="{{ selection.catalogue.id }}" data-type="{{ selection.catalogue.engagement_type or 'non_assurance' }}" data-recurrence="{{ selection.catalogue.recurrence_type or '' }}" data-scope="{{ selection.catalogue.service_scope_type or 'client' }}" data-registration="{{ selection.catalogue.required_registration_type or '' }}">
|
||||
{{ selection.catalogue.service_name }} ({{ selection.catalogue.service_code }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</option>{% endfor %}
|
||||
</select>
|
||||
<p id="service-derived-info" class="mt-1 text-xs text-slate-500">
|
||||
Due-date rules, recurrence, assurance type, and task workflow are taken from the selected service setup.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Return / Engagement Period</label>
|
||||
<select name="period_label"
|
||||
id="bulk-period"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">Not applicable</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-slate-500">
|
||||
Required only when generating one monthly or quarterly engagement.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Engagement Partner</label>
|
||||
<select name="default_partner_user_id"
|
||||
id="bulk-partner"
|
||||
required
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">Select partner</option>
|
||||
{% for u in partners %}
|
||||
<option value="{{ u.id }}" data-branch="{{ u.branch_id }}">{{ u.full_name or u.email }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-slate-500">
|
||||
The subscription branch is derived from the selected Partner and active branch scope.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Performing Partner</label>
|
||||
<select name="default_performing_partner_user_id"
|
||||
id="bulk-performing-partner"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">Same as Engagement Partner</option>
|
||||
{% for u in partners %}
|
||||
<option value="{{ u.id }}" data-branch="{{ u.branch_id }}">{{ u.full_name or u.email }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-slate-500">
|
||||
Defaults to the Engagement Partner when left blank.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Default Manager</label>
|
||||
<select name="default_manager_user_id"
|
||||
id="bulk-manager"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">Not assigned</option>
|
||||
{% for u in managers %}
|
||||
<option value="{{ u.id }}" data-branch="{{ u.branch_id }}">{{ u.full_name or u.email }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Default Staff</label>
|
||||
<select name="default_staff_user_id"
|
||||
id="bulk-staff"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">Not assigned</option>
|
||||
{% for u in staff_users %}
|
||||
<option value="{{ u.id }}" data-branch="{{ u.branch_id }}">{{ u.full_name or u.email }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="review-partner-field">
|
||||
<label class="mb-2 flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||
<span>Review Partner</span>
|
||||
<span id="review-partner-requirement"
|
||||
class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-600">
|
||||
Optional
|
||||
</span>
|
||||
</label>
|
||||
<select name="default_review_partner_user_id"
|
||||
id="bulk-review-partner"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">Not assigned</option>
|
||||
{% for u in review_partners %}
|
||||
<option value="{{ u.id }}" data-branch="{{ u.branch_id }}">{{ u.full_name or u.email }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p id="review-partner-help" class="mt-1 text-xs text-slate-500">
|
||||
Optional for non-assurance services. The selected Review Partner becomes the subscription default.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Effective From</label>
|
||||
<input type="date"
|
||||
name="effective_from"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Effective To</label>
|
||||
<input type="date"
|
||||
name="effective_to"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2 xl:col-span-3">
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Remarks applied to all subscriptions and generated engagements</label>
|
||||
<textarea name="remarks"
|
||||
rows="2"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></textarea>
|
||||
<p id="service-derived-info" class="mt-1 text-xs text-slate-500">Select a service to load the applicable subscription scope.</p>
|
||||
</div>
|
||||
<div><label class="mb-2 block text-sm font-medium">Return / Engagement Period</label><select name="period_label" id="bulk-period" class="w-full rounded-xl border px-4 py-2 text-sm"><option value="">Not applicable</option></select></div>
|
||||
<div><label class="mb-2 block text-sm font-medium">Engagement Partner</label><select name="default_partner_user_id" required class="w-full rounded-xl border px-4 py-2 text-sm"><option value="">Select partner</option>{% for u in partners %}<option value="{{ u.id }}">{{ u.full_name or u.email }}</option>{% endfor %}</select></div>
|
||||
<div><label class="mb-2 block text-sm font-medium">Performing Partner</label><select name="default_performing_partner_user_id" class="w-full rounded-xl border px-4 py-2 text-sm"><option value="">Same as Engagement Partner</option>{% for u in partners %}<option value="{{ u.id }}">{{ u.full_name or u.email }}</option>{% endfor %}</select></div>
|
||||
<div><label class="mb-2 block text-sm font-medium">Default Manager</label><select name="default_manager_user_id" class="w-full rounded-xl border px-4 py-2 text-sm"><option value="">Not assigned</option>{% for u in managers %}<option value="{{ u.id }}">{{ u.full_name or u.email }}</option>{% endfor %}</select></div>
|
||||
<div><label class="mb-2 block text-sm font-medium">Default Staff</label><select name="default_staff_user_id" class="w-full rounded-xl border px-4 py-2 text-sm"><option value="">Not assigned</option>{% for u in staff_users %}<option value="{{ u.id }}">{{ u.full_name or u.email }}</option>{% endfor %}</select></div>
|
||||
<div><label class="mb-2 block text-sm font-medium">Review Partner</label><select name="default_review_partner_user_id" id="bulk-review-partner" class="w-full rounded-xl border px-4 py-2 text-sm"><option value="">Not assigned</option>{% for u in review_partners %}<option value="{{ u.id }}">{{ u.full_name or u.email }}</option>{% endfor %}</select></div>
|
||||
<div><label class="mb-2 block text-sm font-medium">Effective From</label><input type="date" name="effective_from" class="w-full rounded-xl border px-4 py-2 text-sm"></div>
|
||||
<div><label class="mb-2 block text-sm font-medium">Effective To</label><input type="date" name="effective_to" class="w-full rounded-xl border px-4 py-2 text-sm"></div>
|
||||
<div class="md:col-span-2 xl:col-span-3"><label class="mb-2 block text-sm font-medium">Remarks applied to all subscriptions and generated engagements</label><textarea name="remarks" rows="2" class="w-full rounded-xl border px-4 py-2 text-sm"></textarea></div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<h3 class="text-base font-semibold text-slate-900">Engagement Generation</h3>
|
||||
<h3 class="font-semibold">Engagement Generation</h3>
|
||||
<div class="mt-4 grid gap-3 lg:grid-cols-3">
|
||||
<label class="flex cursor-pointer items-start gap-3 rounded-xl border border-slate-200 p-4 hover:bg-slate-50">
|
||||
<input type="radio"
|
||||
name="generation_mode"
|
||||
value="subscription_only"
|
||||
checked
|
||||
class="mt-1 border-slate-300">
|
||||
<span>
|
||||
<span class="block text-sm font-medium text-slate-900">Subscriptions only</span>
|
||||
<span class="block text-xs text-slate-500">Create or reuse the client-service master without generating engagement instances.</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="flex cursor-pointer items-start gap-3 rounded-xl border border-slate-200 p-4 hover:bg-slate-50">
|
||||
<input type="radio"
|
||||
name="generation_mode"
|
||||
value="current_period"
|
||||
class="mt-1 border-slate-300">
|
||||
<span>
|
||||
<span class="block text-sm font-medium text-slate-900">One engagement</span>
|
||||
<span class="block text-xs text-slate-500">Generate one selected month, quarter, annual, or one-time engagement for each client.</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="flex cursor-pointer items-start gap-3 rounded-xl border border-slate-200 p-4 hover:bg-slate-50">
|
||||
<input type="radio"
|
||||
name="generation_mode"
|
||||
value="all_periods"
|
||||
class="mt-1 border-slate-300">
|
||||
<span>
|
||||
<span class="block text-sm font-medium text-slate-900">All FY engagements</span>
|
||||
<span class="block text-xs text-slate-500">Generate 12 monthly, 4 quarterly, or one annual/one-time engagement for each client.</span>
|
||||
</span>
|
||||
</label>
|
||||
{% for value,title,help in [
|
||||
('subscription_only','Subscriptions only','Create or reuse the service master without generating engagements.'),
|
||||
('current_period','One engagement','Generate one selected period for each chosen scope.'),
|
||||
('all_periods','All FY engagements','Generate all monthly or quarterly periods, or one annual engagement.')
|
||||
] %}
|
||||
<label class="flex items-start gap-3 rounded-xl border p-4"><input type="radio" name="generation_mode" value="{{ value }}" {% if value=='subscription_only' %}checked{% endif %} class="mt-1"><span><span class="block text-sm font-medium">{{ title }}</span><span class="text-xs text-slate-500">{{ help }}</span></span></label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<label class="mt-4 inline-flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox"
|
||||
name="auto_generate_periods"
|
||||
value="1"
|
||||
class="rounded border-slate-300">
|
||||
Keep automatic period generation enabled on the subscription
|
||||
</label>
|
||||
<label class="mt-4 inline-flex gap-2 text-sm"><input type="checkbox" name="auto_generate_periods" value="1"> Keep automatic period generation enabled</label>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl bg-white shadow-soft">
|
||||
<div class="flex flex-wrap items-end justify-between gap-3 border-b border-slate-200 p-4">
|
||||
<div class="flex flex-wrap items-end justify-between gap-3 border-b p-4">
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Search clients</label>
|
||||
<input type="search"
|
||||
id="client-search"
|
||||
placeholder="Code, name, PAN, GSTIN or type"
|
||||
class="w-80 max-w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<button type="button"
|
||||
id="clear-client-search"
|
||||
class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-sm text-slate-600">
|
||||
<span id="selected-client-count" class="font-semibold text-slate-900">0</span> clients selected
|
||||
<div><label id="scope-search-label" class="mb-1 block text-xs font-semibold uppercase text-slate-500">Select subscription scope</label>
|
||||
<input type="search" id="scope-search" disabled placeholder="Select an enabled firm service first" class="w-96 max-w-full rounded-xl border px-3 py-2 text-sm"></div>
|
||||
<button type="button" id="clear-search" class="rounded-xl border px-4 py-2 text-sm">Clear</button>
|
||||
</div>
|
||||
<div class="text-sm"><span id="selected-count" class="font-semibold">0</span> <span id="selected-noun">items</span> selected</div>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[32rem] overflow-auto">
|
||||
<table class="min-w-full divide-y divide-slate-200">
|
||||
<thead class="sticky top-0 z-10 bg-slate-50">
|
||||
<tr>
|
||||
<th class="w-12 px-4 py-3 text-left">
|
||||
<input type="checkbox"
|
||||
id="select-all-visible"
|
||||
class="rounded border-slate-300"
|
||||
title="Select all visible clients">
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Code</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Client</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">PAN / GSTIN</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Type</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Current Partner</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="client-table-body" class="divide-y divide-slate-100">
|
||||
{% for client in clients %}
|
||||
{% set search_text = ((client.client_code or '') ~ ' ' ~ (client.client_name or '') ~ ' ' ~ (client.trade_name or '') ~ ' ' ~ (client.pan or '') ~ ' ' ~ (client.gstin or '') ~ ' ' ~ (client.client_type or ''))|lower %}
|
||||
<tr class="client-row" data-search="{{ search_text|e }}">
|
||||
<td class="px-4 py-3">
|
||||
<input type="checkbox"
|
||||
name="client_ids"
|
||||
value="{{ client.id }}"
|
||||
class="client-checkbox rounded border-slate-300">
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm font-medium text-slate-700">{{ client.client_code }}</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="font-medium text-slate-900">{{ client.client_name }}</div>
|
||||
{% if client.trade_name %}
|
||||
<div class="text-xs text-slate-500">{{ client.trade_name }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600">
|
||||
<div>PAN: {{ client.pan or '-' }}</div>
|
||||
<div>GSTIN: {{ client.gstin or '-' }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-600">{{ client.client_type or '-' }}</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600">
|
||||
{{ client_partner_names.get(client.partner_id, '-') if client.partner_id else '-' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-8 text-center text-sm text-slate-500">
|
||||
No clients are available for subscription.
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<div id="scope-message" class="p-8 text-center text-sm text-slate-500">Select an enabled firm service to load Clients, Business Units, Client Branches or registrations.</div>
|
||||
<div id="scope-table-wrap" class="hidden max-h-[32rem] overflow-auto">
|
||||
<table class="min-w-full divide-y">
|
||||
<thead class="sticky top-0 bg-slate-50"><tr id="scope-table-head"></tr></thead>
|
||||
<tbody id="scope-table-body" class="divide-y"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<p class="text-sm text-slate-600">
|
||||
Existing client-service subscriptions are reused. Existing engagements for the same client, service, financial year and period are skipped automatically.
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<a href="/services/subscriptions"
|
||||
class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||
Cancel
|
||||
</a>
|
||||
<button type="submit"
|
||||
id="create-subscriptions-button"
|
||||
class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">
|
||||
Create Subscriptions
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 rounded-2xl bg-white p-4 shadow-soft">
|
||||
<a href="/services/subscriptions" class="rounded-xl border px-4 py-2 text-sm">Cancel</a>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm text-white">Create Subscriptions</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const form = document.getElementById('bulk-subscription-form');
|
||||
const service = document.getElementById('bulk-service');
|
||||
const info = document.getElementById('service-derived-info');
|
||||
const reviewPartner = document.getElementById('bulk-review-partner');
|
||||
const reviewRequirement = document.getElementById('review-partner-requirement');
|
||||
const reviewHelp = document.getElementById('review-partner-help');
|
||||
const financialYear = document.getElementById('bulk-financial-year');
|
||||
const period = document.getElementById('bulk-period');
|
||||
const generationRadios = Array.from(document.querySelectorAll('input[name="generation_mode"]'));
|
||||
const search = document.getElementById('client-search');
|
||||
const clear = document.getElementById('clear-client-search');
|
||||
const selectAll = document.getElementById('select-all-visible');
|
||||
const count = document.getElementById('selected-client-count');
|
||||
const rows = Array.from(document.querySelectorAll('.client-row'));
|
||||
const boxes = Array.from(document.querySelectorAll('.client-checkbox'));
|
||||
(function(){
|
||||
const form=document.getElementById('bulk-subscription-form'), service=document.getElementById('bulk-service'),
|
||||
search=document.getElementById('scope-search'), clear=document.getElementById('clear-search'),
|
||||
head=document.getElementById('scope-table-head'), body=document.getElementById('scope-table-body'),
|
||||
wrap=document.getElementById('scope-table-wrap'), message=document.getElementById('scope-message'),
|
||||
count=document.getElementById('selected-count'), noun=document.getElementById('selected-noun'),
|
||||
label=document.getElementById('scope-search-label'), info=document.getElementById('service-derived-info'),
|
||||
fy=document.getElementById('bulk-financial-year'), period=document.getElementById('bulk-period'),
|
||||
review=document.getElementById('bulk-review-partner');
|
||||
let targets=[], scopeType='client', registrationType='';
|
||||
|
||||
function currentGenerationMode() {
|
||||
const selected = generationRadios.find(radio => radio.checked);
|
||||
return selected ? selected.value : 'subscription_only';
|
||||
}
|
||||
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const meta={
|
||||
client:{title:'Search Clients',noun:'clients',placeholder:'Code, client name, PAN or entity type',columns:[['client_code','Code'],['client_name','Client'],['pan','PAN'],['entity_type','Type']]},
|
||||
business_unit:{title:'Search Business Units',noun:'business units',placeholder:'Client, Business Unit, trade name, PAN or nature',columns:[['client_name','Client'],['pan','PAN'],['business_unit','Business Unit'],['trade_name','Trade Name']]},
|
||||
client_branch:{title:'Search Client Branches',noun:'client branches',placeholder:'Client, Business Unit, Client Branch, code or state',columns:[['client_name','Client'],['business_unit','Business Unit'],['client_branch','Client Branch'],['state','State']]},
|
||||
registration:{title:'Search Registrations',noun:'registrations',placeholder:'Client, business, branch, registration number or state',columns:[['client_name','Client'],['business_unit','Business Unit'],['client_branch','Client Branch'],['trade_name','Trade / Unit Name'],['registration_number','Registration'],['state','State']]}
|
||||
};
|
||||
|
||||
function financialYearParts() {
|
||||
const raw = (financialYear.value || '').trim();
|
||||
const start = parseInt(raw.split('-')[0], 10);
|
||||
return Number.isFinite(start)
|
||||
? [start, start + 1]
|
||||
: [new Date().getFullYear(), new Date().getFullYear() + 1];
|
||||
}
|
||||
|
||||
function rebuildPeriodOptions() {
|
||||
const option = service.options[service.selectedIndex];
|
||||
const recurrence = option ? (option.dataset.recurrence || '').trim().toLowerCase().replaceAll('-', '_').replaceAll(' ', '_') : '';
|
||||
const mode = currentGenerationMode();
|
||||
const requiresSelectedPeriod = mode === 'current_period' && (recurrence === 'monthly' || recurrence === 'quarterly');
|
||||
|
||||
period.innerHTML = '<option value="">Not applicable</option>';
|
||||
period.required = requiresSelectedPeriod;
|
||||
period.disabled = mode !== 'current_period';
|
||||
|
||||
if (!requiresSelectedPeriod) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [startYear, endYear] = financialYearParts();
|
||||
|
||||
if (recurrence === 'monthly') {
|
||||
const months = [
|
||||
[4, 'Apr'], [5, 'May'], [6, 'Jun'], [7, 'Jul'],
|
||||
[8, 'Aug'], [9, 'Sep'], [10, 'Oct'], [11, 'Nov'],
|
||||
[12, 'Dec'], [1, 'Jan'], [2, 'Feb'], [3, 'Mar']
|
||||
];
|
||||
months.forEach(([month, label]) => {
|
||||
const year = month >= 4 ? startYear : endYear;
|
||||
const value = `${year}-${String(month).padStart(2, '0')}`;
|
||||
period.add(new Option(`${label} ${year}`, value));
|
||||
});
|
||||
} else if (recurrence === 'quarterly') {
|
||||
['Q1', 'Q2', 'Q3', 'Q4'].forEach(quarter => {
|
||||
period.add(new Option(`${quarter} ${financialYear.value}`, quarter));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateServiceInfo() {
|
||||
const option = service.options[service.selectedIndex];
|
||||
const type = option ? (option.dataset.type || '') : '';
|
||||
const recurrence = option ? (option.dataset.recurrence || '') : '';
|
||||
const normalizedType = type.trim().toLowerCase().replaceAll('-', '_').replaceAll(' ', '_');
|
||||
const assurance = normalizedType === 'assurance';
|
||||
|
||||
reviewPartner.required = assurance;
|
||||
reviewRequirement.textContent = assurance ? 'Mandatory' : 'Optional';
|
||||
reviewRequirement.className = assurance
|
||||
? 'rounded-full bg-rose-100 px-2 py-0.5 text-xs font-semibold text-rose-700'
|
||||
: 'rounded-full bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-600';
|
||||
reviewHelp.textContent = assurance
|
||||
? 'Mandatory because the selected enabled firm service is an assurance engagement.'
|
||||
: 'Optional for non-assurance services. The selected Review Partner becomes the subscription default.';
|
||||
|
||||
info.textContent = option && option.value
|
||||
? `Type: ${assurance ? 'Assurance' : 'Non-Assurance'}${recurrence ? ' · Recurrence: ' + recurrence.replaceAll('_', ' ') : ''} · Due-date rule and workflow are taken from the service setup.`
|
||||
: 'Due-date rules, recurrence, assurance type, and task workflow are taken from the selected service setup.';
|
||||
|
||||
rebuildPeriodOptions();
|
||||
}
|
||||
|
||||
function updateVisibleRows() {
|
||||
const term = (search.value || '').trim().toLowerCase();
|
||||
rows.forEach(row => {
|
||||
row.classList.toggle('hidden', term && !row.dataset.search.includes(term));
|
||||
});
|
||||
updateSelectAllState();
|
||||
}
|
||||
|
||||
function visibleBoxes() {
|
||||
return rows
|
||||
.filter(row => !row.classList.contains('hidden'))
|
||||
.map(row => row.querySelector('.client-checkbox'));
|
||||
}
|
||||
|
||||
function updateCount() {
|
||||
count.textContent = String(boxes.filter(box => box.checked).length);
|
||||
updateSelectAllState();
|
||||
}
|
||||
|
||||
function updateSelectAllState() {
|
||||
const visible = visibleBoxes();
|
||||
const checked = visible.filter(box => box.checked).length;
|
||||
selectAll.checked = visible.length > 0 && checked === visible.length;
|
||||
selectAll.indeterminate = checked > 0 && checked < visible.length;
|
||||
}
|
||||
|
||||
service.addEventListener('change', updateServiceInfo);
|
||||
financialYear.addEventListener('change', rebuildPeriodOptions);
|
||||
generationRadios.forEach(radio => radio.addEventListener('change', rebuildPeriodOptions));
|
||||
search.addEventListener('input', updateVisibleRows);
|
||||
clear.addEventListener('click', () => {
|
||||
search.value = '';
|
||||
updateVisibleRows();
|
||||
search.focus();
|
||||
});
|
||||
selectAll.addEventListener('change', () => {
|
||||
visibleBoxes().forEach(box => {
|
||||
box.checked = selectAll.checked;
|
||||
});
|
||||
updateCount();
|
||||
});
|
||||
boxes.forEach(box => box.addEventListener('change', updateCount));
|
||||
|
||||
form.addEventListener('submit', event => {
|
||||
const selected = boxes.filter(box => box.checked).length;
|
||||
if (!selected) {
|
||||
event.preventDefault();
|
||||
alert('Select at least one client.');
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = currentGenerationMode();
|
||||
let message = `Create or reuse subscriptions for ${selected} selected client${selected === 1 ? '' : 's'}`;
|
||||
if (mode === 'current_period') {
|
||||
message += ' and generate one engagement for each?';
|
||||
} else if (mode === 'all_periods') {
|
||||
message += ' and generate all applicable FY engagements?';
|
||||
} else {
|
||||
message += '?';
|
||||
}
|
||||
|
||||
if (!confirm(message)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
updateServiceInfo();
|
||||
function checked(){return Array.from(body.querySelectorAll('.target-box:checked'));}
|
||||
function updateCount(){count.textContent=checked().length;}
|
||||
function render(){
|
||||
const m=meta[scopeType]||meta.client;
|
||||
label.textContent=registrationType?`Search ${registrationType} Registrations`:m.title;
|
||||
noun.textContent=registrationType?`${registrationType} registrations`:m.noun;
|
||||
search.placeholder=m.placeholder;
|
||||
head.innerHTML=`<th class="w-12 px-4 py-3"><input type="checkbox" id="select-all"></th>`+
|
||||
m.columns.map(c=>`<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">${esc(c[1])}</th>`).join('')+
|
||||
`<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Subscription Status</th>`;
|
||||
const term=search.value.trim().toLowerCase();
|
||||
const visible=targets.filter(t=>!term||Object.values(t).join(' ').toLowerCase().includes(term));
|
||||
body.innerHTML=visible.map(t=>`<tr>
|
||||
<td class="px-4 py-3"><input class="target-box" type="checkbox" name="scope_targets" value="${esc(t.token)}"></td>
|
||||
${m.columns.map(c=>`<td class="px-4 py-3 text-sm ${c[0]==='client_name'||c[0]==='business_unit'||c[0]==='client_branch'||c[0]==='registration_number'?'font-medium':''}">${esc(t[c[0]]||'-')}</td>`).join('')}
|
||||
<td class="px-4 py-3 text-xs"><span class="rounded-full px-2 py-1 ${t.subscription_status==='not_subscribed'?'bg-slate-100 text-slate-600':'bg-emerald-100 text-emerald-700'}">${esc(t.subscription_status.replaceAll('_',' '))}</span></td>
|
||||
</tr>`).join('');
|
||||
document.querySelectorAll('.target-box').forEach(x=>x.addEventListener('change',updateCount));
|
||||
const all=document.getElementById('select-all');
|
||||
if(all) all.addEventListener('change',()=>{document.querySelectorAll('.target-box').forEach(x=>x.checked=all.checked);updateCount();});
|
||||
updateCount();
|
||||
}
|
||||
|
||||
async function loadTargets(){
|
||||
const id=service.value;
|
||||
targets=[]; body.innerHTML=''; count.textContent='0';
|
||||
if(!id){wrap.classList.add('hidden');message.classList.remove('hidden');search.disabled=true;return;}
|
||||
search.disabled=true; message.textContent='Loading applicable subscription scope...';message.classList.remove('hidden');wrap.classList.add('hidden');
|
||||
const response=await fetch(`/services/subscriptions/bulk/targets?service_catalogue_id=${encodeURIComponent(id)}`,{headers:{'Accept':'application/json'}});
|
||||
const data=await response.json();
|
||||
if(!response.ok){message.textContent=data.error||'Unable to load subscription scope.';return;}
|
||||
scopeType=data.scope_type||'client';registrationType=data.registration_type||'';targets=data.targets||[];
|
||||
search.disabled=false;message.classList.toggle('hidden',targets.length>0);wrap.classList.toggle('hidden',targets.length===0);
|
||||
if(!targets.length)message.textContent='No permitted scope records are available. Add the required Business Unit, Client Branch or Registration in the Client Business Structure page.';
|
||||
render();
|
||||
}
|
||||
|
||||
function updateService(){
|
||||
const o=service.options[service.selectedIndex], type=o?.dataset.type||'', recurrence=o?.dataset.recurrence||'', scope=o?.dataset.scope||'client', reg=o?.dataset.registration||'';
|
||||
review.required=type==='assurance';
|
||||
info.textContent=o&&o.value?`Scope: ${scope.replaceAll('_',' ')}${reg?' — '+reg:''} · Type: ${type.replaceAll('_',' ')} · Recurrence: ${recurrence.replaceAll('_',' ')||'one time'}`:'Select a service to load the applicable subscription scope.';
|
||||
rebuildPeriods(recurrence);
|
||||
loadTargets();
|
||||
}
|
||||
function rebuildPeriods(recurrence){
|
||||
const mode=document.querySelector('input[name="generation_mode"]:checked').value;
|
||||
period.innerHTML='<option value="">Not applicable</option>';period.disabled=mode!=='current_period';period.required=false;
|
||||
const start=parseInt((fy.value||'').split('-')[0]);
|
||||
if(mode!=='current_period'||!start)return;
|
||||
if(recurrence==='monthly'){[['04','Apr',start],['05','May',start],['06','Jun',start],['07','Jul',start],['08','Aug',start],['09','Sep',start],['10','Oct',start],['11','Nov',start],['12','Dec',start],['01','Jan',start+1],['02','Feb',start+1],['03','Mar',start+1]].forEach(r=>period.add(new Option(`${r[1]} ${r[2]}`,`${r[2]}-${r[0]}`)));period.required=true;}
|
||||
else if(recurrence==='quarterly'){['Q1','Q2','Q3','Q4'].forEach(q=>period.add(new Option(`${q} ${fy.value}`,q)));period.required=true;}
|
||||
}
|
||||
service.addEventListener('change',updateService);search.addEventListener('input',render);clear.addEventListener('click',()=>{search.value='';render();search.focus();});
|
||||
fy.addEventListener('input',updateService);document.querySelectorAll('input[name="generation_mode"]').forEach(x=>x.addEventListener('change',updateService));
|
||||
form.addEventListener('submit',e=>{if(!checked().length){e.preventDefault();alert('Select at least one applicable subscription scope.');}});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user