475 lines
20 KiB
HTML
475 lines
20 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}{{ customer.username }} — Customer Portal{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="row mb-4 align-items-center">
|
|
<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 %}
|
|
<span class="badge bg-success ms-2 fs-6">Active</span>
|
|
{% endif %}
|
|
</h2>
|
|
<p class="text-muted mb-0 small">{{ customer.email }}{% if customer.full_name %} · @{{ customer.username }}{% endif %}</p>
|
|
</div>
|
|
<div class="col-auto d-flex gap-2">
|
|
<a href="{{ url_for('customers.edit', customer_id=customer.id) }}"
|
|
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() }}">
|
|
<button type="submit"
|
|
class="btn btn-sm {{ 'btn-outline-warning' if customer.active else 'btn-outline-success' }}"
|
|
onclick="return confirm('{{ 'Disable' if customer.active else 'Enable' }} {{ customer.username }}?')">
|
|
<i class="bi bi-{{ 'person-slash' if customer.active else 'person-check' }} me-1"></i>
|
|
{{ 'Disable' if customer.active else 'Enable' }}
|
|
</button>
|
|
</form>
|
|
<a href="{{ url_for('customers.index') }}" class="btn btn-outline-primary btn-sm">
|
|
<i class="bi bi-arrow-left"></i> All Customers
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row g-4">
|
|
|
|
{# ── Left column: account info + scoped facilities ── #}
|
|
<div class="col-md-4">
|
|
|
|
<div class="card shadow-sm mb-3">
|
|
<div class="card-header bg-light fw-semibold">
|
|
<i class="bi bi-person-circle me-1"></i> Account Details
|
|
</div>
|
|
<div class="card-body">
|
|
<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>
|
|
<dd class="col-7">{{ customer.email }}</dd>
|
|
<dt class="col-5 text-muted">Status</dt>
|
|
<dd class="col-7">
|
|
<span class="badge bg-{{ 'success' if customer.active else 'secondary' }}">
|
|
{{ 'Active' if customer.active else 'Disabled' }}
|
|
</span>
|
|
</dd>
|
|
<dt class="col-5 text-muted">Password</dt>
|
|
<dd class="col-7">
|
|
{% if customer.password_set %}
|
|
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Set</span>
|
|
{% else %}
|
|
<span class="badge bg-warning text-dark"><i class="bi bi-hourglass-split me-1"></i>Pending setup</span>
|
|
{% endif %}
|
|
</dd>
|
|
<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">{{ 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>
|
|
{% if not customer.password_set %}
|
|
<hr class="my-3">
|
|
<form method="POST"
|
|
action="{{ url_for('customers.resend_invite', customer_id=customer.id) }}">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
<button type="submit" class="btn btn-outline-primary btn-sm w-100"
|
|
onclick="return confirm('Resend invitation email to {{ customer.email }}?')">
|
|
<i class="bi bi-send me-1"></i>Resend Invitation Email
|
|
</button>
|
|
</form>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
{# ── Scoped facilities ── #}
|
|
<div class="card shadow-sm">
|
|
<div class="card-header bg-light fw-semibold">
|
|
<i class="bi bi-building me-1"></i> Accessible Facilities
|
|
</div>
|
|
{% if facilities %}
|
|
<div class="card-body p-0">
|
|
<ul class="list-group list-group-flush">
|
|
{% for f in facilities %}
|
|
<li class="list-group-item d-flex justify-content-between align-items-center
|
|
py-2 px-3 small">
|
|
<span>
|
|
<i class="bi bi-building text-muted me-1"></i>{{ f.name }}
|
|
</span>
|
|
{% if f.project %}
|
|
<span class="badge bg-primary" style="font-size:.65rem;">{{ f.project.name }}</span>
|
|
{% endif %}
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
</div>
|
|
{% else %}
|
|
<div class="card-body text-muted small">
|
|
No facilities accessible yet — add an assignment below.
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{# ── 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">
|
|
<span><i class="bi bi-diagram-3 me-1"></i> Contract Assignments</span>
|
|
</div>
|
|
{% if assignments %}
|
|
<div class="card-body p-0 table-responsive">
|
|
<table class="table table-hover table-sm mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>Contract</th>
|
|
<th>Facility Scope</th>
|
|
<th>Assigned</th>
|
|
<th width="60"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for a in assignments %}
|
|
<tr>
|
|
<td class="small">
|
|
<a href="{{ url_for('projects.view', project_id=a.project_id) }}"
|
|
class="text-decoration-none">
|
|
{{ a.project.name }}
|
|
</a>
|
|
</td>
|
|
<td>
|
|
{% if a.facility %}
|
|
<span class="badge bg-info text-dark small">{{ a.facility.name }}</span>
|
|
{% else %}
|
|
<span class="badge bg-secondary small">All facilities</span>
|
|
{% endif %}
|
|
</td>
|
|
<td class="text-muted small">{{ a.created_at.strftime('%Y-%m-%d') }}</td>
|
|
<td>
|
|
<form method="POST"
|
|
action="{{ url_for('customers.remove_assignment', assignment_id=a.id) }}"
|
|
onsubmit="return confirm('Remove this assignment?');">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
<button type="submit" class="btn btn-sm btn-outline-danger"
|
|
title="Remove">
|
|
<i class="bi bi-x-lg"></i>
|
|
</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% else %}
|
|
<div class="card-body text-muted small">No assignments yet.</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{# ── Add assignment form ── #}
|
|
<div class="card shadow-sm">
|
|
<div class="card-header bg-light fw-semibold">
|
|
<i class="bi bi-plus-circle me-1"></i> Add Assignment
|
|
</div>
|
|
<div class="card-body">
|
|
<form method="POST"
|
|
action="{{ url_for('customers.add_assignment', customer_id=customer.id) }}">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
|
|
<div class="row g-3 align-items-end">
|
|
<div class="col-md-5">
|
|
<label class="form-label small fw-semibold">Contract</label>
|
|
<select name="project_id" id="proj-select" class="form-select form-select-sm"
|
|
required>
|
|
<option value="">— Select contract —</option>
|
|
{% for p in projects %}
|
|
<option value="{{ p.id }}">{{ p.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div class="col-md-5">
|
|
<label class="form-label small fw-semibold">Facility Scope</label>
|
|
<select name="facility_id" id="fac-select" class="form-select form-select-sm">
|
|
<option value="">— All facilities in contract —</option>
|
|
</select>
|
|
<div class="form-text" style="font-size:.72rem;">
|
|
Leave blank to grant access to all facilities in the contract.
|
|
</div>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<button type="submit" class="btn btn-primary btn-sm w-100">
|
|
<i class="bi bi-plus-circle me-1"></i> Add
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</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>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
(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;
|
|
facSelect.innerHTML = '<option value="">— All facilities in contract —</option>';
|
|
|
|
if (!projectId) return;
|
|
|
|
fetch('/customers/facilities-for-project/' + projectId, { credentials: 'same-origin' })
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
data.forEach(function (f) {
|
|
const opt = document.createElement('option');
|
|
opt.value = f.id;
|
|
opt.textContent = f.name;
|
|
facSelect.appendChild(opt);
|
|
});
|
|
})
|
|
.catch(function () {});
|
|
});
|
|
}());
|
|
</script>
|
|
{% endblock %}
|