Aug 17 - Update bulk actions for inspection/issue list

This commit is contained in:
2026-08-17 14:48:20 -04:00
parent 5486145a24
commit 1de15a925b
14 changed files with 801 additions and 48 deletions
+14 -2
View File
@@ -107,10 +107,15 @@
<div class="card shadow-sm">
<div class="card-body p-0">
{% if inspections.items %}
{% include 'partials/bulk_inspections_toolbar.html' %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th style="width:34px;">
<input type="checkbox" class="form-check-input bulk-check-all"
title="Select all on this page" aria-label="Select all">
</th>
<th>#</th><th>Date</th><th>Contract</th><th>Facility</th><th>Area</th>
<th>Template</th><th>Inspector</th><th>Score</th>
<th>Status</th><th></th>
@@ -119,6 +124,11 @@
<tbody>
{% for ins in inspections.items %}
<tr>
<td>
<input type="checkbox" class="form-check-input bulk-check"
form="inspectionsBulkForm" name="inspection_ids" value="{{ ins.id }}"
aria-label="Select inspection #{{ ins.id }}">
</td>
<td><small class="text-muted">#{{ ins.id }}</small></td>
<td>{{ ins.inspection_date.strftime('%Y-%m-%d %H:%M') }}</td>
<td><small>{{ ins.facility.project.name if ins.facility and ins.facility.project else '—' }}</small></td>
@@ -160,9 +170,9 @@
</td>
<td class="text-nowrap">
{% if ins.status == 'in_progress' or ins.status == 'flagged' %}
<a href="{{ url_for('inspections.execute', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-primary insp-list-link">Continue</a>
<a href="{{ url_for('inspections.execute', inspection_id=ins.id, next=current_url()) }}" class="btn btn-sm btn-outline-primary insp-list-link">Continue</a>
{% else %}
<a href="{{ url_for('inspections.view', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-secondary insp-list-link">View</a>
<a href="{{ url_for('inspections.view', inspection_id=ins.id, next=current_url()) }}" class="btn btn-sm btn-outline-secondary insp-list-link">View</a>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<button type="button"
@@ -224,6 +234,7 @@
</button>
<form id="deleteInspectionForm" method="POST" action="" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ current_url() }}">
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash3-fill"></i> Delete Permanently
</button>
@@ -236,6 +247,7 @@
{% endblock %}
{% block extra_js %}
{% include 'partials/bulk_select_js.html' %}
<script>
(function () {
'use strict';
+13 -2
View File
@@ -338,7 +338,11 @@
{# Action bar #}
<div class="d-flex justify-content-between align-items-center mb-3">
<a id="backToInspectionsBtn" href="{{ url_for('inspections.index') }}" class="btn btn-sm btn-outline-secondary">
{# `next` carries the filtered list URL from the list page; the
sessionStorage fallback below still covers links opened before
this page started sending one. #}
{% set back_url = request.args.get('next') or url_for('inspections.index') %}
<a id="backToInspectionsBtn" href="{{ back_url }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Inspections
</a>
<div class="d-flex gap-2">
@@ -378,6 +382,7 @@
action="{{ url_for('inspections.clear_followup', inspection_id=inspection.id) }}"
class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ back_url }}">
<button class="btn btn-sm btn-warning">
<i class="bi bi-flag-fill"></i> Clear Follow-up
</button>
@@ -386,6 +391,7 @@
<form method="post" action="{{ url_for('inspections.delete', inspection_id=inspection.id) }}"
onsubmit="return confirm('Delete this inspection permanently?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ back_url }}">
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash3"></i> Delete</button>
</form>
{% endif %}
@@ -889,7 +895,11 @@
{% block extra_js %}
<script>
(function () {
var backUrl = sessionStorage.getItem('insp_list_back_url');
// A server-provided `next` is authoritative — it reflects the list this
// page was actually opened from. Only fall back to sessionStorage when
// there is none (e.g. a link created before `next` was threaded in).
var hasNext = {{ 'true' if request.args.get('next') else 'false' }};
var backUrl = hasNext ? null : sessionStorage.getItem('insp_list_back_url');
if (backUrl) {
var btn = document.getElementById('backToInspectionsBtn');
if (btn) btn.href = backUrl;
@@ -915,6 +925,7 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMedia()
<div class="modal-dialog">
<form method="POST" action="{{ url_for('inspections.flag_followup', inspection_id=inspection.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ back_url }}">
<div class="modal-content">
{% set is_cust = current_user.role == 'customer' %}
<div class="modal-header">
+14 -2
View File
@@ -107,10 +107,15 @@
<div class="card shadow-sm">
<div class="card-body p-0">
{% if issues.items %}
{% include 'partials/bulk_issues_toolbar.html' %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th style="width:34px;">
<input type="checkbox" class="form-check-input bulk-check-all"
title="Select all on this page" aria-label="Select all">
</th>
<th>#</th>
<th>Reported</th>
<th>Severity</th>
@@ -129,6 +134,11 @@
{% set is_following = issue.id in followed_ids %}
{% set sla = sla_status(issue) %}
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
<td>
<input type="checkbox" class="form-check-input bulk-check"
form="issuesBulkForm" name="issue_ids" value="{{ issue.id }}"
aria-label="Select issue #{{ issue.id }}">
</td>
<td><small class="text-muted">#{{ issue.id }}</small></td>
<td><small>{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</small></td>
<td>
@@ -203,7 +213,7 @@
class="d-inline"
title="Unfollow this issue">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ url_for('issues.index', page=issues.page, issue_id=issue_id_filter, severity=severity_filter, status=status_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, reporter_id=reporter_filter, handler_type=handler_filter, unassigned=unassigned_filter) }}">
<input type="hidden" name="next" value="{{ current_url() }}">
<button type="submit" class="btn btn-sm btn-outline-primary p-0 px-1 me-1"
title="Unfollow">
<i class="bi bi-bell-slash" style="font-size:.75rem;"></i>
@@ -211,7 +221,7 @@
</form>
{% endif %}
<a href="{{ url_for('issues.view', issue_id=issue.id) }}"
<a href="{{ url_for('issues.view', issue_id=issue.id, next=current_url()) }}"
class="btn btn-sm btn-outline-secondary">
{% if current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %}
<i class="bi bi-pencil"></i> Edit
@@ -224,6 +234,7 @@
class="d-inline"
onsubmit="return confirm('Permanently delete Issue #{{ issue.id }}? This cannot be undone.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ current_url() }}">
<button type="submit" class="btn btn-sm btn-outline-danger"
title="Delete Issue #{{ issue.id }}">
<i class="bi bi-trash"></i>
@@ -260,6 +271,7 @@
{% endblock %}
{% block extra_js %}
{% include 'partials/bulk_select_js.html' %}
<script>
(function () {
'use strict';
+10 -1
View File
@@ -19,6 +19,10 @@
{% block content %}
{% set can_edit = current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %}
{# The filtered list URL this page was opened from (phase: filter
preservation). Threaded into every action so an update or delete
returns to the same filtered page, and used by the Back button. #}
{% set back_url = request.args.get('next') or url_for('issues.index') %}
<div class="row">
{# ══════════════════════════════════ LEFT COLUMN ══════════════════════════════════ #}
@@ -192,6 +196,7 @@
<strong>Awaiting director verification.</strong>
{% if current_user.role in ['admin','director','auditor'] %}
<form method="POST" action="{{ url_for('issues.verify', issue_id=issue.id) }}" class="mt-2">
<input type="hidden" name="next" value="{{ back_url }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-2">
<input type="text" name="verification_note" class="form-control form-control-sm"
@@ -282,6 +287,7 @@
<div class="card-body">
<p class="fw-semibold small mb-2">Add Comment</p>
<form method="post">
<input type="hidden" name="next" value="{{ back_url }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="status" value="{{ issue.status }}">
<input type="hidden" name="assigned_to" value="{{ issue.assigned_to or 0 }}">
@@ -321,6 +327,7 @@
<div class="card-body">
<p class="fw-semibold small mb-2">Add Comment</p>
<form method="post">
<input type="hidden" name="next" value="{{ back_url }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-2">
<textarea name="update_notes" class="form-control" rows="3"
@@ -395,6 +402,7 @@
<div class="card-header bg-light"><h6 class="mb-0">Update Issue</h6></div>
<div class="card-body">
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="next" value="{{ back_url }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
{{ form.status.label(class="form-label fw-semibold") }}
@@ -530,7 +538,7 @@
</div>
<div class="d-flex align-items-center gap-2 mt-2">
<a href="{{ url_for('issues.index') }}" class="btn btn-outline-secondary btn-sm">
<a href="{{ back_url }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Back to Issues
</a>
<a href="{{ url_for('issues.export_pdf', issue_id=issue.id) }}" class="btn btn-outline-primary btn-sm">
@@ -568,6 +576,7 @@
<i class="bi bi-x-circle"></i> Cancel
</button>
<form method="POST" action="{{ url_for('issues.delete', issue_id=issue.id) }}" class="d-inline">
<input type="hidden" name="next" value="{{ back_url }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash-fill"></i> Delete Permanently
+14 -2
View File
@@ -162,10 +162,15 @@
{% endif %}
{% if inspections.items %}
{% include 'partials/bulk_inspections_toolbar.html' %}
<div class="jqc-table-wrap table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th style="width:34px;">
<input type="checkbox" class="form-check-input bulk-check-all"
title="Select all on this page" aria-label="Select all">
</th>
<th>#</th><th>Date</th><th>Contract</th><th>Facility</th><th>Area</th>
<th>Template</th><th>Inspector</th><th>Score</th>
<th>Status</th><th></th>
@@ -174,6 +179,11 @@
<tbody>
{% for ins in inspections.items %}
<tr>
<td>
<input type="checkbox" class="form-check-input bulk-check"
form="inspectionsBulkForm" name="inspection_ids" value="{{ ins.id }}"
aria-label="Select inspection #{{ ins.id }}">
</td>
<td><small class="text-muted">#{{ ins.id }}</small></td>
<td class="text-nowrap">{{ ins.inspection_date.strftime('%Y-%m-%d %H:%M') }}</td>
<td><small>{{ ins.facility.project.name if ins.facility and ins.facility.project else '—' }}</small></td>
@@ -215,9 +225,9 @@
</td>
<td class="text-nowrap">
{% if ins.status == 'in_progress' or ins.status == 'flagged' %}
<a href="{{ url_for('inspections.execute', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-primary insp-list-link">Continue</a>
<a href="{{ url_for('inspections.execute', inspection_id=ins.id, next=current_url()) }}" class="btn btn-sm btn-outline-primary insp-list-link">Continue</a>
{% else %}
<a href="{{ url_for('inspections.view', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-secondary insp-list-link">View</a>
<a href="{{ url_for('inspections.view', inspection_id=ins.id, next=current_url()) }}" class="btn btn-sm btn-outline-secondary insp-list-link">View</a>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<button type="button"
@@ -287,6 +297,7 @@
</button>
<form id="deleteInspectionForm" method="POST" action="" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ current_url() }}">
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash3-fill"></i> Delete Permanently
</button>
@@ -299,6 +310,7 @@
{% endblock %}
{% block extra_js %}
{% include 'partials/bulk_select_js.html' %}
<script>
(function () {
'use strict';
+14 -2
View File
@@ -166,10 +166,15 @@
{% endif %}
{% if issues.items %}
{% include 'partials/bulk_issues_toolbar.html' %}
<div class="jqc-table-wrap table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th style="width:34px;">
<input type="checkbox" class="form-check-input bulk-check-all"
title="Select all on this page" aria-label="Select all">
</th>
<th>#</th>
<th>Reported</th>
<th>Severity</th>
@@ -188,6 +193,11 @@
{% set is_following = issue.id in followed_ids %}
{% set sla = sla_status(issue) %}
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
<td>
<input type="checkbox" class="form-check-input bulk-check"
form="issuesBulkForm" name="issue_ids" value="{{ issue.id }}"
aria-label="Select issue #{{ issue.id }}">
</td>
<td><small class="text-muted">#{{ issue.id }}</small></td>
<td class="text-nowrap"><small>{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</small></td>
<td>
@@ -262,7 +272,7 @@
class="d-inline"
title="Unfollow this issue">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ url_for('issues.index', page=issues.page, issue_id=issue_id_filter, severity=severity_filter, status=status_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, reporter_id=reporter_filter, handler_type=handler_filter, unassigned=unassigned_filter) }}">
<input type="hidden" name="next" value="{{ current_url() }}">
<button type="submit" class="btn btn-sm btn-outline-primary p-0 px-1 me-1"
title="Unfollow">
<i class="bi bi-bell-slash" style="font-size:.75rem;"></i>
@@ -270,7 +280,7 @@
</form>
{% endif %}
<a href="{{ url_for('issues.view', issue_id=issue.id) }}"
<a href="{{ url_for('issues.view', issue_id=issue.id, next=current_url()) }}"
class="btn btn-sm btn-outline-secondary">
{% if current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %}
<i class="bi bi-pencil"></i> Edit
@@ -283,6 +293,7 @@
class="d-inline"
onsubmit="return confirm('Permanently delete Issue #{{ issue.id }}? This cannot be undone.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ current_url() }}">
<button type="submit" class="btn btn-sm btn-outline-danger"
title="Delete Issue #{{ issue.id }}">
<i class="bi bi-trash"></i>
@@ -324,6 +335,7 @@
{% endblock %}
{% block extra_js %}
{% include 'partials/bulk_select_js.html' %}
<script>
(function () {
'use strict';
@@ -0,0 +1,57 @@
{# ── Bulk-action toolbar for the inspections list ─────────────────────────────
Included by BOTH inspections/list.html and modern/inspections/list.html —
edit here, not in either copy.
Same structure as the issues toolbar: the form sits OUTSIDE the table and
row checkboxes join it via the HTML5 `form` attribute, so the per-row
delete form inside the table is never nested (rule 9).
Export is offered to anyone who can see the list — it is read-only and the
route re-applies the viewer's facility scope to the submitted ids. The three
mutating actions are admin/director only.
#}
{% set can_manage = current_user.role in ['admin', 'director'] %}
<form method="POST" id="inspectionsBulkForm"
action="{{ url_for('inspections.bulk_action') }}"
class="border-bottom bg-light px-3 py-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ current_url() }}">
<div class="d-flex flex-wrap align-items-center gap-2">
<span class="small fw-semibold text-nowrap">
<span class="bulk-count">0</span> selected
</span>
<span class="text-muted small d-none d-md-inline">|</span>
<button type="submit" name="action" value="export"
class="btn btn-sm btn-outline-secondary text-nowrap" data-bulk-action>
<i class="bi bi-file-earmark-pdf"></i> Export Selected
</button>
{% if can_manage %}
<div class="d-flex align-items-center gap-1">
<input type="text" name="follow_up_note" class="form-control form-control-sm"
style="min-width:180px;font-size:.8rem;"
placeholder="Follow-up note (optional)"
aria-label="Follow-up note applied to all selected">
<button type="submit" name="action" value="flag_followup"
class="btn btn-sm btn-outline-warning text-nowrap" data-bulk-action
data-bulk-confirm="Request a follow-up on the selected inspections? Ones not yet submitted, or already flagged, are skipped.">
<i class="bi bi-flag"></i> Request Follow-up
</button>
</div>
<button type="submit" name="action" value="clear_followup"
class="btn btn-sm btn-outline-success text-nowrap" data-bulk-action
data-bulk-confirm="Clear the follow-up flag on the selected inspections?">
<i class="bi bi-flag-fill"></i> Clear Follow-up
</button>
<button type="submit" name="action" value="delete"
class="btn btn-sm btn-outline-danger text-nowrap ms-auto" data-bulk-action
data-bulk-confirm="Permanently delete the selected inspections and their photos? This cannot be undone.">
<i class="bi bi-trash"></i> Delete
</button>
{% endif %}
</div>
</form>
@@ -0,0 +1,79 @@
{# ── Bulk-action toolbar for the issues list ──────────────────────────────────
Included by BOTH issues/list.html and modern/issues/list.html — edit here,
not in either copy.
The form lives OUTSIDE the table on purpose. Row checkboxes join it with the
HTML5 `form="issuesBulkForm"` attribute instead of being wrapped by it, so
the per-row delete / unfollow forms inside the table are never nested inside
this one (rule 9 — browsers silently discard nested forms, and the row
actions would stop working with no error).
`next` carries the current filtered list URL so the action returns here
rather than to the bare index.
Requires from the view: `staff` (assignable users).
#}
{% set can_manage = current_user.role in ['admin', 'director', 'auditor'] %}
{% set can_delete = current_user.role in ['admin', 'director'] %}
{% if can_manage or can_delete %}
<form method="POST" id="issuesBulkForm"
action="{{ url_for('issues.bulk_action') }}"
class="border-bottom bg-light px-3 py-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ current_url() }}">
<div class="d-flex flex-wrap align-items-center gap-2">
<span class="small fw-semibold text-nowrap">
<span class="bulk-count">0</span> selected
</span>
<span class="text-muted small d-none d-md-inline">|</span>
{% if can_manage %}
<div class="d-flex align-items-center gap-1">
<select name="assigned_to" class="form-select form-select-sm"
style="min-width:150px;font-size:.8rem;" aria-label="Assign selected to">
<option value="0">— Unassigned —</option>
{% for u in staff %}
<option value="{{ u.id }}">{{ u.display_name }}{{ ' (Customer)' if u.is_external_inspector }}</option>
{% endfor %}
</select>
<button type="submit" name="action" value="assign"
class="btn btn-sm btn-outline-primary text-nowrap" data-bulk-action
data-bulk-confirm="Assign the selected issues to the chosen user?">
<i class="bi bi-person-check"></i> Assign
</button>
</div>
<div class="d-flex align-items-center gap-1">
<select name="status" class="form-select form-select-sm"
style="min-width:150px;font-size:.8rem;" aria-label="Set status of selected">
<option value="">— Set status… —</option>
<option value="open">Open</option>
<option value="in_progress">In Progress</option>
<option value="pending_verification">Pending Verification</option>
<option value="resolved">Resolved</option>
</select>
<button type="submit" name="action" value="status"
class="btn btn-sm btn-outline-primary text-nowrap" data-bulk-action
data-bulk-confirm="Change the status of the selected issues?">
<i class="bi bi-arrow-repeat"></i> Apply
</button>
</div>
<button type="submit" name="action" value="verify"
class="btn btn-sm btn-outline-success text-nowrap" data-bulk-action
data-bulk-confirm="Verify and close the selected issues? Issues that are not awaiting verification are skipped.">
<i class="bi bi-patch-check"></i> Verify &amp; Close
</button>
{% endif %}
{% if can_delete %}
<button type="submit" name="action" value="delete"
class="btn btn-sm btn-outline-danger text-nowrap ms-auto" data-bulk-action
data-bulk-confirm="Permanently delete the selected issues and their photos? This cannot be undone.">
<i class="bi bi-trash"></i> Delete
</button>
{% endif %}
</div>
</form>
{% endif %}
@@ -0,0 +1,77 @@
{# ── Shared row-selection behaviour for bulk-action list pages ────────────────
Included by the issues and inspections list templates (classic + modern).
Generic on purpose — it keys off classes/attributes, not page-specific ids,
so both pages share one implementation:
.bulk-check one per row (name=issue_ids / inspection_ids)
.bulk-check-all the header select-all box
.bulk-count element whose text becomes the selected count
[data-bulk-action] submit buttons, disabled while nothing is selected
[data-bulk-confirm] optional confirm text, count substituted for {n}
Guarding the submit on a zero selection matters: the browser would happily
POST an empty id list, and the route would flash "No issues selected" after
a full page round trip.
#}
<script>
(function () {
'use strict';
var boxes = Array.prototype.slice.call(document.querySelectorAll('.bulk-check'));
var all = document.querySelector('.bulk-check-all');
var counts = Array.prototype.slice.call(document.querySelectorAll('.bulk-count'));
var btns = Array.prototype.slice.call(document.querySelectorAll('[data-bulk-action]'));
if (!boxes.length) return;
function selected() {
return boxes.filter(function (b) { return b.checked; });
}
function sync() {
var n = selected().length;
counts.forEach(function (el) { el.textContent = n; });
btns.forEach(function (b) { b.disabled = (n === 0); });
if (all) {
all.checked = (n > 0 && n === boxes.length);
// Distinguishes "some" from "none"/"all" in the header box.
all.indeterminate = (n > 0 && n < boxes.length);
}
}
boxes.forEach(function (b) { b.addEventListener('change', sync); });
if (all) {
all.addEventListener('change', function () {
boxes.forEach(function (b) { b.checked = all.checked; });
sync();
});
}
// Shift-click selects the range from the last clicked box — the usual
// convention, and the difference between ticking 3 boxes and 40.
var lastIndex = null;
boxes.forEach(function (b, i) {
b.addEventListener('click', function (e) {
if (e.shiftKey && lastIndex !== null) {
var lo = Math.min(lastIndex, i), hi = Math.max(lastIndex, i);
for (var j = lo; j <= hi; j++) { boxes[j].checked = b.checked; }
sync();
}
lastIndex = i;
});
});
btns.forEach(function (btn) {
btn.addEventListener('click', function (e) {
var n = selected().length;
if (n === 0) { e.preventDefault(); return; }
var msg = btn.getAttribute('data-bulk-confirm');
if (msg && !window.confirm(msg.replace('{n}', n) + '\n\n' + n + ' selected.')) {
e.preventDefault();
}
});
});
sync();
}());
</script>