Add bulk firm selection to service catalogue

This commit is contained in:
A R R R Associates
2026-08-03 17:23:38 +05:30
parent 6224b721c9
commit 34c258e26e
2 changed files with 226 additions and 1 deletions
@@ -19,6 +19,20 @@
</div> </div>
</div> </div>
{% if request.query_params.get('bulk_action') == 'enable' or request.query_params.get('bulk_action') == 'disable' %}
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">
Bulk update completed: <span class="font-semibold">{{ request.query_params.get('bulk_updated', '0') }}</span> changed,
<span class="font-semibold">{{ request.query_params.get('bulk_unchanged', '0') }}</span> already in the requested state,
and <span class="font-semibold">{{ request.query_params.get('bulk_skipped', '0') }}</span> skipped.
</div>
{% elif request.query_params.get('bulk_action') == 'no_selection' %}
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">Select at least one service before applying a bulk action.</div>
{% elif request.query_params.get('bulk_action') == 'invalid_branch' %}
<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">The selected default branch is not available for the active firm.</div>
{% elif request.query_params.get('bulk_action') == 'invalid' %}
<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">The requested bulk action is invalid.</div>
{% endif %}
{% if can_manage_firm_services %} {% if can_manage_firm_services %}
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800"> <div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">
Select the services your firm provides. Once selected, you can open <span class="font-semibold">Firm Task Templates</span> and customise tasks for your firm. Select the services your firm provides. Once selected, you can open <span class="font-semibold">Firm Task Templates</span> and customise tasks for your firm.
@@ -59,10 +73,41 @@
</div> </div>
</form> </form>
{% if can_manage_firm_services %}
<form id="bulk-service-form" method="post" action="/services/catalogue/bulk-selection" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="return_q" value="{{ q }}">
<input type="hidden" name="return_category_id" value="{{ category_id or '' }}">
<input type="hidden" name="return_recurrence_type" value="{{ recurrence_type }}">
<input type="hidden" name="return_engagement_type" value="{{ engagement_type }}">
<input type="hidden" name="return_page" value="{{ page }}">
<input type="hidden" name="return_per_page" value="{{ per_page }}">
<div class="flex flex-wrap items-center gap-3">
<div class="mr-auto text-sm font-medium text-slate-700"><span id="bulk-selected-count">0</span> services selected</div>
{% if branches %}
<select name="default_branch_id" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
<option value="">Keep existing branch / no default for new selections</option>
{% for branch in branches %}<option value="{{ branch.id }}">{{ branch.name }}</option>{% endfor %}
</select>
{% else %}
<input type="hidden" name="default_branch_id" value="">
{% endif %}
<button type="submit" name="bulk_action" value="enable" class="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700">Select for Firm</button>
<button type="submit" name="bulk_action" value="disable" class="rounded-xl border border-amber-300 px-4 py-2 text-sm font-semibold text-amber-700 hover:bg-amber-50">Disable for Firm</button>
<button type="button" id="bulk-clear-selection" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Clear Selection</button>
</div>
</form>
{% endif %}
<div class="overflow-hidden rounded-2xl bg-white shadow-soft"> <div class="overflow-hidden rounded-2xl bg-white shadow-soft">
<table class="min-w-full divide-y divide-slate-200"> <table class="min-w-full divide-y divide-slate-200">
<thead class="bg-slate-50"> <thead class="bg-slate-50">
<tr> <tr>
{% if can_manage_firm_services %}
<th class="w-12 px-4 py-3 text-left">
<input id="bulk-select-all-visible" type="checkbox" class="h-4 w-4 rounded border-slate-300 text-brand-600" aria-label="Select all services visible on this page">
</th>
{% endif %}
<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">Code</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Name</th> <th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Name</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Category</th> <th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Category</th>
@@ -75,6 +120,11 @@
{% for row in rows %} {% for row in rows %}
{% set selection = firm_selection_by_catalogue.get(row.id) if firm_selection_by_catalogue else None %} {% set selection = firm_selection_by_catalogue.get(row.id) if firm_selection_by_catalogue else None %}
<tr> <tr>
{% if can_manage_firm_services %}
<td class="px-4 py-3 align-top">
<input type="checkbox" name="service_ids" value="{{ row.id }}" form="bulk-service-form" class="bulk-service-checkbox h-4 w-4 rounded border-slate-300 text-brand-600" aria-label="Select {{ row.service_name }}">
</td>
{% endif %}
<td class="px-4 py-3 text-sm font-medium text-slate-900">{{ row.service_code }}</td> <td class="px-4 py-3 text-sm font-medium text-slate-900">{{ row.service_code }}</td>
<td class="px-4 py-3 text-sm text-slate-700"> <td class="px-4 py-3 text-sm text-slate-700">
<div class="font-medium text-slate-900">{{ row.service_name }}</div> <div class="font-medium text-slate-900">{{ row.service_name }}</div>
@@ -118,7 +168,7 @@
</td> </td>
</tr> </tr>
{% else %} {% else %}
<tr><td colspan="6" class="px-4 py-8 text-center text-sm text-slate-500">No catalogue services found.</td></tr> <tr><td colspan="{{ 7 if can_manage_firm_services else 6 }}" class="px-4 py-8 text-center text-sm text-slate-500">No catalogue services found.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
@@ -190,4 +240,41 @@
</div> </div>
</div> </div>
</div> </div>
{% if can_manage_firm_services %}
<script>
(() => {
const selectAll = document.getElementById('bulk-select-all-visible');
const clearButton = document.getElementById('bulk-clear-selection');
const countLabel = document.getElementById('bulk-selected-count');
const checkboxes = Array.from(document.querySelectorAll('.bulk-service-checkbox'));
const refreshSelectionState = () => {
const selectedCount = checkboxes.filter((checkbox) => checkbox.checked).length;
countLabel.textContent = String(selectedCount);
if (selectAll) {
selectAll.checked = checkboxes.length > 0 && selectedCount === checkboxes.length;
selectAll.indeterminate = selectedCount > 0 && selectedCount < checkboxes.length;
}
};
if (selectAll) {
selectAll.addEventListener('change', () => {
checkboxes.forEach((checkbox) => { checkbox.checked = selectAll.checked; });
refreshSelectionState();
});
}
checkboxes.forEach((checkbox) => checkbox.addEventListener('change', refreshSelectionState));
if (clearButton) {
clearButton.addEventListener('click', () => {
checkboxes.forEach((checkbox) => { checkbox.checked = false; });
refreshSelectionState();
});
}
refreshSelectionState();
})();
</script>
{% endif %}
{% endblock %} {% endblock %}
+138
View File
@@ -849,6 +849,144 @@ def catalogue_edit_submit(request: Request, catalogue_id: int, service_name: str
db.close() db.close()
@router.post('/catalogue/bulk-selection')
def bulk_update_firm_services(
request: Request,
service_ids: list[int] = Form([]),
bulk_action: str = Form(...),
default_branch_id: str = Form(''),
return_q: str = Form(''),
return_category_id: str = Form(''),
return_recurrence_type: str = Form(''),
return_engagement_type: str = Form(''),
return_page: int = Form(1),
return_per_page: int = Form(20),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url='/login', status_code=303)
if not _can_manage_firm_services(db, user):
return _redirect_denied()
tenant_id = _active_tenant_id(request, user)
action = (bulk_action or '').strip().lower()
if action not in {'enable', 'disable'}:
action = 'invalid'
unique_service_ids = list(dict.fromkeys(service_id for service_id in service_ids if service_id > 0))
branch_id: int | None = None
branch_value = str(default_branch_id or '').strip()
if branch_value:
try:
parsed_branch_id = int(branch_value)
except (TypeError, ValueError):
parsed_branch_id = 0
branch = db.execute(
select(Branch).where(
Branch.id == parsed_branch_id,
Branch.tenant_id == tenant_id,
Branch.is_active.is_(True),
)
).scalar_one_or_none()
if not branch:
action = 'invalid_branch'
else:
branch_id = branch.id
updated_count = 0
unchanged_count = 0
skipped_count = 0
if action in {'enable', 'disable'} and unique_service_ids:
catalogue_rows = db.execute(
select(ServiceCatalogue).where(
ServiceCatalogue.id.in_(unique_service_ids),
ServiceCatalogue.is_active.is_(True),
)
).scalars().all()
catalogue_by_id = {row.id: row for row in catalogue_rows}
selections = db.execute(
select(FirmServiceSelection).where(
FirmServiceSelection.tenant_id == tenant_id,
FirmServiceSelection.service_catalogue_id.in_(unique_service_ids),
)
).scalars().all()
selection_by_catalogue = {
selection.service_catalogue_id: selection for selection in selections
}
for catalogue_id in unique_service_ids:
if catalogue_id not in catalogue_by_id:
skipped_count += 1
continue
selection = selection_by_catalogue.get(catalogue_id)
if action == 'enable':
if selection is None:
selection = FirmServiceSelection(
tenant_id=tenant_id,
service_catalogue_id=catalogue_id,
is_enabled=True,
default_branch_id=branch_id,
activated_by_user_id=user.id,
updated_by_user_id=user.id,
)
db.add(selection)
updated_count += 1
else:
changed = not bool(selection.is_enabled)
selection.is_enabled = True
if branch_id is not None and selection.default_branch_id != branch_id:
selection.default_branch_id = branch_id
changed = True
selection.updated_by_user_id = user.id
if changed:
updated_count += 1
else:
unchanged_count += 1
else:
if selection is not None and selection.is_enabled:
selection.is_enabled = False
selection.updated_by_user_id = user.id
updated_count += 1
else:
unchanged_count += 1
db.commit()
elif not unique_service_ids:
action = 'no_selection'
query_parts = [
f'bulk_action={action}',
f'bulk_updated={updated_count}',
f'bulk_unchanged={unchanged_count}',
f'bulk_skipped={skipped_count}',
f'page={max(1, return_page)}',
f'per_page={return_per_page if return_per_page in (20, 50, 100) else 20}',
]
if return_q:
from urllib.parse import quote_plus
query_parts.append(f'q={quote_plus(return_q)}')
if return_category_id:
query_parts.append(f'category_id={return_category_id}')
if return_recurrence_type:
query_parts.append(f'recurrence_type={return_recurrence_type}')
if return_engagement_type:
query_parts.append(f'engagement_type={return_engagement_type}')
return RedirectResponse(
url='/services/catalogue?' + '&'.join(query_parts),
status_code=303,
)
finally:
db.close()
@router.post('/catalogue/{catalogue_id}/toggle') @router.post('/catalogue/{catalogue_id}/toggle')
def toggle_firm_service(request: Request, catalogue_id: int, default_branch_id: str = Form(''), csrf_token: str = Form(...)): def toggle_firm_service(request: Request, catalogue_id: int, default_branch_id: str = Form(''), csrf_token: str = Form(...)):
validate_csrf(request, csrf_token) validate_csrf(request, csrf_token)