Aug 19 - Update code to catch up with ST
This commit is contained in:
@@ -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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 & Send Invitation
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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') }}">
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 & 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 %}
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user