Aug 17 - Update customer roles management
This commit is contained in:
@@ -79,19 +79,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# phase51 — an External Inspector is invited by email and chooses
|
||||
their own username and 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 username and
|
||||
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") }}
|
||||
@@ -141,26 +132,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,78 @@
|
||||
</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() }}">
|
||||
|
||||
<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>
|
||||
<td class="text-center">
|
||||
<input class="form-check-input" type="radio"
|
||||
name="event_{{ row.event }}" value="inherit"
|
||||
{% if row.override is none %}checked{% endif %}>
|
||||
<div class="text-muted" style="font-size:.62rem;">
|
||||
{{ 'on' if row.global else 'off' }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<input class="form-check-input" type="radio"
|
||||
name="event_{{ row.event }}" value="on"
|
||||
{% if row.override is true %}checked{% endif %}>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<input class="form-check-input" type="radio"
|
||||
name="event_{{ row.event }}" value="off"
|
||||
{% if row.override is false %}checked{% endif %}>
|
||||
</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 +377,31 @@
|
||||
(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); });
|
||||
|
||||
// ── 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;
|
||||
|
||||
@@ -579,7 +579,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>
|
||||
|
||||
@@ -177,7 +177,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>
|
||||
|
||||
@@ -236,7 +236,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>
|
||||
|
||||
@@ -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')) }}"
|
||||
|
||||
Reference in New Issue
Block a user