Add multi-client bulk engagement setup
This commit is contained in:
@@ -18,6 +18,7 @@ from app.modules.services.execution import (
|
||||
reopen_engagement_closure,
|
||||
)
|
||||
from app.modules.clients.models import Client
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.services.client_services import (
|
||||
SUBSCRIPTION_STATUSES,
|
||||
assessment_year_from_financial_year,
|
||||
@@ -146,7 +147,17 @@ def _lock_subscription_row(row: ClientServiceSubscription, user) -> bool:
|
||||
|
||||
|
||||
@router.get("")
|
||||
def subscription_list(request: Request, q: str = "", financial_year: str = "", include_inactive: bool = True, locked: int = 0, skipped: int = 0):
|
||||
def subscription_list(
|
||||
request: Request,
|
||||
q: str = "",
|
||||
financial_year: str = "",
|
||||
include_inactive: bool = True,
|
||||
locked: int = 0,
|
||||
skipped: int = 0,
|
||||
bulk_created: int = 0,
|
||||
bulk_existing: int = 0,
|
||||
bulk_skipped: int = 0,
|
||||
):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
@@ -180,6 +191,9 @@ def subscription_list(request: Request, q: str = "", financial_year: str = "", i
|
||||
include_inactive=include_inactive,
|
||||
locked_count=locked,
|
||||
skipped_count=skipped,
|
||||
bulk_created_count=bulk_created,
|
||||
bulk_existing_count=bulk_existing,
|
||||
bulk_skipped_count=bulk_skipped,
|
||||
can_manage=_can_manage_client_services(db, user),
|
||||
can_lock_engagements=_can_lock_engagements(db, user),
|
||||
)
|
||||
@@ -327,6 +341,225 @@ def subscription_create_submit(
|
||||
db.close()
|
||||
|
||||
|
||||
|
||||
@router.get("/bulk-new")
|
||||
def subscription_bulk_create_page(request: Request, error: str = ""):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
try:
|
||||
require_permission(db, user, "clients.edit")
|
||||
except Exception:
|
||||
return _redirect_denied()
|
||||
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
clients = list_clients_for_assignment(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
partner_id=_locked_partner_id(db, user),
|
||||
)
|
||||
enabled_services = list_enabled_services_for_assignment(db, tenant_id=tenant_id)
|
||||
partners = list_assignable_users(db, tenant_id=tenant_id, role_names=("Partner",))
|
||||
managers = list_assignable_users(db, tenant_id=tenant_id, role_names=("Branch Manager",))
|
||||
staff_users = list_assignable_users(db, tenant_id=tenant_id, role_names=("Staff",))
|
||||
review_partners = list_review_partners(db, tenant_id=tenant_id)
|
||||
|
||||
return _render(
|
||||
request,
|
||||
"modules/services/templates/services/engagements/bulk_form.html",
|
||||
db,
|
||||
user,
|
||||
title="Bulk Engagement Setup",
|
||||
clients=clients,
|
||||
enabled_services=enabled_services,
|
||||
partners=partners,
|
||||
client_partner_names={row.id: (row.full_name or row.email) for row in partners},
|
||||
managers=managers,
|
||||
staff_users=staff_users,
|
||||
review_partners=review_partners,
|
||||
financial_year=_active_financial_year(request),
|
||||
error_message={
|
||||
"service": "Select a valid enabled firm service.",
|
||||
"partner": "Select a valid active Partner.",
|
||||
"manager": "The selected Manager is not available for this firm.",
|
||||
"staff": "The selected Staff member is not available for this firm.",
|
||||
"review_partner": "The selected Review Partner is not available for this firm.",
|
||||
"clients": "Select at least one permitted client.",
|
||||
}.get(error),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/bulk-new")
|
||||
def subscription_bulk_create_submit(
|
||||
request: Request,
|
||||
client_ids: list[int] = Form([]),
|
||||
service_catalogue_id: int = Form(...),
|
||||
assigned_partner_user_id: int = Form(...),
|
||||
assigned_manager_user_id: str = Form(""),
|
||||
assigned_staff_user_id: str = Form(""),
|
||||
review_partner_user_id: str = Form(""),
|
||||
financial_year: str = Form(""),
|
||||
remarks: str = Form(""),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
created_count = 0
|
||||
existing_count = 0
|
||||
skipped_count = 0
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
try:
|
||||
require_permission(db, user, "clients.edit")
|
||||
except Exception:
|
||||
return _redirect_denied()
|
||||
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
selected_financial_year = normalize_financial_year(financial_year or _active_financial_year(request))
|
||||
locked_response = redirect_if_financial_year_locked(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
year_code=selected_financial_year,
|
||||
redirect_url=f"/services/engagements?financial_year={selected_financial_year}",
|
||||
)
|
||||
if locked_response:
|
||||
return locked_response
|
||||
|
||||
firm_selection = get_enabled_firm_service(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
service_catalogue_id=service_catalogue_id,
|
||||
)
|
||||
if not firm_selection:
|
||||
return RedirectResponse(url="/services/engagements/bulk-new?error=service", status_code=303)
|
||||
|
||||
partners = list_assignable_users(db, tenant_id=tenant_id, role_names=("Partner",))
|
||||
managers = list_assignable_users(db, tenant_id=tenant_id, role_names=("Branch Manager",))
|
||||
staff_users = list_assignable_users(db, tenant_id=tenant_id, role_names=("Staff",))
|
||||
review_partners = list_review_partners(db, tenant_id=tenant_id)
|
||||
|
||||
partner_ids = {row.id for row in partners}
|
||||
manager_ids = {row.id for row in managers}
|
||||
staff_ids = {row.id for row in staff_users}
|
||||
review_partner_ids = {row.id for row in review_partners}
|
||||
|
||||
if assigned_partner_user_id not in partner_ids:
|
||||
return RedirectResponse(url="/services/engagements/bulk-new?error=partner", status_code=303)
|
||||
|
||||
manager_id = int(assigned_manager_user_id) if assigned_manager_user_id.strip() else None
|
||||
staff_id = int(assigned_staff_user_id) if assigned_staff_user_id.strip() else None
|
||||
review_partner_id = int(review_partner_user_id) if review_partner_user_id.strip() else None
|
||||
if manager_id is not None and manager_id not in manager_ids:
|
||||
return RedirectResponse(url="/services/engagements/bulk-new?error=manager", status_code=303)
|
||||
if staff_id is not None and staff_id not in staff_ids:
|
||||
return RedirectResponse(url="/services/engagements/bulk-new?error=staff", status_code=303)
|
||||
if review_partner_id is not None and review_partner_id not in review_partner_ids:
|
||||
return RedirectResponse(url="/services/engagements/bulk-new?error=review_partner", status_code=303)
|
||||
|
||||
partner = db.get(User, assigned_partner_user_id)
|
||||
if not partner or partner.tenant_id != tenant_id or not partner.is_active:
|
||||
return RedirectResponse(url="/services/engagements/bulk-new?error=partner", status_code=303)
|
||||
engagement_branch_id = partner.branch_id
|
||||
|
||||
allowed_clients = list_clients_for_assignment(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=_active_branch_id(request, user, db),
|
||||
partner_id=_locked_partner_id(db, user),
|
||||
)
|
||||
allowed_client_ids = {row.id for row in allowed_clients}
|
||||
selected_client_ids = []
|
||||
seen_client_ids = set()
|
||||
for value in client_ids:
|
||||
client_id = int(value)
|
||||
if client_id in seen_client_ids:
|
||||
continue
|
||||
seen_client_ids.add(client_id)
|
||||
if client_id in allowed_client_ids:
|
||||
selected_client_ids.append(client_id)
|
||||
else:
|
||||
skipped_count += 1
|
||||
|
||||
if not selected_client_ids:
|
||||
return RedirectResponse(url="/services/engagements/bulk-new?error=clients", status_code=303)
|
||||
|
||||
engagement_type = getattr(firm_selection.catalogue, "engagement_type", "non_assurance") or "non_assurance"
|
||||
requires_review_partner = review_partner_required_for_engagement(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
engagement_type=engagement_type,
|
||||
)
|
||||
|
||||
for client_id in selected_client_ids:
|
||||
existing = get_existing_subscription(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
service_catalogue_id=service_catalogue_id,
|
||||
financial_year=selected_financial_year,
|
||||
)
|
||||
if existing:
|
||||
existing_count += 1
|
||||
continue
|
||||
|
||||
client = db.get(Client, client_id)
|
||||
if not client or client.tenant_id != tenant_id:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
row = ClientServiceSubscription(
|
||||
tenant_id=tenant_id,
|
||||
branch_id=engagement_branch_id,
|
||||
client_id=client_id,
|
||||
service_catalogue_id=service_catalogue_id,
|
||||
firm_service_selection_id=firm_selection.id,
|
||||
assigned_partner_user_id=assigned_partner_user_id,
|
||||
assigned_manager_user_id=manager_id,
|
||||
assigned_staff_user_id=staff_id,
|
||||
review_partner_user_id=(
|
||||
review_partner_id or getattr(client, "default_review_partner_user_id", None)
|
||||
if requires_review_partner
|
||||
else None
|
||||
),
|
||||
financial_year=selected_financial_year,
|
||||
assessment_year=assessment_year_from_financial_year(selected_financial_year),
|
||||
engagement_type=engagement_type,
|
||||
status="active",
|
||||
remarks=remarks.strip() or None,
|
||||
is_active=True,
|
||||
created_by_user_id=user.id,
|
||||
updated_by_user_id=user.id,
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
apply_due_date_rule_to_subscription(db, row)
|
||||
ensure_engagement_quality_workflow(db, subscription=row, actor_user_id=user.id, create_declarations=False)
|
||||
enforce_quality_gate_on_subscription(row)
|
||||
created_count += 1
|
||||
|
||||
db.commit()
|
||||
return RedirectResponse(
|
||||
url=(
|
||||
f"/services/engagements?financial_year={selected_financial_year}"
|
||||
f"&bulk_created={created_count}&bulk_existing={existing_count}&bulk_skipped={skipped_count}"
|
||||
),
|
||||
status_code=303,
|
||||
)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/bulk-lock")
|
||||
def subscription_bulk_lock(
|
||||
request: Request,
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Bulk Engagement Setup</h2>
|
||||
<p class="text-sm text-slate-500">Choose one enabled firm service, assign the engagement team, and create the same engagement for multiple clients.</p>
|
||||
</div>
|
||||
<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">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 %}
|
||||
|
||||
<form method="post" action="/services/engagements/bulk-new" id="bulk-engagement-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" 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">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">
|
||||
<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 engagement branch is derived automatically from the selected Partner’s branch.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Default Manager</label>
|
||||
<select name="assigned_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="assigned_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" class="hidden">
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Review Partner</label>
|
||||
<select name="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="">Use client default / 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 class="mt-1 text-xs text-slate-500">Used only when the selected service is an assurance engagement and the firm setup requires a review partner.</p>
|
||||
</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 created 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 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 assignment.</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 engagements for the same client, service and financial year 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>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const form = document.getElementById('bulk-engagement-form');
|
||||
const service = document.getElementById('bulk-service');
|
||||
const info = document.getElementById('service-derived-info');
|
||||
const reviewField = document.getElementById('review-partner-field');
|
||||
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 updateServiceInfo() {
|
||||
const option = service.options[service.selectedIndex];
|
||||
const type = option ? (option.dataset.type || '') : '';
|
||||
const recurrence = option ? (option.dataset.recurrence || '') : '';
|
||||
const assurance = type.toLowerCase() === 'assurance';
|
||||
reviewField.classList.toggle('hidden', !assurance);
|
||||
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.';
|
||||
}
|
||||
|
||||
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);
|
||||
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;
|
||||
}
|
||||
if (!confirm(`Create engagements for ${selected} selected client${selected === 1 ? '' : 's'}?`)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
updateServiceInfo();
|
||||
updateCount();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -13,12 +13,21 @@
|
||||
</div>
|
||||
{% if can_manage %}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/services/engagements/bulk-new" class="rounded-xl border border-brand-300 px-4 py-2 text-sm font-medium text-brand-700 hover:bg-brand-50">Bulk Engagement Setup</a>
|
||||
<a href="/services/bulk-imports" class="rounded-xl border border-emerald-300 px-4 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50">Bulk Assign by Excel</a>
|
||||
<a href="/services/engagements/new" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Assign Service</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if bulk_created_count or bulk_existing_count or bulk_skipped_count %}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 text-sm shadow-soft">
|
||||
{% if bulk_created_count %}<span class="font-medium text-emerald-700">{{ bulk_created_count }} engagement{{ 's' if bulk_created_count != 1 else '' }} created.</span>{% endif %}
|
||||
{% if bulk_existing_count %}<span class="ml-2 font-medium text-amber-700">{{ bulk_existing_count }} already existed and were preserved.</span>{% endif %}
|
||||
{% if bulk_skipped_count %}<span class="ml-2 font-medium text-slate-600">{{ bulk_skipped_count }} skipped because they were unavailable or not permitted.</span>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if locked_count or skipped_count %}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 text-sm shadow-soft">
|
||||
{% if locked_count %}<span class="font-medium text-emerald-700">{{ locked_count }} engagement{{ 's' if locked_count != 1 else '' }} locked.</span>{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user