Add periodic engagements and statutory due dates

This commit is contained in:
A R R R Associates
2026-08-05 12:31:39 +05:30
parent dd2a69dbaa
commit c35c89982e
9 changed files with 415 additions and 94 deletions
@@ -19,7 +19,7 @@
<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" 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">
<input type="text" id="bulk-financial-year" name="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">
@@ -35,6 +35,18 @@
<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>
<label id="bulk-generate-all-wrap" class="mt-2 hidden items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="generate_all_periods" id="bulk-generate-all" value="1" class="rounded border-slate-300">
Generate all periods for the financial year
</label>
<p class="mt-1 text-xs text-slate-500">For monthly or quarterly services, due dates are calculated separately for each generated period.</p>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-slate-700">Engagement Partner</label>
<select name="assigned_partner_user_id" id="bulk-partner" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
@@ -131,7 +143,7 @@
</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 engagements for the same client, service and financial year are skipped automatically. No existing engagement is overwritten.</p>
<p class="text-sm text-slate-600">Existing engagements for the same client, service, financial year and period are skipped automatically. No existing engagement is overwritten.</p>
<div class="flex gap-3">
<a href="/services/engagements" 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-engagements-button" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Create Engagements</button>
@@ -144,6 +156,10 @@
(function () {
const form = document.getElementById('bulk-engagement-form');
const service = document.getElementById('bulk-service');
const fy = document.getElementById('bulk-financial-year');
const period = document.getElementById('bulk-period');
const generateAll = document.getElementById('bulk-generate-all');
const generateWrap = document.getElementById('bulk-generate-all-wrap');
const info = document.getElementById('service-derived-info');
const reviewPartner = document.getElementById('bulk-review-partner');
const reviewRequirement = document.getElementById('review-partner-requirement');
@@ -169,6 +185,21 @@
reviewHelp.textContent = assurance
? 'Mandatory because the selected enabled firm service is an assurance engagement.'
: 'Optional for non-assurance engagements. A selected Review Partner is saved for every engagement created in this batch.';
const fyMatch = (fy.value || '').trim().match(/^(\d{4})-(\d{2}|\d{4})$/);
const startYear = fyMatch ? Number(fyMatch[1]) : null;
period.innerHTML = '';
generateAll.checked = false;
period.disabled = false;
function addPeriod(value, label) { const o = document.createElement('option'); o.value = value; o.textContent = label; period.appendChild(o); }
if (recurrence === 'monthly' && startYear) {
[['04','Apr',startYear],['05','May',startYear],['06','Jun',startYear],['07','Jul',startYear],['08','Aug',startYear],['09','Sep',startYear],['10','Oct',startYear],['11','Nov',startYear],['12','Dec',startYear],['01','Jan',startYear+1],['02','Feb',startYear+1],['03','Mar',startYear+1]].forEach(row => addPeriod(`${row[2]}-${row[0]}`, `${row[1]} ${row[2]}`));
period.required = true; generateWrap.classList.remove('hidden'); generateWrap.classList.add('flex');
} else if (recurrence === 'quarterly' && startYear) {
['Q1','Q2','Q3','Q4'].forEach(q => addPeriod(q, `${q} ${fy.value}`));
period.required = true; generateWrap.classList.remove('hidden'); generateWrap.classList.add('flex');
} else {
addPeriod('', 'Not applicable'); period.required = false; generateWrap.classList.add('hidden'); generateWrap.classList.remove('flex');
}
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.';
@@ -199,6 +230,8 @@
}
service.addEventListener('change', updateServiceInfo);
fy.addEventListener('input', updateServiceInfo);
generateAll.addEventListener('change', () => { period.disabled = generateAll.checked; });
search.addEventListener('input', updateVisibleRows);
clear.addEventListener('click', () => { search.value = ''; updateVisibleRows(); search.focus(); });
selectAll.addEventListener('change', () => { visibleBoxes().forEach(box => { box.checked = selectAll.checked; }); updateCount(); });
@@ -10,7 +10,7 @@
</div>
{% if row.is_locked %}<div class="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">This engagement is locked as historical record. It cannot be edited.</div>{% endif %}
<div class="grid gap-6 lg:grid-cols-2">
<section class="rounded-2xl bg-white p-5 shadow-soft"><h3 class="text-sm font-semibold text-slate-900">Client & Service</h3><dl class="mt-4 space-y-3 text-sm"><div><dt class="text-slate-500">Client</dt><dd class="font-medium text-slate-900">{{ row.client.client_name if row.client else '-' }}</dd></div><div><dt class="text-slate-500">Service</dt><dd class="font-medium text-slate-900">{{ row.catalogue.service_name if row.catalogue else '-' }}</dd></div><div><dt class="text-slate-500">Financial Year</dt><dd>{{ row.financial_year or '-' }}</dd></div><div><dt class="text-slate-500">Assessment Year</dt><dd>{{ row.assessment_year or '-' }}</dd></div><div><dt class="text-slate-500">Engagement Type</dt><dd>{{ 'Assurance' if row.engagement_type == 'assurance' else 'Non-Assurance' }}</dd></div><div><dt class="text-slate-500">Original Due Date</dt><dd>{{ row.original_due_date or '-' }}</dd></div><div><dt class="text-slate-500">Expiry Date</dt><dd>{{ row.expiry_date or '-' }}</dd></div><div><dt class="text-slate-500">Current Due Date</dt><dd class="font-medium text-slate-900">{{ row.current_due_date or '-' }}{% if row.due_date_source %}<span class="ml-2 rounded-full bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">{{ row.due_date_source|replace('_',' ')|title }}</span>{% endif %}</dd></div><div><dt class="text-slate-500">Status</dt><dd>{{ 'Locked' if row.is_locked else row.status|replace('_',' ')|title }}{% if not row.is_active %} / Inactive{% endif %}</dd></div><div><dt class="text-slate-500">Period</dt><dd>{{ row.start_date or '-' }} to {{ row.end_date or '-' }}</dd></div></dl></section>
<section class="rounded-2xl bg-white p-5 shadow-soft"><h3 class="text-sm font-semibold text-slate-900">Client & Service</h3><dl class="mt-4 space-y-3 text-sm"><div><dt class="text-slate-500">Client</dt><dd class="font-medium text-slate-900">{{ row.client.client_name if row.client else '-' }}</dd></div><div><dt class="text-slate-500">Service</dt><dd class="font-medium text-slate-900">{{ row.catalogue.service_name if row.catalogue else '-' }}</dd></div><div><dt class="text-slate-500">Financial Year</dt><dd>{{ row.financial_year or '-' }}</dd></div><div><dt class="text-slate-500">Assessment Year</dt><dd>{{ row.assessment_year or '-' }}</dd></div><div><dt class="text-slate-500">Return / Engagement Period</dt><dd>{{ row.period_label or 'Not applicable' }}</dd></div><div><dt class="text-slate-500">Engagement Type</dt><dd>{{ 'Assurance' if row.engagement_type == 'assurance' else 'Non-Assurance' }}</dd></div><div><dt class="text-slate-500">Original Due Date</dt><dd>{{ row.original_due_date or '-' }}</dd></div><div><dt class="text-slate-500">Expiry Date</dt><dd>{{ row.expiry_date or '-' }}</dd></div><div><dt class="text-slate-500">Current Due Date</dt><dd class="font-medium text-slate-900">{{ row.current_due_date or '-' }}{% if row.due_date_source %}<span class="ml-2 rounded-full bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">{{ row.due_date_source|replace('_',' ')|title }}</span>{% endif %}</dd></div><div><dt class="text-slate-500">Status</dt><dd>{{ 'Locked' if row.is_locked else row.status|replace('_',' ')|title }}{% if not row.is_active %} / Inactive{% endif %}</dd></div><div><dt class="text-slate-500">Engagement Dates</dt><dd>{{ row.start_date or '-' }} to {{ row.end_date or '-' }}</dd></div></dl></section>
<section class="rounded-2xl bg-white p-5 shadow-soft"><h3 class="text-sm font-semibold text-slate-900">Assignment</h3><dl class="mt-4 space-y-3 text-sm"><div><dt class="text-slate-500">Partner</dt><dd>{{ row.assigned_partner.full_name if row.assigned_partner and row.assigned_partner.full_name else (row.assigned_partner.email if row.assigned_partner else '-') }}</dd></div><div><dt class="text-slate-500">Performing Partner</dt><dd>{{ row.performing_partner.full_name if row.performing_partner and row.performing_partner.full_name else (row.performing_partner.email if row.performing_partner else (row.assigned_partner.full_name if row.assigned_partner and row.assigned_partner.full_name else (row.assigned_partner.email if row.assigned_partner else '-'))) }}</dd></div><div><dt class="text-slate-500">Manager</dt><dd>{{ row.assigned_manager.full_name if row.assigned_manager and row.assigned_manager.full_name else (row.assigned_manager.email if row.assigned_manager else '-') }}</dd></div><div><dt class="text-slate-500">Staff</dt><dd>{{ row.assigned_staff.full_name if row.assigned_staff and row.assigned_staff.full_name else (row.assigned_staff.email if row.assigned_staff else '-') }}</dd></div><div><dt class="text-slate-500">Review Partner</dt><dd>{{ row.review_partner.full_name if row.review_partner and row.review_partner.full_name else (row.review_partner.email if row.review_partner else '-') }}</dd></div></dl></section>
</div>
@@ -17,7 +17,7 @@
{% if mode == 'edit' %}
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-2 text-sm text-slate-700">{{ subscription.financial_year }}</div>
{% else %}
<input type="text" name="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">
<input type="text" id="engagement-financial-year" name="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">
{% endif %}
</div>
@@ -40,15 +40,33 @@
{{ subscription.catalogue.service_name if subscription and subscription.catalogue else '-' }} · {{ 'Assurance' if subscription and subscription.engagement_type == 'assurance' else 'Non-Assurance' }}
</div>
{% else %}
<select name="service_catalogue_id" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
<select name="service_catalogue_id" id="engagement-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 }}">{{ selection.catalogue.service_name }} ({{ selection.catalogue.service_code }}) - {{ 'Assurance' if selection.catalogue.engagement_type == 'assurance' else 'Non-Assurance' }}</option>
<option value="{{ selection.catalogue.id }}" data-recurrence="{{ selection.catalogue.recurrence_type or '' }}">{{ selection.catalogue.service_name }} ({{ selection.catalogue.service_code }}) - {{ 'Assurance' if selection.catalogue.engagement_type == 'assurance' else 'Non-Assurance' }}</option>
{% endfor %}
</select>
{% endif %}
</div>
<div id="engagement-period-field">
<label class="mb-2 block text-sm font-medium text-slate-700">Return / Engagement Period</label>
{% if mode == 'edit' %}
<select name="period_label" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
{% for value, label in period_choices %}<option value="{{ value }}" {% if subscription and subscription.period_label == value %}selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
{% else %}
<select name="period_label" id="engagement-period" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
<option value="">Not applicable</option>
</select>
<label id="generate-all-periods-wrap" class="mt-2 hidden items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="generate_all_periods" id="generate-all-periods" value="1" class="rounded border-slate-300">
Generate all periods for this financial year
</label>
{% endif %}
<p class="mt-1 text-xs text-slate-500">Monthly and quarterly services require a period so the statutory due date can be calculated.</p>
</div>
<div><label class="mb-2 block text-sm font-medium text-slate-700">Assigned Partner</label><select name="assigned_partner_user_id" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"><option value="">Not assigned</option>{% for u in assignable_users %}<option value="{{ u.id }}" {% if subscription and subscription.assigned_partner_user_id == u.id %}selected{% endif %}>{{ u.full_name or u.email }}</option>{% endfor %}</select></div>
<div><label class="mb-2 block text-sm font-medium text-slate-700">Performing Partner</label><select name="performing_partner_user_id" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"><option value="">Use client default / Engagement Partner</option>{% for u in performing_partners or [] %}<option value="{{ u.id }}" {% if subscription and subscription.performing_partner_user_id == u.id %}selected{% endif %}>{{ u.full_name or u.email }}</option>{% endfor %}</select><p class="mt-1 text-xs text-slate-500">The Partner who directly performs or supervises this engagement.</p></div>
<div><label class="mb-2 block text-sm font-medium text-slate-700">Assigned Manager</label><select name="assigned_manager_user_id" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"><option value="">Not assigned</option>{% for u in assignable_users %}<option value="{{ u.id }}" {% if subscription and subscription.assigned_manager_user_id == u.id %}selected{% endif %}>{{ u.full_name or u.email }}</option>{% endfor %}</select></div>
@@ -64,4 +82,43 @@
<div class="md:col-span-2 flex justify-end gap-3"><a href="/services/engagements" 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>
{% if mode != 'edit' %}
<script>
(function () {
const service = document.getElementById('engagement-service');
const fy = document.getElementById('engagement-financial-year');
const period = document.getElementById('engagement-period');
const generateWrap = document.getElementById('generate-all-periods-wrap');
const generateAll = document.getElementById('generate-all-periods');
if (!service || !fy || !period) return;
function fyStart() {
const match = (fy.value || '').trim().match(/^(\d{4})-(\d{2}|\d{4})$/);
return match ? Number(match[1]) : null;
}
function add(value, label) {
const option = document.createElement('option'); option.value = value; option.textContent = label; period.appendChild(option);
}
function rebuild() {
const selected = service.options[service.selectedIndex];
const recurrence = ((selected && selected.dataset.recurrence) || '').toLowerCase().replaceAll('-', '_').replaceAll(' ', '_');
const start = fyStart();
period.innerHTML = '';
generateAll.checked = false;
if (recurrence === 'monthly' && start) {
[['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(row => add(`${row[2]}-${row[0]}`, `${row[1]} ${row[2]}`));
period.required = true; generateWrap.classList.remove('hidden'); generateWrap.classList.add('flex');
} else if (recurrence === 'quarterly' && start) {
['Q1','Q2','Q3','Q4'].forEach(q => add(q, `${q} ${fy.value}`));
period.required = true; generateWrap.classList.remove('hidden'); generateWrap.classList.add('flex');
} else {
add('', 'Not applicable'); period.required = false; generateWrap.classList.add('hidden'); generateWrap.classList.remove('flex');
}
}
generateAll.addEventListener('change', () => { period.disabled = generateAll.checked; });
service.addEventListener('change', rebuild); fy.addEventListener('input', rebuild); rebuild();
})();
</script>
{% endif %}
{% endblock %}
@@ -84,7 +84,7 @@
{% if can_lock_engagements %}<td class="px-4 py-3 text-sm">{% if not row.is_locked %}<input type="checkbox" class="engagement-lock-checkbox rounded border-slate-300" name="subscription_ids" value="{{ row.id }}">{% endif %}</td>{% endif %}
<td class="px-4 py-3 text-sm"><div class="font-medium text-slate-900">{{ row.client.client_name if row.client else '-' }}</div><div class="text-xs text-slate-500">{{ row.client.client_code if row.client else '' }}</div></td>
<td class="px-4 py-3 text-sm"><div class="font-medium text-slate-900">{{ row.catalogue.service_name if row.catalogue else '-' }}</div><div class="text-xs text-slate-500">{{ row.catalogue.service_code if row.catalogue else '' }}</div></td>
<td class="px-4 py-3 text-xs text-slate-600"><div>FY: {{ row.financial_year or '-' }}</div><div>AY: {{ row.assessment_year or '-' }}</div></td>
<td class="px-4 py-3 text-xs text-slate-600"><div>FY: {{ row.financial_year or '-' }}</div><div>AY: {{ row.assessment_year or '-' }}</div><div>Period: {{ row.period_label or '-' }}</div></td>
<td class="px-4 py-3 text-xs text-slate-600"><div>Current: <span class="font-medium text-slate-900">{{ row.current_due_date or '-' }}</span></div><div>Original: {{ row.original_due_date or '-' }}</div>{% if row.expiry_date %}<div>Expiry: {{ row.expiry_date }}</div>{% endif %}{% if row.due_date_source %}<div class="text-slate-500">{{ row.due_date_source|replace('_',' ')|title }}</div>{% endif %}</td>
<td class="px-4 py-3 text-sm text-slate-700">{{ 'Assurance' if row.engagement_type == 'assurance' else 'Non-Assurance' }}</td>
<td class="px-4 py-3 text-xs text-slate-600"><div>Partner: {{ row.assigned_partner.full_name if row.assigned_partner and row.assigned_partner.full_name else (row.assigned_partner.email if row.assigned_partner else '-') }}</div><div>Performing: {{ row.performing_partner.full_name if row.performing_partner and row.performing_partner.full_name else (row.performing_partner.email if row.performing_partner else (row.assigned_partner.full_name if row.assigned_partner and row.assigned_partner.full_name else (row.assigned_partner.email if row.assigned_partner else '-'))) }}</div><div>Manager: {{ row.assigned_manager.full_name if row.assigned_manager and row.assigned_manager.full_name else (row.assigned_manager.email if row.assigned_manager else '-') }}</div><div>Staff: {{ row.assigned_staff.full_name if row.assigned_staff and row.assigned_staff.full_name else (row.assigned_staff.email if row.assigned_staff else '-') }}</div><div>Review: {{ row.review_partner.full_name if row.review_partner and row.review_partner.full_name else (row.review_partner.email if row.review_partner else '-') }}</div></td>