Aug 19 - Update code to catch up with ST

This commit is contained in:
2026-08-19 14:05:18 -04:00
parent c9984e7ae6
commit 12141c2f75
52 changed files with 3321 additions and 342 deletions
+4 -35
View File
@@ -80,19 +80,10 @@
</div>
</div>
{# MT-15 — an External Inspector is invited by email and chooses
their own password, so the admin never sets one. The JS at the
foot of this page swaps these two blocks when the role changes;
the server decides independently of the JS. #}
<div id="inviteNotice" class="alert alert-info d-none">
<i class="bi bi-envelope me-1"></i>
<strong>This account will be invited by email.</strong>
External inspectors work outside the business, so we do not set
a password for them. On save, an invitation is sent to the email
address above with a link to choose their own password. The link
is valid for 72 hours.
</div>
{# phase51 — the email-invitation branch that used to live here
moved to Customer Management along with the Customer
Inspector role. Every role this form still offers is our own
staff, created with an admin-set password. #}
<div class="row" id="passwordFields">
<div class="col-md-6 mb-3">
{{ form.password.label(class="form-label") }}
@@ -142,26 +133,4 @@
</div>
</div>
<script>
(function () {
'use strict';
var roleSel = document.getElementById('role');
var pwBlock = document.getElementById('passwordFields');
var notice = document.getElementById('inviteNotice');
if (!roleSel || !pwBlock || !notice) return; // director view has no role select
function sync() {
var invited = roleSel.value === 'external_inspector';
pwBlock.classList.toggle('d-none', invited);
notice.classList.toggle('d-none', !invited);
// Clear anything already typed so an invited account can never be created
// with an admin-chosen password sitting in the POST body.
if (invited) {
pwBlock.querySelectorAll('input').forEach(function (i) { i.value = ''; });
}
}
roleSel.addEventListener('change', sync);
sync();
})();
</script>
{% endblock %}
+18 -2
View File
@@ -5,7 +5,11 @@
<div class="row mb-4 align-items-center">
<div class="col">
<h2><i class="bi bi-person-badge"></i> Customer Management</h2>
<p class="text-muted mb-0">Manage portal access for all customer accounts.</p>
<p class="text-muted mb-0">
Manage both customer-side roles — <strong>Customer Directors</strong>
(portal access) and <strong>Customer Inspectors</strong> (perform
inspections on their contracts).
</p>
</div>
<div class="col-auto d-flex gap-2">
<a href="{{ url_for('customers.bulk_import') }}" class="btn btn-outline-success">
@@ -55,6 +59,7 @@
<tr>
<th>Username</th>
<th>Full Name</th>
<th>Role</th>
<th>Email</th>
<th>Status</th>
<th>Assigned Contracts</th>
@@ -65,7 +70,11 @@
</thead>
<tbody>
{% for customer in customers %}
{% set assignments = assignment_map[customer.id] %}
{# Inspectors are scoped by InspectorAssignment, directors by
CustomerAssignment — read the map that matches the role. #}
{% set assignments = inspector_assignment_map[customer.id]
if customer.is_inspector
else assignment_map[customer.id] %}
{% set facility_ids = scope_map[customer.id] %}
<tr class="{{ 'table-secondary text-muted' if not customer.active else '' }}">
<td>
@@ -77,6 +86,11 @@
</strong>
</td>
<td>{{ customer.full_name or '—' }}</td>
<td>
<span class="badge {{ 'bg-info text-dark' if customer.is_inspector else 'bg-primary' }}">
{{ customer.role_label }}
</span>
</td>
<td class="small text-muted">{{ customer.email }}</td>
<td>
{% if customer.active %}
@@ -136,6 +150,8 @@
<div class="mt-3 text-muted small">
{{ customers|length }} customer account{{ 's' if customers|length != 1 else '' }} total
· {{ customers|selectattr('active')|list|length }} active
· {{ customers|rejectattr('is_inspector')|list|length }} director{{ 's' if customers|rejectattr('is_inspector')|list|length != 1 else '' }}
· {{ customers|selectattr('is_inspector')|list|length }} inspector{{ 's' if customers|selectattr('is_inspector')|list|length != 1 else '' }}
</div>
{% else %}
+16 -1
View File
@@ -31,7 +31,7 @@
{% endfor %}
</div>
<div class="mb-4">
<div class="mb-3">
{{ form.email.label(class="form-label fw-semibold") }}
{{ form.email(class="form-control" + (" is-invalid" if form.email.errors else ""),
placeholder="jane@example.com") }}
@@ -44,6 +44,21 @@
</div>
</div>
<div class="mb-4">
{{ form.role.label(class="form-label fw-semibold") }}
{{ form.role(class="form-select" + (" is-invalid" if form.role.errors else "")) }}
{% for error in form.role.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
<div class="form-text">
A <strong>Director</strong> gets portal access to their facilities'
inspections, issues and reports. An <strong>Inspector</strong>
performs inspections and manages issues on the contracts you assign
them — the same tools as our own inspectors, limited to their
contracts. You can switch an account between the two later.
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-send me-1"></i>Create &amp; Send Invitation
+226 -1
View File
@@ -6,6 +6,9 @@
<div class="col">
<h2>
<i class="bi bi-person-badge"></i> {{ customer.display_name }}
<span class="badge {{ 'bg-info text-dark' if customer.is_inspector else 'bg-primary' }} ms-2 fs-6">
{{ customer.role_label }}
</span>
{% if not customer.active %}
<span class="badge bg-secondary ms-2 fs-6">Disabled</span>
{% else %}
@@ -19,6 +22,20 @@
class="btn btn-outline-secondary btn-sm">
<i class="bi bi-pencil"></i> Edit Account
</a>
{# ── Switch role (admin only) ── #}
{% if current_user.role == 'admin' %}
{% set to_label = 'Customer Director' if customer.is_inspector else 'Customer Inspector' %}
<form method="POST"
action="{{ url_for('customers.switch_role', customer_id=customer.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-info"
title="Change what this account can do"
onclick="return confirm('Switch {{ customer.display_name }} from {{ customer.role_label }} to {{ to_label }}?\n\nTheir contracts are carried across.{% if not customer.is_inspector %}\n\nFacility-level limits do not exist for inspectors — an account limited to specific facilities will gain the whole contract.{% endif %}\n\nAny signed-in device will be logged out.')">
<i class="bi bi-arrow-left-right me-1"></i> Switch to {{ to_label }}
</button>
</form>
{% endif %}
<form method="POST"
action="{{ url_for('customers.toggle_active', customer_id=customer.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
@@ -48,6 +65,19 @@
<dl class="row mb-0 small">
<dt class="col-5 text-muted">Full Name</dt>
<dd class="col-7">{{ customer.full_name or '—' }}</dd>
<dt class="col-5 text-muted">Role</dt>
<dd class="col-7">
<span class="badge {{ 'bg-info text-dark' if customer.is_inspector else 'bg-primary' }}">
{{ customer.role_label }}
</span>
<div class="text-muted" style="font-size:.72rem;">
{% if customer.is_inspector %}
Performs inspections and manages issues on their assigned contracts.
{% else %}
Portal access to their facilities' inspections, issues and reports.
{% endif %}
</div>
</dd>
<dt class="col-5 text-muted">Username</dt>
<dd class="col-7">{{ customer.username }}</dd>
<dt class="col-5 text-muted">Email</dt>
@@ -69,7 +99,7 @@
<dt class="col-5 text-muted">Created</dt>
<dd class="col-7">{{ customer.created_at.strftime('%Y-%m-%d') }}</dd>
<dt class="col-5 text-muted">Assignments</dt>
<dd class="col-7">{{ assignments|length }}</dd>
<dd class="col-7">{{ assigned_pids|length if customer.is_inspector else assignments|length }}</dd>
<dt class="col-5 text-muted">Facilities</dt>
<dd class="col-7">{{ facilities|length }}</dd>
</dl>
@@ -120,6 +150,58 @@
{# ── Right column: assignments ── #}
<div class="col-md-8">
{% if customer.is_inspector %}
{# ══ Customer Inspector — whole contracts, no facility-level narrowing ══
Scoped by InspectorAssignment, the same rows an internal inspector uses.
Posts the COMPLETE checked set; unchecked contracts are removed. ══ #}
<div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-semibold d-flex justify-content-between align-items-center">
<span><i class="bi bi-diagram-3 me-1"></i> Contract Assignments</span>
<span class="badge bg-secondary rounded-pill" id="assignedCount">
{{ assigned_pids|length }} assigned
</span>
</div>
<div class="card-body">
<p class="text-muted small">
A Customer Inspector sees only the contracts ticked here — with none
ticked they see nothing at all. Inspectors are assigned whole
contracts; there is no per-facility option for this role.
</p>
<form method="POST"
action="{{ url_for('customers.assign_contracts', customer_id=customer.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-2">
<button type="button" class="btn btn-sm btn-outline-secondary" id="selectAll">Select all</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="deselectAll">Deselect all</button>
</div>
{% if projects %}
<div class="list-group list-group-flush mb-3"
style="max-height:340px;overflow-y:auto;">
{% for p in projects %}
<label class="list-group-item d-flex align-items-center gap-2 py-2">
<input class="form-check-input m-0 contract-check" type="checkbox"
name="project_ids" value="{{ p.id }}"
{% if p.id in assigned_pids %}checked{% endif %}>
<span class="small">{{ p.name }}</span>
</label>
{% endfor %}
</div>
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-check2 me-1"></i> Save Contract Assignments
</button>
{% else %}
<p class="text-muted small mb-0">No active contracts exist yet.</p>
{% endif %}
</form>
</div>
</div>
{% else %}
{# ══ Customer Director — contract or single-facility assignments ══ #}
{# ── Current assignments table ── #}
<div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-semibold d-flex justify-content-between align-items-center">
@@ -213,6 +295,116 @@
</form>
</div>
</div>
{% endif %}
{# ── Per-account notification matrix ─────────────────────────────────
Overrides the global Notification Matrix for THIS account only.
"Inherit" is the default and means "follow the global column", so it
keeps tracking future changes there — it is not a snapshot. #}
<div class="card shadow-sm mt-4">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-bell me-1"></i> Notifications for this account
</div>
<div class="card-body">
<p class="text-muted small">
Each customer's enrollment form says which notifications their people
want, so these can differ per person. <strong>Inherit</strong> follows
the global Notification Matrix for
{{ customer.role_label }}s — including any later change to it. Choose
On or Off only where this account should differ.
</p>
<form method="POST"
action="{{ url_for('customers.save_notifications', customer_id=customer.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<style>
/* The whole cell is the control. Padding (not min-height on the
input) gives the touch target, so the native radio keeps its
own box — see the note in ipad_responsive.css. */
.matrix-opt {
display: block;
padding: .55rem .25rem;
margin: 0;
cursor: pointer;
text-align: center;
}
.matrix-opt:hover { background: rgba(13,110,253,.06); }
.matrix-opt input { cursor: pointer; }
.matrix-opt-sub {
display: block;
font-size: .62rem;
color: #6c757d;
margin-top: 2px;
}
</style>
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
<span class="small text-muted">Set every row:</span>
<button type="button" class="btn btn-sm btn-outline-secondary" data-matrix-all="inherit">Inherit</button>
<button type="button" class="btn btn-sm btn-outline-success" data-matrix-all="on">On</button>
<button type="button" class="btn btn-sm btn-outline-danger" data-matrix-all="off">Off</button>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle mb-3">
<thead class="table-light">
<tr>
<th>Event</th>
<th class="text-center" style="width:110px;">Inherit</th>
<th class="text-center" style="width:70px;">On</th>
<th class="text-center" style="width:70px;">Off</th>
</tr>
</thead>
<tbody>
{% for row in matrix_rows %}
<tr>
<td class="small">
{{ row.label }}
{% if row.override is not none %}
<span class="badge bg-warning text-dark ms-1" style="font-size:.6rem;">custom</span>
{% endif %}
</td>
{# Each option is a <label> filling its whole cell, so the
click target is the cell rather than the ~16px glyph. A
bare <input> in a centred <td> was effectively unclickable
at touch/narrow widths. #}
<td class="p-0">
<label class="matrix-opt" title="Follow the global matrix">
<input class="form-check-input" type="radio"
name="event_{{ row.event }}" value="inherit"
{% if row.override is none %}checked{% endif %}>
<span class="matrix-opt-sub">
currently {{ 'on' if row.global else 'off' }}
</span>
</label>
</td>
<td class="p-0">
<label class="matrix-opt" title="Always send this to this account">
<input class="form-check-input" type="radio"
name="event_{{ row.event }}" value="on"
{% if row.override is true %}checked{% endif %}>
</label>
</td>
<td class="p-0">
<label class="matrix-opt" title="Never send this to this account">
<input class="form-check-input" type="radio"
name="event_{{ row.event }}" value="off"
{% if row.override is false %}checked{% endif %}>
</label>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-check2 me-1"></i> Save Notification Settings
</button>
</form>
</div>
</div>
</div>
</div>
@@ -223,8 +415,41 @@
(function () {
'use strict';
// ── Customer Inspector: contract checkbox helpers ──
const checks = document.querySelectorAll('.contract-check');
const countBadge = document.getElementById('assignedCount');
function refreshCount() {
if (!countBadge) return;
const n = document.querySelectorAll('.contract-check:checked').length;
countBadge.textContent = n + ' assigned';
}
function setAll(state) {
checks.forEach(function (c) { c.checked = state; });
refreshCount();
}
const selectAll = document.getElementById('selectAll');
const deselectAll = document.getElementById('deselectAll');
if (selectAll) selectAll.addEventListener('click', function () { setAll(true); });
if (deselectAll) deselectAll.addEventListener('click', function () { setAll(false); });
checks.forEach(function (c) { c.addEventListener('change', refreshCount); });
// ── Notification matrix: set every row at once ──
document.querySelectorAll('[data-matrix-all]').forEach(function (btn) {
btn.addEventListener('click', function () {
var want = btn.getAttribute('data-matrix-all');
document.querySelectorAll('.matrix-opt input[type=radio]').forEach(function (r) {
if (r.value === want) { r.checked = true; }
});
});
});
// ── Customer Director: contract → facility cascade ──
// Both selects are absent on the inspector view, so bail out rather than
// throwing on addEventListener of null (which would kill the handlers above).
const projSelect = document.getElementById('proj-select');
const facSelect = document.getElementById('fac-select');
if (!projSelect || !facSelect) return;
projSelect.addEventListener('change', function () {
const projectId = this.value;
+15 -5
View File
@@ -1,9 +1,15 @@
{% extends "base.html" %}
{% block title %}Inspection Schedules{% endblock %}
{% block content %}
{# Who may create/edit/delete a schedule — mirrors schedule_manager_required
in routes/inspection_schedules.py. 'customer' is the Customer DIRECTOR, who
plans work for their own facilities; a Customer Inspector ('external_inspector')
performs schedules and is covered by is_inspector below. #}
{% set can_manage_schedules = current_user.role in
['admin','director','project_manager','auditor','customer'] %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-calendar2-week"></i> Inspection Schedules</h2>
{% if current_user.role != 'inspector' %}
{% if can_manage_schedules %}
<a href="{{ url_for('inspection_schedules.create') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> New Schedule
</a>
@@ -11,7 +17,7 @@
</div>
<p class="text-muted small mb-4">
{% if current_user.role == 'inspector' %}
{% if current_user.is_inspector %}
Inspections scheduled for you. <strong>Auto</strong> schedules appear in your
Inspections list on their own each period; <strong>Plan</strong> schedules wait
for you to press Start.
@@ -134,14 +140,18 @@
</button>
</form>
{% endif %}
{# Start is for whoever must DO the work. A Customer Director can
plan a schedule but never execute one — the route 403s them, so
the button must not be offered either. #}
{% if s.active and s.mode == 'plan'
and (current_user.role != 'inspector' or s.inspector_id == current_user.id) %}
and current_user.role != 'customer'
and (not current_user.is_inspector or s.inspector_id == current_user.id) %}
<a href="{{ url_for('inspection_schedules.start', schedule_id=s.id) }}"
class="btn btn-sm btn-primary" title="Start this inspection now">
<i class="bi bi-play-fill"></i> Start
</a>
{% endif %}
{% if current_user.role != 'inspector' %}
{% if can_manage_schedules %}
<a href="{{ url_for('inspection_schedules.edit', schedule_id=s.id) }}"
class="btn btn-sm btn-outline-secondary" title="Edit">
<i class="bi bi-pencil"></i>
@@ -191,7 +201,7 @@
{% else %}
<i class="bi bi-calendar-x fs-1 d-block mb-3 opacity-25"></i>
<p class="mb-3">No inspection schedules configured yet.</p>
{% if current_user.role != 'inspector' %}
{% if can_manage_schedules %}
<a href="{{ url_for('inspection_schedules.create') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Create First Schedule
</a>
+1 -1
View File
@@ -564,7 +564,7 @@
<option value="0">— Unassigned —</option>
{% set staff = staff_for_flag_issue %}
{% if staff %}{% for u in staff %}
<option value="{{ u.id }}">{{ u.display_name }}{{ ' (External)' if u.is_external_inspector }}</option>
<option value="{{ u.id }}">{{ u.display_name }}{{ ' (Customer)' if u.is_external_inspector }}</option>
{% endfor %}{% endif %}
</select>
</div>
+18 -4
View File
@@ -3,16 +3,18 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-clipboard-data"></i> Inspections</h2>
{% if current_user.role != 'customer' %}
{# Customer Directors schedule inspections for their own facilities, so the
Scheduled link is theirs too — but starting an ad-hoc inspection is not. #}
<div class="d-flex gap-2">
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-outline-secondary">
<i class="bi bi-calendar-check"></i> Scheduled
</a>
{% if current_user.role != 'customer' %}
<a href="{{ url_for('inspections.start') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> New Inspection
</a>
{% endif %}
</div>
{% endif %}
</div>
{# Filters #}
@@ -106,10 +108,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>
@@ -118,6 +125,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>
@@ -152,9 +164,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"
@@ -216,6 +228,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>
@@ -228,6 +241,7 @@
{% endblock %}
{% block extra_js %}
{% include 'partials/bulk_select_js.html' %}
<script>
(function () {
'use strict';
+33 -1
View File
@@ -14,8 +14,14 @@
<div class="mb-3">
{{ form.template_id.label(class="form-label fw-semibold") }}
{{ form.template_id(class="form-select" + (" is-invalid" if form.template_id.errors else "")) }}
{{ form.template_id(class="form-select" + (" is-invalid" if form.template_id.errors else ""), id="templateSelect") }}
{% for e in form.template_id.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
{# phase52 — the list shows shared forms plus the ones attached to
the selected contract, refreshed by JS when the contract changes. #}
<div class="form-text">Shows forms available on the selected contract.</div>
<div id="templateEmpty" class="form-text text-danger d-none">
No forms are available on this contract yet.
</div>
</div>
<div class="mb-3">
@@ -55,6 +61,8 @@
<script>
(function () {
const projectSel = document.getElementById('projectSelect');
const templateSel = document.getElementById('templateSelect');
const templateEmpty = document.getElementById('templateEmpty');
const facilitySel = document.getElementById('facilitySelect');
const spinner = document.getElementById('facilitySpinner');
const emptyMsg = document.getElementById('facilityEmpty');
@@ -62,6 +70,7 @@
const areaSel = document.getElementById('areaSelect');
const FACILITIES_URL = `{{ url_for('inspections.facilities_for_project', project_id=0) }}`.replace('/0', '/');
const TEMPLATES_URL = `{{ url_for('inspections.templates_for_project', project_id=0) }}`.replace('/0', '/');
const AREAS_URL = `{{ url_for('inspections.areas_for_facility', facility_id=0) }}`.replace('/0', '/');
function loadAreas(facilityId, selectedAreaId) {
@@ -93,6 +102,28 @@
});
}
// Forms are per-contract (phase52): a customer's bespoke form must not be
// offered on another customer's facilities. Keeps the currently selected
// form if it is still valid on the new contract.
function loadTemplates(projectId) {
if (!projectId || !templateSel) return;
const keep = templateSel.value;
fetch(TEMPLATES_URL + projectId)
.then(r => r.json())
.then(data => {
templateSel.innerHTML = '';
data.forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
opt.textContent = t.name;
if (String(t.id) === keep) opt.selected = true;
templateSel.appendChild(opt);
});
templateEmpty.classList.toggle('d-none', data.length > 0);
})
.catch(() => {}); // leave the server-rendered list in place
}
function loadFacilities(projectId, selectedFacilityId, selectedAreaId) {
if (!projectId) return;
spinner.classList.remove('d-none');
@@ -129,6 +160,7 @@
projectSel.addEventListener('change', function () {
loadFacilities(this.value, null, null);
loadTemplates(this.value);
});
facilitySel.addEventListener('change', function () {
+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 %}
@@ -892,7 +898,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;
@@ -918,6 +928,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">
+15 -3
View File
@@ -115,10 +115,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>
@@ -137,6 +142,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>
@@ -181,7 +191,7 @@
<select class="form-select form-select-sm quick-assign-select" style="min-width:110px;font-size:.78rem;">
<option value="">— Unassigned —</option>
{% for u in staff %}
<option value="{{ u.id }}" {{ 'selected' if issue.assigned_to == u.id }}>{{ u.display_name }}{{ ' (External)' if u.is_external_inspector }}</option>
<option value="{{ u.id }}" {{ 'selected' if issue.assigned_to == u.id }}>{{ u.display_name }}{{ ' (Customer)' if u.is_external_inspector }}</option>
{% endfor %}
</select>
<span class="quick-assign-spinner spinner-border spinner-border-sm text-secondary d-none" role="status"></span>
@@ -202,7 +212,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) }}">
<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>
@@ -210,7 +220,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
@@ -223,6 +233,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>
@@ -259,6 +270,7 @@
{% endblock %}
{% block extra_js %}
{% include 'partials/bulk_select_js.html' %}
<script>
(function () {
'use strict';
+55 -3
View File
@@ -19,6 +19,30 @@
{% 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') %}
{# Is the viewer OUR staff? Drives the internal-only chrome on this page: the
"comments are visible to everyone" warning and the per-comment
"Customer visible" / "Staff only" badges. Both are instructions about how WE
work and must never reach a customer account.
Written as an explicit ALLOWLIST of our own roles, deliberately:
* It FAILS CLOSED. The obvious form, `not current_user.is_customer_account`,
fails OPEN — if the attribute is missing for any reason (a process still
running an older models/user.py after a template-only reload, say) Jinja
yields Undefined, `not Undefined` is true, and the internal text is shown
to exactly the people it must be hidden from. An allowlist of literal role
strings can only ever be true for a role we listed.
* `external_inspector` is absent ON PURPOSE. This is NOT the rule-87 case:
rule 87 is about capability/scoping, where a Customer Inspector must
behave exactly like our own inspector. Here the question is "does this
person work for us?", which is the one place the two genuinely differ.
Do not "fix" this by adding external_inspector to the list. #}
{% set viewer_is_our_staff = current_user.role in
['admin', 'director', 'project_manager', 'auditor', 'inspector'] %}
<div class="row">
{# ══════════════════════════════════ LEFT COLUMN ══════════════════════════════════ #}
@@ -187,6 +211,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"
@@ -230,8 +255,12 @@
{% else %}
<span class="badge bg-secondary" style="font-size:.65rem;">{{ c.author.role|replace('_',' ')|title }}</span>
{% endif %}
{# Visibility indicator — staff only #}
{% if current_user.role != 'customer' %}
{# Visibility indicator — OUR staff only, and only while the
per-comment flag still decides anything. While comments_open
is set EVERY comment reaches the customer, so a "Staff only"
badge would be a lie; it is suppressed rather than shown
incorrectly. #}
{% if viewer_is_our_staff and not comments_open %}
{% if c.is_customer_visible %}
<span class="badge bg-success bg-opacity-10 text-success border border-success"
style="font-size:.6rem;" title="Customer can see this comment">
@@ -274,6 +303,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 }}">
@@ -281,6 +311,25 @@
<textarea name="update_notes" class="form-control" rows="3"
placeholder="Write a comment…" required></textarea>
</div>
{# While comments_open is set, every comment reaches the customer, so
the "Share with customer" tick decides nothing. Saying so plainly
matters: a staff member must not write something they believe is
private. The checkbox is still posted and recorded, so turning the
setting off restores its meaning immediately.
OUR STAFF ONLY. `can_edit` is also true for a Customer Inspector
assigned to the issue, and this banner is an internal-process
warning ("do not post internal-only notes") — showing it to a
customer account exposes how we work and reads as nonsense to
them, since nothing they write was ever private. #}
{% if comments_open and viewer_is_our_staff %}
<div class="alert alert-warning py-2 px-3 small mb-2">
<i class="bi bi-eye me-1"></i>
<strong>Comments are currently visible to everyone,</strong> including
the customer, regardless of the tick below. Do not post internal-only
notes here.
</div>
{% endif %}
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
<div class="form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox"
@@ -301,6 +350,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"
@@ -369,6 +419,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") }}
@@ -545,7 +596,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">
@@ -583,6 +634,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
+5 -1
View File
@@ -193,7 +193,11 @@
</a>
</li>
{% endif %}
{% if current_user.role == 'customer' %}
{# Both customer-side roles get the Support menu — a Customer
Inspector works at the customer's facilities and has the
same questions. The assistant answers them for their own
role (see _role addendum in routes/support.py). #}
{% if current_user.is_customer_account %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
+5 -19
View File
@@ -187,25 +187,11 @@
<i class="bi bi-bell-slash me-1"></i>Notification Preferences
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
{# ── Design switcher (modern → classic) ── #}
<form method="POST" action="{{ url_for('ui.switch_theme') }}" class="px-1">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="theme" value="classic">
<input type="hidden" name="next" value="{{ request.full_path }}">
<button type="submit" class="dropdown-item">
<i class="bi bi-arrow-counterclockwise me-1"></i>Back to Classic Design
</button>
</form>
</li>
{% if current_user.role == 'admin' %}
<li>
<a class="dropdown-item" href="{{ url_for('ui.theme_votes') }}">
<i class="bi bi-bar-chart me-1"></i>Design Vote Tally
</a>
</li>
{% endif %}
{# The design A/B test is over — modern is THE design (Aug 2026).
The switcher and the vote tally are gone from this menu.
ui.switch_theme / ui.theme_votes still exist and still work
if visited directly, so nothing is stranded mid-request;
they are simply no longer offered. #}
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">
+18 -4
View File
@@ -25,16 +25,18 @@
<div>
<div class="jqc-page-title">Inspections</div>
</div>
{% if current_user.role != 'customer' %}
{# Customer Directors schedule inspections for their own facilities, so the
Scheduled link is theirs too — but starting an ad-hoc inspection is not. #}
<div class="d-flex gap-2">
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-outline-primary">
<i class="bi bi-calendar-check"></i> Scheduled
</a>
{% if current_user.role != 'customer' %}
<a href="{{ url_for('inspections.start') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> New Inspection
</a>
{% endif %}
</div>
{% endif %}
</div>
{# ── Filters ──────────────────────────────────────────────────────────── #}
@@ -162,10 +164,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 +181,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 +227,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 +299,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 +312,7 @@
{% endblock %}
{% block extra_js %}
{% include 'partials/bulk_select_js.html' %}
<script>
(function () {
'use strict';
+15 -3
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>
@@ -236,7 +246,7 @@
<select class="form-select form-select-sm quick-assign-select" style="min-width:110px;font-size:.78rem;">
<option value="">— Unassigned —</option>
{% for u in staff %}
<option value="{{ u.id }}" {{ 'selected' if issue.assigned_to == u.id }}>{{ u.display_name }}{{ ' (External)' if u.is_external_inspector }}</option>
<option value="{{ u.id }}" {{ 'selected' if issue.assigned_to == u.id }}>{{ u.display_name }}{{ ' (Customer)' if u.is_external_inspector }}</option>
{% endfor %}
</select>
<span class="quick-assign-spinner spinner-border spinner-border-sm text-secondary d-none" role="status"></span>
@@ -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>
@@ -116,7 +116,7 @@
<td class="fw-semibold">
{{ s.display_name }}
{% if s.external %}
<span class="badge bg-dark ms-1" title="Customer / third-party inspector">External</span>
<span class="badge bg-dark ms-1" title="Customer / third-party inspector">Customer</span>
{% endif %}
</td>
<td class="text-center">{{ s.total }}</td>
@@ -193,7 +193,7 @@
<h6 class="mb-0">
<i class="bi bi-person-circle me-2"></i>{{ selected_inspector.display_name }}
{% if selected_inspector.is_external_inspector %}
<span class="badge bg-dark ms-1" title="Customer / third-party inspector">External</span>
<span class="badge bg-dark ms-1" title="Customer / third-party inspector">Customer</span>
{% endif %}
</h6>
<a href="{{ url_for('reports.inspector_performance', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}"
@@ -11,6 +11,10 @@
<a href="{{ url_for('support.admin_tickets') }}" class="btn btn-outline-secondary btn-sm me-1">
<i class="bi bi-inbox me-1"></i>Tickets
</a>
<a href="{{ url_for('support.admin_knowledge_preview') }}" class="btn btn-outline-primary btn-sm"
title="See the exact prompt the chatbot receives, with your entries in it">
<i class="bi bi-eye me-1"></i>What the AI Sees
</a>
<a href="{{ url_for('support.admin_conversations') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-chat-dots me-1"></i>Conversations
</a>
@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}What the AI Sees{% endblock %}
{# Read-only view of the assembled system prompt. Exists so an admin can tell
"my knowledge entry never reached the prompt" apart from "the model saw it
and chose not to use it" — the two have completely different fixes. #}
{% block content %}
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3 gap-2">
<h2 class="mb-0"><i class="bi bi-eye"></i> What the AI Sees</h2>
<a href="{{ url_for('support.admin_knowledge') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Knowledge Base
</a>
</div>
<div class="row g-3 mb-3">
<div class="col-6 col-md-3">
<div class="card shadow-sm h-100">
<div class="card-body text-center py-3">
<div class="fs-4 fw-bold">{{ active_count }}</div>
<div class="text-muted small">Active entries</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm h-100">
<div class="card-body text-center py-3">
<div class="fs-4 fw-bold">{{ total_count - active_count }}</div>
<div class="text-muted small">Inactive (not sent)</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm h-100">
<div class="card-body text-center py-3">
<div class="fs-4 fw-bold">{{ prompt | length }}</div>
<div class="text-muted small">Prompt characters</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm h-100">
<div class="card-body text-center py-3">
{% if kb_included %}
<div class="fs-4 fw-bold text-success"><i class="bi bi-check-circle"></i></div>
<div class="text-muted small">Knowledge included</div>
{% else %}
<div class="fs-4 fw-bold text-danger"><i class="bi bi-x-circle"></i></div>
<div class="text-muted small">Knowledge NOT included</div>
{% endif %}
</div>
</div>
</div>
</div>
{% if not kb_included and total_count %}
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-1"></i>
You have {{ total_count }} knowledge entr{{ 'y' if total_count == 1 else 'ies' }}, but
none reached the prompt. Check that at least one is marked <strong>Active</strong>.
</div>
{% endif %}
<div class="alert alert-info">
<i class="bi bi-info-circle me-1"></i>
This is the exact text sent to the AI ahead of every customer question. Entries are
capped at {{ kb_cap }} characters in total — past that, later entries are dropped
(lowest sort order is kept first). If something you wrote appears here but the AI still
will not say it, the wording of the entry is the thing to change, not the setup.
</div>
<div class="card shadow-sm">
<div class="card-header bg-light fw-semibold">Assembled system prompt</div>
<div class="card-body p-0">
<pre class="mb-0 p-3" style="white-space:pre-wrap; font-size:.8rem; max-height:70vh;
overflow-y:auto; background:#f8fafc;">{{ prompt }}</pre>
</div>
</div>
{% endblock %}
+9
View File
@@ -160,6 +160,15 @@
{{ form.frequency.label(class="form-label fw-semibold small") }}
{{ form.frequency(class="form-select form-select-sm") }}
</div>
<div class="mb-3">
{{ form.contract_ids.label(class="form-label fw-semibold small") }}
{{ form.contract_ids(class="form-select form-select-sm", size=6) }}
<div class="form-text small">
Nothing selected = shared with every contract. Select contracts to
restrict this form to them (hidden from all other customers).
</div>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-sm">
+12
View File
@@ -27,6 +27,18 @@
{{ form.frequency.label(class="form-label") }}
{{ form.frequency(class="form-select") }}
</div>
<div class="mb-3">
{{ form.contract_ids.label(class="form-label") }}
{{ form.contract_ids(class="form-select", size=6) }}
<div class="form-text">
Leave <strong>nothing selected</strong> to share this form with
every contract. Select one or more contracts to make it
specific to them — it will then be hidden from every other
customer's facilities, on the web and in the iPad app.
Ctrl/Cmd-click to select several.
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
+67 -1
View File
@@ -38,6 +38,24 @@
<i class="bi bi-check2-square"></i> {{ template.checklist_items.count() }} items
</small>
</div>
{# phase52 — who may use this form. No links = shared with all. #}
<div class="mt-2">
{% if template.is_shared %}
<span class="badge bg-light text-dark border"
title="Available on every contract">
<i class="bi bi-globe2"></i> Shared
</span>
{% else %}
{% for link in template.contract_links %}
<span class="badge bg-primary"
title="Only available on this contract">
<i class="bi bi-briefcase"></i>
{{ link.project.name if link.project else 'contract #' ~ link.project_id }}
</span>
{% endfor %}
{% endif %}
</div>
</div>
<div class="card-footer bg-transparent d-flex gap-2 align-items-center flex-wrap">
@@ -56,6 +74,7 @@
data-template-name="{{ template.name }}"
data-template-description="{{ template.description or '' }}"
data-template-frequency="{{ template.frequency or 'daily' }}"
data-template-contracts="{{ template.contract_ids|join(',') }}"
title="Edit template details">
<i class="bi bi-pencil"></i> Edit
</button>
@@ -142,7 +161,7 @@
placeholder="Optional description"></textarea>
</div>
<div class="mb-1">
<div class="mb-3">
<label for="editFrequency" class="form-label fw-semibold">Frequency</label>
<select id="editFrequency" name="frequency" class="form-select">
<option value="daily">Daily</option>
@@ -151,6 +170,34 @@
<option value="quarterly">Quarterly</option>
</select>
</div>
{# phase52 — which contracts may use this form. The hidden
marker tells the route this modal really did include the
field, so an empty selection means "share it" rather
than "no field was posted, leave it alone". #}
<div class="mb-1">
<label for="editContracts" class="form-label fw-semibold">
Available on contracts
</label>
<input type="hidden" name="contracts_present" value="1">
<select id="editContracts" name="contract_ids"
class="form-select" multiple size="6">
{% for p in contracts %}
<option value="{{ p.id }}">{{ p.name }}</option>
{% endfor %}
</select>
<div class="form-text">
Leave <strong>nothing selected</strong> to share this form with
every contract (this is how all existing forms are set).
Select one or more contracts to restrict it to them — it is
then hidden from every other customer, on the web and in the
iPad app. Ctrl/Cmd-click to select several.
</div>
<button type="button" id="clearContracts"
class="btn btn-sm btn-link px-0 mt-1">
Clear selection (make shared)
</button>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
@@ -228,6 +275,17 @@ document.addEventListener('DOMContentLoaded', function () {
document.getElementById('renameInput').value = templateName;
document.getElementById('editDescription').value = templateDesc;
document.getElementById('editFrequency').value = templateFreq;
// Pre-tick the contracts this form is currently restricted to. An
// empty attribute means it is shared, so nothing is selected.
const contractSel = document.getElementById('editContracts');
if (contractSel) {
const current = (btn.getAttribute('data-template-contracts') || '')
.split(',').filter(Boolean);
Array.from(contractSel.options).forEach(function (o) {
o.selected = current.indexOf(o.value) !== -1;
});
}
document.getElementById('renameTemplateForm').action =
'/templates/' + templateId + '/rename';
@@ -238,6 +296,14 @@ document.addEventListener('DOMContentLoaded', function () {
});
});
const clearBtn = document.getElementById('clearContracts');
if (clearBtn) {
clearBtn.addEventListener('click', function () {
const sel = document.getElementById('editContracts');
Array.from(sel.options).forEach(function (o) { o.selected = false; });
});
}
const deleteModal = document.getElementById('deleteModal');
deleteModal.addEventListener('show.bs.modal', function (event) {
const btn = event.relatedTarget;
+2 -1
View File
@@ -60,7 +60,8 @@
<div class="jqc-hub-title">Add More People</div>
<div class="jqc-hub-text">
Create accounts for colleagues and set what each person can do.
External inspectors are invited by email and choose their own password.
Customer Directors and Customer Inspectors are invited by email from
Customer Management and choose their own username and password.
</div>
</div>
</div>
+3 -3
View File
@@ -15,7 +15,7 @@
{# ── Live support routes (role-aware) ──────────────────────────────────── #}
<div class="row g-3 mb-4">
{% if current_user.role == 'customer' %}
{% if current_user.is_customer_account %}
<div class="col-12 col-md-4">
<a class="jqc-hub-card" href="{{ url_for('support.chat') }}">
<div class="d-flex gap-3 align-items-center">
@@ -113,13 +113,13 @@
</div>
{% endfor %}
{# The AI chat is customer-only (support.chat redirects staff to the ticket
{# The AI chat is for customer-side accounts (support.chat redirects staff to the ticket
queue, which is itself @supervisor_required). So the destination is chosen
per role rather than pointed at support.chat for everyone — an inspector
following that chain would land on the dashboard with an access-denied
flash, and this page is meant never to dead-end. Roles with no support
destination get no card; the how-to guides below are their support. #}
{% if current_user.role == 'customer' %}
{% if current_user.is_customer_account %}
<div class="col-12 col-md-6 col-xl-4">
<a class="jqc-hub-card dark" href="{{ url_for('support.chat') }}">
<div class="d-flex gap-3 align-items-center">