Align bulk subscriptions with bulk engagement setup

This commit is contained in:
A R R R Associates
2026-08-05 18:31:30 +05:30
parent 47cd5c2cf3
commit 9b307ebcdd
3 changed files with 876 additions and 1 deletions
@@ -0,0 +1,494 @@
{% extends "ui/templates/base/layout.html" %}
{% 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 %}
<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>
{% 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 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 %}
<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">
<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 '' }}">
{{ selection.catalogue.service_name }} ({{ selection.catalogue.service_code }})
</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>
</div>
</div>
<div class="rounded-2xl bg-white p-5 shadow-soft">
<h3 class="text-base font-semibold text-slate-900">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>
</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>
</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 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>
</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>
</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>
</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 currentGenerationMode() {
const selected = generationRadios.find(radio => radio.checked);
return selected ? selected.value : 'subscription_only';
}
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();
updateCount();
})();
</script>
{% endblock %}