Files
JQC_multi_tenant/app/templates/inspections/view.html
T

1022 lines
49 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Inspection #{{ inspection.id }} — Results{% endblock %}
{# The filtered list URL this page was opened from. Defined at TOP LEVEL, not
inside the content block: Jinja blocks do not share scope, and the follow-up
modal below sits in the extra_js block — from there a content-scoped
back_url is Undefined and posts next="", silently dropping the very filters
this mechanism exists to preserve. #}
{% set back_url = request.args.get('next') or url_for('inspections.index') %}
{% block extra_css %}
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<style>
body { font-family:'DM Sans', sans-serif; background:#eef0f4; }
.insp-wrap { max-width:1000px; margin:0 auto; padding:0 1rem 4rem; }
.insp-header {
background:#1a1d23; color:#fff; padding:1.1rem 1.75rem;
border-radius:12px 12px 0 0;
display:flex; align-items:center; justify-content:space-between; gap:1rem;
}
.insp-header h4 { margin:0; font-weight:600; font-size:1.05rem; }
.insp-header .sub { font-size:.78rem; color:#94a3b8; margin-top:.2rem; }
.score-badge {
font-size:1.5rem; font-weight:700; padding:.4rem 1rem;
border-radius:8px; min-width:80px; text-align:center;
}
.insp-body {
background:#fff; border:1px solid #e2e8f0; border-top:none;
border-radius:0 0 12px 12px; padding:1.75rem; padding-bottom:3rem; overflow-x:auto;
-webkit-overflow-scrolling: touch;
}
.meta-row { display:flex; flex-wrap:wrap; gap:1.5rem; margin-bottom:1.5rem; padding-bottom:1rem; border-bottom:1px solid #e2e8f0; }
.meta-item { display:flex; flex-direction:column; }
.meta-item .lbl { font-size:.72rem; color:#94a3b8; font-weight:500; text-transform:uppercase; letter-spacing:.04em; }
.meta-item .val { font-size:.9rem; color:#0f172a; font-weight:500; margin-top:.1rem; }
/* ── Grid: 12 cols × 36px rows, tight 3px gap ── */
.form-grid {
display:grid;
grid-template-columns: repeat(12, 72px);
grid-auto-rows: minmax(36px, auto);
gap: 3px 8px;
width: max-content;
}
/* iPad fluid grid override */
@media (max-width: 1194px) {
.form-grid {
--_cell: calc((min(calc(100vw - 2rem), 900px) - 11 * 8px) / 12);
grid-template-columns: repeat(12, var(--_cell));
grid-auto-rows: minmax(calc(var(--_cell) * 0.5), auto);
width: 100%;
}
.insp-body { padding: 1rem; }
}
.fg-cell {
overflow:hidden; display:flex; flex-direction:column;
padding:.1rem .4rem;
}
.fg-cell .field-lbl {
font-size:.68rem; font-weight:600; color:#94a3b8;
display:block; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
flex-shrink:0; line-height:1.3;
}
.fg-cell .field-val {
font-size:.78rem; color:#0f172a; background:#f8fafc;
border:1px solid #e2e8f0; border-radius:4px;
padding:.1rem .35rem; flex:1; min-height:0;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
display:flex; align-items:center;
}
.fg-cell .field-val.empty { color:#94a3b8; font-style:italic; }
/* Textarea values: wrap text, scroll vertically, align to top */
.fg-cell .field-val.multiline {
white-space:pre-wrap; overflow-y:auto; overflow-x:hidden;
text-overflow:clip; align-items:flex-start; word-break:break-word;
min-height:calc(0.78rem * 1.5 * 4 + 0.2rem * 2); /* 4 lines × line-height + padding */
flex:none;
}
/* Section heading — spans full width, single row */
.section-head {
display:flex; align-items:flex-end; padding-bottom:.15rem;
border-bottom:2px solid #e2e8f0; height:100%;
}
.section-head strong { font-weight:700; color:#374151; font-size:.88rem; }
/* Rating — inline stars */
.rating-line { display:flex; align-items:center; gap:.28rem; flex:1; min-height:0; }
.rating-stars-ro { color:#f59e0b; font-size:.9rem; letter-spacing:.02rem; line-height:1; white-space:nowrap; }
.rating-score { font-size:.7rem; color:#64748b; white-space:nowrap; }
/* View Photo / Signature button */
.btn-view-media {
display:inline-flex; align-items:center; gap:.2rem;
font-size:.68rem; padding:.1rem .42rem; border-radius:4px;
border:1px solid #cbd5e1; background:#f8fafc; color:#374151;
cursor:pointer; white-space:nowrap;
transition:border-color .12s, background .12s;
}
.btn-view-media:hover { border-color:#2563eb; background:#eff6ff; color:#2563eb; }
/* Media lightbox */
.media-backdrop {
display:none; position:fixed; inset:0; z-index:1055;
background:rgba(0,0,0,.65); align-items:center; justify-content:center;
}
.media-backdrop.open { display:flex; }
.media-modal {
background:#fff; border-radius:10px; overflow:hidden;
max-width:min(92vw,700px); max-height:90vh;
display:flex; flex-direction:column;
box-shadow:0 8px 40px rgba(0,0,0,.35);
}
.media-modal-head {
display:flex; align-items:center; justify-content:space-between;
padding:.5rem .9rem; border-bottom:1px solid #e2e8f0;
font-size:.8rem; font-weight:600; color:#374151;
}
.media-modal-close { border:none; background:none; font-size:1.1rem; color:#64748b; cursor:pointer; line-height:1; padding:0; }
.media-modal-close:hover { color:#dc2626; }
.media-modal img { display:block; max-width:100%; max-height:calc(90vh - 46px); object-fit:contain; }
/* Issues */
.tbl-view { width:100%; border-collapse:collapse; font-size:.76rem; }
.tbl-view th { background:#f1f5f9; font-weight:600; color:#374151; padding:.2rem .4rem; border:1px solid #e2e8f0; }
.tbl-view td { border:1px solid #e2e8f0; padding:.15rem .4rem; color:#0f172a; }
/* ── Print styles ── */
@media print {
/* ── 1. Hide all screen-only chrome ── */
nav, .navbar, header,
.d-flex.justify-content-between.align-items-center.mb-3,
.alert-warning,
.modal, .modal-backdrop,
.media-backdrop,
a.btn, button,
.btn { display: none !important; }
/* ── 2. Page / body reset ── */
html, body { background: #fff !important;
font-family: Helvetica, Arial, sans-serif;
font-size: 9pt; color: #1a1d23; }
.insp-wrap { max-width: 100% !important;
padding: 0 !important; margin: 0 !important; }
@page { margin: 0.65in; size: letter; }
/* ── 3. Dark header band (matches PDF header) ── */
.insp-header { background: #1a1d23 !important;
color: #fff !important;
border-radius: 0 !important;
padding: 10pt 14pt !important;
display: flex !important;
justify-content: space-between;
align-items: center;
page-break-after: avoid;
-webkit-print-color-adjust: exact;
print-color-adjust: exact; }
.insp-header h4 { font-size: 12pt !important;
color: #fff !important; margin: 0; }
.insp-header .sub { font-size: 8pt !important;
color: #94a3b8 !important; }
/* Score badge in header */
.score-badge { font-size: 13pt !important;
font-weight: 700;
padding: 4pt 10pt !important;
border-radius: 6pt !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact; }
/* ── 4. Body panel ── */
.insp-body { border: 1pt solid #e2e8f0 !important;
border-top: none !important;
border-radius: 0 !important;
box-shadow: none !important;
padding: 12pt !important; }
/* ── 5. Meta row — render as a bordered grid (matches PDF _meta_table) ── */
.meta-row { display: grid !important;
grid-template-columns: repeat(3, 1fr);
gap: 0 !important;
border: 0.5pt solid #e2e8f0;
background: #f1f5f9 !important;
margin-bottom: 10pt !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact; }
.meta-item { padding: 4pt 6pt !important;
border: 0.25pt solid #e2e8f0 !important;
background: #f1f5f9 !important; }
.meta-item .lbl { font-size: 7pt !important;
color: #64748b !important;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
display: block; }
.meta-item .val { font-size: 9pt !important;
color: #1a1d23 !important;
font-weight: 700;
display: block; margin-top: 1pt; }
/* ── 6. Score / status comparison alerts (parent/child links) ── */
.alert-info, .alert-secondary { display: block !important;
font-size: 7.5pt;
border: 0.5pt solid #e2e8f0 !important;
padding: 4pt 8pt !important;
margin-bottom: 6pt !important;
background: #f8fafc !important;
border-radius: 0 !important; }
/* ── 7. Section headings (matches PDF SectionHead + HR rule) ── */
.section-head { background: none !important;
border-bottom: 1.5pt solid #e2e8f0 !important;
padding: 0 0 2pt 0 !important;
margin: 10pt 0 4pt 0 !important;
page-break-after: avoid; }
.section-head strong { font-size: 9.5pt !important;
font-weight: 700 !important;
color: #1a1d23 !important; }
/* ── 8. Form grid — keep CSS grid, scale columns to page width ──
Letter page at 0.65in margins → ~7.7in usable.
12 equal columns: each unit = 7.7in / 12 ≈ 0.642in.
We replicate the iPad fluid override pattern but for print. ── */
.form-grid { display: grid !important;
--_cell: calc((100% - 11 * 6pt) / 12);
grid-template-columns: repeat(12, var(--_cell)) !important;
grid-auto-rows: minmax(28pt, auto) !important;
gap: 3pt 6pt !important;
width: 100% !important; }
.fg-cell { overflow: visible !important;
display: flex !important;
flex-direction: column !important;
padding: 2pt 3pt !important;
min-height: 0 !important; }
/* Field label (matches PDF FieldLabel: 7.5pt, slate) */
.fg-cell .field-lbl { font-size: 7pt !important;
color: #64748b !important;
font-weight: 600;
display: block !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
flex-shrink: 0;
margin-bottom: 1pt; }
/* Field value box (matches PDF: light border + #f8fafc bg) */
.fg-cell .field-val { font-size: 8pt !important;
color: #1a1d23 !important;
background: #f8fafc !important;
border: 0.5pt solid #e2e8f0 !important;
border-radius: 2pt !important;
padding: 2pt 4pt !important;
display: flex !important;
align-items: center !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
flex: 1 !important;
min-height: 0 !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact; }
.fg-cell .field-val.multiline { white-space: pre-wrap !important;
overflow: auto !important; }
.fg-cell .field-val.empty { color: #94a3b8 !important;
font-style: italic; }
/* Rating stars */
.rating-stars-ro { font-size: 9pt !important;
color: #f59e0b !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact; }
.rating-line { display: flex !important;
align-items: center;
gap: 4pt; }
.rating-score { font-size: 7pt !important;
color: #64748b !important; }
/* Images: show inline, constrained */
.fg-cell img { max-width: 2.8in !important;
max-height: 1.8in !important;
object-fit: contain;
display: block;
margin-top: 3pt; }
/* Photo/signature view buttons — replace with static label */
.btn-view-media { display: none !important; }
/* ── 9. Issues table (matches PDF dark header, alternating rows) ── */
.tbl-view { width: 100%; border-collapse: collapse;
font-size: 7.5pt; margin-top: 4pt; }
.tbl-view thead th { background: #1a1d23 !important;
color: #fff !important;
font-weight: 700;
padding: 3pt 5pt !important;
border: 0.25pt solid #e2e8f0 !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact; }
.tbl-view tbody tr:nth-child(even) td
{ background: #f1f5f9 !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact; }
.tbl-view td { padding: 3pt 5pt !important;
border: 0.25pt solid #e2e8f0 !important;
vertical-align: top; }
/* Severity badge colours in issues table */
.badge.bg-danger { color: #dc2626 !important;
background: none !important;
font-weight: 700; }
.badge.bg-warning { color: #d97706 !important;
background: none !important;
font-weight: 700; }
.badge.bg-secondary { color: #64748b !important;
background: none !important; }
.badge.bg-success { color: #16a34a !important;
background: none !important;
font-weight: 700; }
/* ── 10. Notes block (matches PDF #fffbeb background) ── */
[style*="white-space:pre-wrap"]
{ white-space: pre-wrap !important;
background: #fffbeb !important;
border: 0.5pt solid #e2e8f0 !important;
padding: 5pt 7pt !important;
font-size: 8.5pt !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact; }
/* ── 11. Print sign-off block ── */
.print-signoff { display: block !important;
margin-top: 24pt;
page-break-inside: avoid; }
/* ── 12. Page-break hints ── */
.insp-header { page-break-after: avoid; }
.section-head { page-break-after: avoid; }
hr { border-color: #e2e8f0 !important; }
}
</style>
{% endblock %}
{% block content %}
<div class="insp-wrap mt-3">
{# Action bar #}
<div class="d-flex justify-content-between align-items-center mb-3">
{# `next` carries the filtered list URL from the list page; the
sessionStorage fallback below still covers links opened before
this page started sending one. #}
<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">
<a href="{{ url_for('inspections.export_pdf', inspection_id=inspection.id) }}"
class="btn btn-sm btn-danger">
<i class="bi bi-file-earmark-pdf"></i> Export PDF
</a>
<button onclick="window.print()" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-printer"></i> Print
</button>
{# Offered to managers, to the inspector who did this inspection, and to
whoever the follow-up was assigned to (phase56). Not to any other
inspector who can merely SEE it: reinspect() refuses them, and showing
a button that fails on click is the mismatch this page just fixed. #}
{% if current_user.role not in ['customer']
and (is_own_inspection or owns_follow_up) %}
<a href="{{ url_for('inspections.reinspect', inspection_id=inspection.id) }}"
class="btn btn-sm btn-outline-primary"
title="Start a follow-up re-inspection with the same template and facility">
<i class="bi bi-arrow-repeat"></i> Re-inspect
</a>
{% endif %}
{# phase49 — customers may REQUEST a follow-up on their own completed
inspections; only admin/director can clear one. #}
{% if current_user.role == 'customer' and inspection.status == 'completed'
and not inspection.follow_up_required %}
<button type="button" class="btn btn-sm btn-outline-warning"
data-bs-toggle="modal" data-bs-target="#followupModal"
title="Ask the team to re-inspect this facility">
<i class="bi bi-flag"></i> Request Follow-up
</button>
{% endif %}
{% if current_user.role in ['admin','director'] %}
{% if not inspection.follow_up_required %}
<button type="button" class="btn btn-sm btn-outline-warning"
data-bs-toggle="modal" data-bs-target="#followupModal"
title="Flag this inspection as requiring a follow-up">
<i class="bi bi-flag"></i> Flag Follow-up
</button>
{% else %}
<form method="post"
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>
</form>
{% endif %}
<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 %}
</div>
</div>
{# ── Follow-up required alert ── #}
{% if inspection.follow_up_required %}
<div class="alert alert-warning d-flex align-items-start gap-2 mb-3">
<i class="bi bi-flag-fill mt-1"></i>
<div>
<strong>Follow-up Inspection Required</strong>
{# phase49 — who asked, and whether it was the client or our own staff. #}
{% if inspection.follow_up_requester %}
<span class="badge {{ 'bg-info text-dark' if inspection.follow_up_requester.role == 'customer' else 'bg-secondary' }} ms-1">
{{ 'Requested by customer' if inspection.follow_up_requester.role == 'customer' else 'Requested by staff' }}:
{{ inspection.follow_up_requester.display_name }}
</span>
{% endif %}
{% if inspection.follow_up_requested_at %}
<span class="small text-muted ms-1">{{ inspection.follow_up_requested_at.strftime('%b %d, %Y %I:%M %p') }}</span>
{% endif %}
{# Who is expected to DO it — the assignee when one was named, otherwise
the original inspector (Inspection.follow_up_owner). #}
{% if inspection.follow_up_owner %}
<div class="small mt-1">
<i class="bi bi-person-check me-1"></i>Assigned to
<strong>{{ inspection.follow_up_owner.display_name }}</strong>
{% if not inspection.follow_up_assignee %}
<span class="text-muted">(original inspector)</span>
{% elif inspection.follow_up_owner.id == current_user.id %}
<span class="badge bg-warning text-dark ms-1">You</span>
{% endif %}
</div>
{% endif %}
{% if inspection.follow_up_note %}<br><span class="small">{{ inspection.follow_up_note }}</span>{% endif %}
{# Re-inspection is staff work — reinspect() already refuses customers —
and among inspectors it belongs to the follow-up's OWNER. #}
{% if current_user.role != 'customer'
and (is_own_inspection or owns_follow_up) %}
<div class="mt-2">
<a href="{{ url_for('inspections.reinspect', inspection_id=inspection.id) }}"
class="btn btn-sm btn-warning">
<i class="bi bi-arrow-repeat me-1"></i>Start Re-inspection
</a>
</div>
{% else %}
<div class="small mt-1">The team has been notified and will schedule the re-inspection.</div>
{% endif %}
</div>
</div>
{% endif %}
{# ── Parent/child inspection links ── #}
{% if inspection.parent %}
<div class="alert alert-info small mb-3">
<i class="bi bi-arrow-up-circle me-1"></i>
This is a re-inspection of
<a href="{{ url_for('inspections.view', inspection_id=inspection.parent.id) }}"
class="alert-link">Inspection #{{ inspection.parent.id }}</a>
({{ inspection.parent.inspection_date.strftime('%Y-%m-%d') }},
score: {{ inspection.parent.overall_score|round(1) if inspection.parent.overall_score else 'N/A' }}%).
</div>
{% endif %}
{% set followups = inspection.follow_ups.all() %}
{% if followups %}
<div class="alert alert-secondary small mb-3">
<i class="bi bi-arrow-down-circle me-1"></i>
Follow-up inspection(s):
{% for fu in followups %}
<a href="{{ url_for('inspections.view', inspection_id=fu.id) }}" class="alert-link">
#{{ fu.id }} ({{ fu.inspection_date.strftime('%Y-%m-%d') }},
score: {{ fu.overall_score|round(1) if fu.overall_score else 'N/A' }}%)
</a>{% if not loop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
{# ── Score comparison card (re-inspections only) ── #}
{% if comparison %}
<div class="card shadow-sm mb-3 border-0">
<div class="card-header d-flex justify-content-between align-items-center
bg-{{ 'success' if comparison.score_delta and comparison.score_delta > 0
else 'danger' if comparison.score_delta and comparison.score_delta < 0
else 'secondary' }} text-white">
<span class="fw-semibold">
<i class="bi bi-arrow-left-right me-1"></i>
Score Comparison vs. Inspection #{{ comparison.parent_id }}
<span class="small opacity-75 ms-2">
{{ comparison.parent_date.strftime('%Y-%m-%d') }}
</span>
</span>
<span class="d-flex gap-3 align-items-center">
{# Overall delta badge #}
{% if comparison.score_delta is not none %}
{% if comparison.score_delta > 0 %}
<span class="badge bg-white text-success fw-bold fs-6">
<i class="bi bi-arrow-up-short"></i>+{{ comparison.score_delta }}%
</span>
{% elif comparison.score_delta < 0 %}
<span class="badge bg-white text-danger fw-bold fs-6">
<i class="bi bi-arrow-down-short"></i>{{ comparison.score_delta }}%
</span>
{% else %}
<span class="badge bg-white text-secondary fw-bold fs-6">No change</span>
{% endif %}
{% endif %}
{# Score pills #}
<span class="small opacity-75">
{{ comparison.parent_score|round(1) if comparison.parent_score else '—' }}%
&rarr;
{{ comparison.current_score|round(1) if comparison.current_score else '—' }}%
</span>
</span>
</div>
</div>
{% endif %}
{# Header #}
<div class="insp-header">
<div>
<h4><i class="bi bi-clipboard-check"></i> {{ inspection.template.name }}</h4>
<div class="sub">
{{ inspection.facility.name }}{% if inspection.area %} · {{ inspection.area.name }}{% endif %}
&nbsp;·&nbsp; Inspector: <strong style="color:#e2e8f0;">{{ inspection.inspector.display_name }}</strong>
</div>
</div>
<div class="d-flex align-items-center gap-2">
{# Parity with ST phase43: show that this run came from a schedule. Uses
MT's own column/relationship names (inspection_schedule_id). #}
{% if inspection.inspection_schedule_id %}
<span class="badge bg-info text-dark fs-6" title="Created from a scheduled inspection">
<i class="bi bi-calendar-check"></i> Scheduled{% if inspection.inspection_schedule %} · {{ inspection.inspection_schedule.recurrence_label }}{% endif %}
</span>
{% endif %}
<span class="badge bg-{{ 'success' if inspection.status == 'completed' else 'danger' if inspection.status == 'flagged' else 'secondary' }} fs-6">
{{ inspection.status|replace('_',' ')|title }}
</span>
{% if inspection.overall_score is not none %}
<div class="score-badge bg-{{ 'success' if inspection.overall_score >= 90 else 'warning' if inspection.overall_score >= 70 else 'danger' }} text-white">
{{ inspection.overall_score }}%
</div>
{% endif %}
</div>
</div>
<div class="insp-body">
{# Meta row #}
<div class="meta-row">
<div class="meta-item">
<span class="lbl">Start Date</span>
<span class="val">{{ inspection.inspection_date.strftime('%B %d, %Y %H:%M') }}</span>
</div>
{% if inspection.completed_at %}
<div class="meta-item">
<span class="lbl">Completed</span>
<span class="val">{{ inspection.completed_at.strftime('%B %d, %Y %H:%M') }}</span>
</div>
{% endif %}
<div class="meta-item">
<span class="lbl">Template</span>
<span class="val">{{ inspection.template.name }}</span>
</div>
<div class="meta-item">
<span class="lbl">Frequency</span>
<span class="val">{{ inspection.template.frequency|title }}</span>
</div>
{% if inspection.inspection_schedule and inspection.inspection_schedule.creator %}
<div class="meta-item">
<span class="lbl">Scheduled By</span>
<span class="val">{{ inspection.inspection_schedule.creator.display_name }}</span>
</div>
{% endif %}
</div>
{# ── Submission GPS (admin / director only) ──────────────────────────── #}
{% if current_user.role in ['admin', 'director'] and inspection.submit_latitude and inspection.submit_longitude %}
{% set _lat = inspection.submit_latitude | float %}
{% set _lng = inspection.submit_longitude | float %}
<div class="mb-4">
<div class="lbl mb-1" style="font-size:.72rem;color:#94a3b8;font-weight:500;text-transform:uppercase;letter-spacing:.04em;">
<i class="bi bi-geo-alt-fill text-danger me-1"></i>Submission Location
</div>
<div style="border-radius:10px;overflow:hidden;border:1px solid #e2e8f0;max-width:420px;">
<iframe
src="https://maps.google.com/maps?q={{ _lat }},{{ _lng }}&z=15&output=embed"
width="420" height="220"
style="border:0;display:block;width:100%;"
allowfullscreen="" loading="lazy"
referrerpolicy="no-referrer-when-downgrade">
</iframe>
</div>
<div class="mt-1" style="font-size:.8rem;color:#64748b;">
{{ '%.6f' | format(_lat) }}, {{ '%.6f' | format(_lng) }}
<a href="https://www.google.com/maps?q={{ _lat }},{{ _lng }}" target="_blank"
class="ms-2 text-decoration-none small">
<i class="bi bi-box-arrow-up-right"></i> Open in Maps
</a>
</div>
</div>
{% endif %}
{# ── Build the filtered field set ──────────────────────────────────────
Strategy:
1. Find all grid rows that contain at least one rated rating field.
2. Collect every field whose grid row overlaps a rated row.
3. Also collect section fields that immediately precede a rated group.
4. Re-number rows sequentially (1-based) so there are no gaps.
#}
{% if form_fields %}
{# Pass 1: find all rated rows AND which label IDs have a rated field after them #}
{% set ns = namespace(rated_rows=[], visible_label_ids=[]) %}
{# 1a: collect rows that have any answered/filled field (not just ratings) #}
{% set _skip_types = ['label', 'section', 'button_submit', 'button_print', 'button_email'] %}
{% for field in form_fields %}
{% if field.type not in _skip_types %}
{% set fval = form_data.get(field.id | string, '') %}
{# A row is "visible" if: rating has score>0, OR any other field has a non-empty value #}
{% if field.type == 'rating' %}
{% set score = fval | int %}
{% if score > 0 %}
{% for r in range(field.row, field.row + field.rowSpan) %}
{% if r not in ns.rated_rows %}{% set ns.rated_rows = ns.rated_rows + [r] %}{% endif %}
{% endfor %}
{% endif %}
{% elif fval and fval != '' and fval != [] %}
{% for r in range(field.row, field.row + field.rowSpan) %}
{% if r not in ns.rated_rows %}{% set ns.rated_rows = ns.rated_rows + [r] %}{% endif %}
{% endfor %}
{% endif %}
{% endif %}
{% endfor %}
{# 1b: walk fields in order; accumulate label IDs.
When we hit any answered field (rating > 0, pass_fail answered, text filled, etc.),
mark ALL accumulated label IDs as visible and clear the buffer. #}
{% set lbuf = namespace(ids=[]) %}
{% for field in form_fields %}
{% if field.type == 'label' %}
{% set lbuf.ids = lbuf.ids + [field.id] %}
{% elif field.type not in _skip_types %}
{% set fval = form_data.get(field.id | string, '') %}
{% set answered = namespace(v=false) %}
{% if field.type == 'rating' %}
{% if fval | int > 0 %}{% set answered.v = true %}{% endif %}
{% elif fval and fval != '' and fval != [] %}
{% set answered.v = true %}
{% endif %}
{% if answered.v %}
{% for lid in lbuf.ids %}
{% if lid not in ns.visible_label_ids %}
{% set ns.visible_label_ids = ns.visible_label_ids + [lid] %}
{% endif %}
{% endfor %}
{% set lbuf.ids = [] %}
{% endif %}
{% endif %}
{% endfor %}
{# Pass 2: render — labels only if in visible_label_ids,
data fields only if row overlaps rated_rows, sections always buffered. #}
{% set remap = namespace(out_row=1, last_orig_row=-1, pending_sec=none) %}
<div class="form-grid">
{% for field in form_fields %}
{% set ftype = field.type %}
{# Track pending section #}
{% if ftype == 'section' %}
{% set remap.pending_sec = field %}
{% elif ftype in ('button_submit', 'button_print', 'button_email') %}
{# skip — action buttons not shown in read-only view #}
{% elif ftype == 'label' %}
{# Only show labels that are ancestors of rated fields #}
{% if field.id in ns.visible_label_ids %}
{# Advance row only when original row actually changes #}
{% if field.row != remap.last_orig_row %}
{% if remap.last_orig_row != -1 %}{% set remap.out_row = remap.out_row + 1 %}{% endif %}
{# Flush pending section before the first field on this new row #}
{% if remap.pending_sec is not none %}
<div class="fg-cell" style="grid-column:1 / span 12; grid-row:{{ remap.out_row }} / span 1;">
<div class="section-head"><strong>{{ remap.pending_sec.label }}</strong></div>
</div>
{% set remap.out_row = remap.out_row + 1 %}
{% set remap.pending_sec = none %}
{% endif %}
{% set remap.last_orig_row = field.row %}
{% endif %}
{% set fs_map = {'small':'0.72rem','normal':'0.82rem','large':'0.96rem','x-large':'1.1rem'} %}
<div class="fg-cell"
style="grid-column: {{ field.col }} / span {{ field.colSpan }};
grid-row: {{ remap.out_row }} / span 1;">
<div style="font-size:{{ fs_map.get(field.font_size or 'normal','0.82rem') }};
font-weight:{{ field.font_weight or 'normal' }};
color:#374151; overflow:hidden; display:flex; align-items:center; height:100%;">
{{ field.text_content or '' }}
</div>
</div>
{% endif %}
{% else %}
{# Check if this field overlaps any rated row #}
{% set vis = namespace(show=false) %}
{% for r in range(field.row, field.row + field.rowSpan) %}
{% if r in ns.rated_rows %}{% set vis.show = true %}{% endif %}
{% endfor %}
{% if vis.show %}
{# Advance row only when original row actually changes.
Section flush is also guarded here so a label already placed on
this row at the same out_row cannot be displaced. #}
{% if field.row != remap.last_orig_row %}
{% if remap.last_orig_row != -1 %}{% set remap.out_row = remap.out_row + 1 %}{% endif %}
{% if remap.pending_sec is not none %}
<div class="fg-cell" style="grid-column:1 / span 12; grid-row:{{ remap.out_row }} / span 1;">
<div class="section-head"><strong>{{ remap.pending_sec.label }}</strong></div>
</div>
{% set remap.out_row = remap.out_row + 1 %}
{% set remap.pending_sec = none %}
{% endif %}
{% set remap.last_orig_row = field.row %}
{% endif %}
{# Render the field at its original col/colSpan but remapped row #}
<div class="fg-cell"
style="grid-column: {{ field.col }} / span {{ field.colSpan }};
grid-row: {{ remap.out_row }} / span 1;">
{% set fid = field.id | string %}
{% set val = form_data.get(fid, '') %}
{% if ftype == 'rating' %}
<span class="field-lbl">{{ field.label }}</span>
<div class="rating-line">
{% set score = val | int %}
{% if score > 0 %}
<span class="rating-stars-ro">{% for i in range(1,6) %}{{ '★' if i <= score else '☆' }}{% endfor %}</span>
<span class="rating-score">{{ score }}/5</span>
{% else %}
<span style="font-size:.72rem;color:#94a3b8;font-style:italic;">Not rated</span>
{% endif %}
</div>
{% elif ftype == 'image' %}
<span class="field-lbl">{{ field.label }}</span>
<div style="display:flex;align-items:center;flex:1;min-height:0;">
{% if val %}
<button type="button" class="btn-view-media"
onclick="openMedia('{{ media_url(val) }}','{{ field.label | e }}')">
<i class="bi bi-image"></i> View Photo
</button>
{% else %}
<span style="font-size:.72rem;color:#94a3b8;font-style:italic;">No photo</span>
{% endif %}
</div>
{% elif ftype == 'signature' %}
<span class="field-lbl">{{ field.label }}</span>
<div style="display:flex;align-items:center;flex:1;min-height:0;">
{% if val and val.startswith('data:') %}
<button type="button" class="btn-view-media"
onclick="openMedia('{{ val }}','{{ field.label | e }}')">
<i class="bi bi-pen"></i> View Signature
</button>
{% else %}
<span style="font-size:.72rem;color:#94a3b8;font-style:italic;">No signature</span>
{% endif %}
</div>
{% elif ftype == 'pass_fail' %}
<span class="field-lbl">{{ field.label }}</span>
<div class="field-val">
{% if val and val.lower() in ('pass','yes','ok','good','acceptable','compliant') %}
<span class="text-success"><i class="bi bi-check-circle-fill"></i> {{ val }}</span>
{% elif val %}
<span class="text-danger"><i class="bi bi-x-circle-fill"></i> {{ val }}</span>
{% else %}
<span style="font-size:.72rem;color:#94a3b8;font-style:italic;">Not answered</span>
{% endif %}
</div>
{% elif ftype == 'checkbox' %}
<span class="field-lbl">{{ field.label }}</span>
<div class="field-val">
{% if val == 'yes' %}<span class="text-success"><i class="bi bi-check-circle-fill"></i> Yes</span>
{% else %}<span class="text-muted"><i class="bi bi-circle"></i> No</span>{% endif %}
</div>
{% elif ftype == 'checkbox_group' %}
<span class="field-lbl">{{ field.label }}</span>
<div class="field-val" style="white-space:normal;overflow:auto;">
{% if val and val is iterable and val is not string %}
{% for item in val %}<span class="badge bg-primary me-1" style="font-size:.62rem;">{{ item }}</span>{% endfor %}
{% else %}<span class="empty">None</span>{% endif %}
</div>
{% elif ftype == 'table' %}
<span class="field-lbl">{{ field.label }}</span>
<div style="overflow:auto;flex:1;min-height:0;">
{% if val and val is iterable and val is not string %}
<table class="tbl-view">
<thead><tr>{% for hdr in (field.col_headers or ['Col']) %}<th>{{ hdr }}</th>{% endfor %}</tr></thead>
<tbody>{% for row in val %}<tr>{% for hdr in (field.col_headers or ['Col']) %}<td>{{ row.get(hdr,'') }}</td>{% endfor %}</tr>{% endfor %}</tbody>
</table>
{% else %}<span style="font-size:.72rem;color:#94a3b8;">No data</span>{% endif %}
</div>
{% else %}
{# text, number, date, email, radio, select, textarea #}
<span class="field-lbl">{{ field.label }}</span>
<div class="field-val {{ 'multiline' if ftype == 'textarea' }} {{ 'empty' if not val }}">{{ val or '—' }}</div>
{% endif %}
</div>{# /fg-cell #}
{% endif %}
{% endif %}
{% endfor %}
</div>{# /form-grid #}
{% if not ns.rated_rows %}
<p class="text-muted">No rated fields in this inspection.</p>
{% endif %}
{% else %}
<p class="text-muted">No form fields found for this template.</p>
{% endif %}
{# Issues #}
{% if issues %}
<hr class="mt-4">
<h6 class="text-danger"><i class="bi bi-exclamation-triangle"></i> Issues Logged ({{ issues|length }})</h6>
<div class="table-responsive">
<table class="table table-sm table-hover">
<thead class="table-light">
<tr><th>Severity</th><th>Area</th><th>Description</th><th>Status</th><th></th></tr>
</thead>
<tbody>
{% for issue in issues %}
<tr>
<td><span class="badge bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning text-dark' if issue.severity == 'medium' else 'secondary' }}">{{ issue.severity|title }}</span></td>
<td>{{ issue.area.name }}</td>
<td>{{ issue.description[:80] }}{% if issue.description|length > 80 %}…{% endif %}</td>
<td><span class="badge bg-{{ 'success' if issue.status == 'resolved' else 'warning text-dark' if issue.status == 'in_progress' else 'secondary' }}">{{ issue.status|replace('_',' ')|title }}</span></td>
<td><a href="{{ url_for('issues.view', issue_id=issue.id) }}" class="btn btn-sm btn-outline-secondary">View</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
{# ── Print-only sign-off block (hidden on screen, visible @media print) ── #}
<div class="print-signoff" style="display:none; font-family:Helvetica,Arial,sans-serif;">
<table style="width:100%; border-collapse:collapse; font-size:8pt; color:#64748b;
margin-top:10pt;">
<tr>
<td colspan="6" style="font-size:9.5pt; font-weight:700; color:#1a1d23;
padding-bottom:6pt; border-bottom:0.5pt solid #e2e8f0;">
Sign-off
</td>
</tr>
<tr><td colspan="6" style="height:18pt;"></td></tr>
{# Inspector row #}
<tr>
<td style="width:18%; border-top:0.75pt solid #1a1d23; padding-top:3pt;
font-size:7.5pt; color:#64748b;">Inspector Signature</td>
<td style="width:4%;"></td>
<td style="width:38%; border-top:0.75pt solid #1a1d23; padding-top:3pt;
font-size:7.5pt; color:#64748b;">Printed Name</td>
<td style="width:4%;"></td>
<td style="width:32%; border-top:0.75pt solid #1a1d23; padding-top:3pt;
font-size:7.5pt; color:#64748b;">Date</td>
<td style="width:4%;"></td>
</tr>
<tr><td colspan="6" style="height:22pt;"></td></tr>
{# Supervisor row #}
<tr>
<td style="border-top:0.75pt solid #1a1d23; padding-top:3pt;
font-size:7.5pt; color:#64748b;">Supervisor Signature</td>
<td></td>
<td style="border-top:0.75pt solid #1a1d23; padding-top:3pt;
font-size:7.5pt; color:#64748b;">Printed Name</td>
<td></td>
<td style="border-top:0.75pt solid #1a1d23; padding-top:3pt;
font-size:7.5pt; color:#64748b;">Date</td>
<td></td>
</tr>
</table>
</div>
{# Media lightbox #}
<div class="media-backdrop" id="mediaModal" onclick="if(event.target.id==='mediaModal')closeMedia()">
<div class="media-modal">
<div class="media-modal-head">
<span id="mediaLabel">Photo</span>
<button class="media-modal-close" onclick="closeMedia()">&times;</button>
</div>
<img id="mediaImg" src="" alt="">
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
(function () {
// 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;
}
}());
</script>
<script>
function openMedia(src, label) {
document.getElementById('mediaImg').src = src;
document.getElementById('mediaLabel').textContent = label;
document.getElementById('mediaModal').classList.add('open');
}
function closeMedia() {
document.getElementById('mediaModal').classList.remove('open');
document.getElementById('mediaImg').src = '';
}
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMedia(); });
</script>
{# ── Flag follow-up modal ── #}
<div class="modal fade" id="followupModal" tabindex="-1">
<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">
<h5 class="modal-title">
<i class="bi bi-flag me-2"></i>{{ 'Request a Follow-up Inspection' if is_cust else 'Flag Follow-up Required' }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
{% if is_cust %}
<p class="small text-muted">
Ask the team to re-inspect this facility. Your request is sent to the
inspector and management right away.
</p>
{% endif %}
<label class="form-label fw-semibold">
{{ 'What still needs attention?' if is_cust else 'Reason / Notes' }}
<span class="text-muted small">(optional)</span>
</label>
<textarea name="follow_up_note" class="form-control" rows="3"
placeholder="Describe what needs to be addressed in the follow-up inspection…"></textarea>
{# ── Assign it (phase56) ────────────────────────────────────────
Optional. Left blank, the follow-up stays with whoever performed
the original inspection — the behaviour before this existed. The
list is contract-scoped in _followup_assignees_for(), so a
Customer Director only ever sees inspectors on their own
contracts. #}
{% if followup_assignees %}
<div class="mt-3">
<label class="form-label fw-semibold">
Assign to
<span class="text-muted small">(optional)</span>
</label>
<select name="follow_up_assigned_to" class="form-select">
<option value="">
— {{ inspection.inspector.display_name }} (original inspector) —
</option>
{% for u in followup_assignees %}
{% if u.id != inspection.inspector_id %}
<option value="{{ u.id }}">
{{ u.display_name }}{{ ' (Customer)' if u.is_external_inspector }}
</option>
{% endif %}
{% endfor %}
</select>
<div class="form-text">
Choose someone else to carry out the re-inspection. They are
notified and it appears in their list on the web and the iPad;
the original inspector is not asked to do it.
</div>
</div>
{% endif %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-warning">
<i class="bi bi-flag me-1"></i>{{ 'Send Request' if is_cust else 'Flag Follow-up' }}
</button>
</div>
</div>
</form>
</div>
</div>
{% endblock %}