Files
LT_Janitorial_Quality_Control/app/templates/inspections/execute.html
T

1094 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.template.name }} — Inspection{% endblock %}
{% 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; }
.freq-badge {
font-size:.72rem; background:rgba(255,255,255,.15);
padding:.28rem .65rem; border-radius:20px; font-weight:500; white-space:nowrap;
}
.insp-body {
background:#fff; border:1px solid #e2e8f0; border-top:none;
border-radius: 0 0 12px 12px; padding:1.75rem; overflow-x:auto;
-webkit-overflow-scrolling: touch;
}
/* ── 12-col grid (matches form editor exactly) ── */
.form-grid {
display: grid;
grid-template-columns: repeat(12, 72px);
grid-auto-rows: 52px; /* matches editor CELL_H — keeps proportions tight */
gap: 8px;
width: max-content;
}
/* ── iPad / touch-device fluid grid override ──
--fg-w is set by ResizeObserver in JS (see bottom of file),
which measures the actual .insp-body clientWidth — no vw/scrollbar ambiguity.
Falls back to a safe 100% - padding calculation if JS hasn't run yet. */
@media (max-width: 1194px) {
.form-grid {
--fg-gap: 8px;
--fg-w: calc(100% - 2rem); /* JS overrides this with measured px value */
--fg-cell: calc((var(--fg-w) - 11 * var(--fg-gap)) / 12);
grid-template-columns: repeat(12, var(--fg-cell));
grid-auto-rows: minmax(calc(var(--fg-cell) * 0.72), auto);
gap: var(--fg-gap);
width: 100%;
}
.insp-body { padding: 1rem; }
/* Rating stars: scale down to fit compressed grid cells on iPad */
.rating-stars button { font-size: .9rem; min-height: 36px; min-width: 28px; }
/* Pass/fail pills: allow wrapping on narrow cells */
.pf-group { flex-wrap: wrap !important; gap: .4rem !important; }
.pf-btn { flex-shrink: 0; }
/* Image upload cells: don't clip the chosen-file row */
.upload-zone { overflow: visible; }
/* Touch: larger tap targets for checkboxes and radios */
.form-check-input { width: 1.1em; height: 1.1em; margin-top: .15rem; }
}
.fg-cell {
/* overflow:visible so content (dropdowns, tall labels) is never silently clipped.
align-content:start keeps items pinned to the top of the cell. */
overflow: visible;
display: flex;
flex-direction: column;
align-content: start;
padding: .18rem .45rem;
/* Min height ensures touch targets are reachable even on very short row spans */
min-height: 36px;
}
.fg-cell .field-lbl {
font-size:.74rem; font-weight:500; color:#64748b;
margin-bottom:.2rem; display:block;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex-shrink:0;
}
.required-mark { color:#dc2626; }
.help-text { font-size:.72rem; color:#64748b; margin-top:.18rem; flex-shrink:0; }
.field-error { font-size:.72rem; color:#dc2626; margin-top:.18rem; }
.fg-cell .form-control,
.fg-cell .form-select {
font-size:.76rem; padding:.2rem .4rem;
border-color:#e2e8f0; background:#f8fafc;
}
.fg-cell .form-control:focus,
.fg-cell .form-select:focus { border-color:#2563eb; background:#fff; box-shadow:none; }
.fg-cell textarea.form-control { resize:none; flex:1; min-height:0; }
.fg-cell .form-check-label { font-size:.76rem; color:#64748b; }
.fg-cell .form-check-input { margin-top:.18rem; }
.fg-cell .form-check { margin-bottom:.1rem; }
/* Upload zone — <label> wrapper so iOS Safari reliably opens the file picker */
.upload-zone {
border:2px dashed #cbd5e1; border-radius:6px; flex:1; min-height:0;
display:flex; flex-direction:row; align-items:center; justify-content:center;
color:#64748b; background:#f8fafc; cursor:pointer; font-size:.74rem; gap:.35rem;
padding:.2rem .4rem;
transition:border-color .18s, background .18s;
-webkit-tap-highlight-color: rgba(37,99,235,.08);
position:relative; overflow:hidden;
}
.upload-zone:hover,
.upload-zone:active { border-color:#2563eb; background:#eff6ff; color:#2563eb; }
/* Hidden file input — clip trick keeps it accessible without the opacity:0/abs iOS bug */
.upload-zone input[type=file] {
position:absolute; width:1px; height:1px; overflow:hidden;
clip:rect(0,0,0,0); white-space:nowrap;
}
.upload-zone i { font-size:.95rem; flex-shrink:0; pointer-events:none; }
/* Left side: icon + text stack */
.upload-main { display:flex; flex-direction:column; align-items:center; flex:1; pointer-events:none; gap:.1rem; }
.upload-prompt { font-size:.7rem; pointer-events:none; }
/* Right side: filename chip + remove button, shown only when a file is selected */
.upload-chosen {
display:none; flex-direction:column; align-items:flex-end; gap:.15rem;
max-width:45%; flex-shrink:0;
}
.upload-chosen.has-file { display:flex; }
.upload-fname {
font-size:.65rem; color:#2563eb; font-weight:500;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
max-width:100%; pointer-events:none;
}
.upload-clear {
font-size:.62rem; padding:.08rem .3rem; line-height:1.4;
background:#fee2e2; border:1px solid #fca5a5; border-radius:3px;
color:#dc2626; cursor:pointer; pointer-events:all; white-space:nowrap;
}
.upload-clear:hover { background:#fecaca; }
/* ── Pass / Fail pills ── */
.pf-group { display:flex; gap:.5rem; flex-wrap:wrap; }
.pf-btn {
display: flex; align-items: center; gap: .35rem;
cursor: pointer;
padding: .4rem .85rem; border-radius: 20px;
font-size: .84rem; font-weight: 600;
border: 2px solid transparent;
transition: background .15s, color .15s, border-color .15s;
user-select: none;
min-height: 36px; /* 44px touch target via padding — no layout shift */
}
.pf-btn input[type=radio] { display: none; }
/* Pass variants */
.pf-btn.pf-pass { border-color: #16a34a; color: #16a34a; background: transparent; }
.pf-btn.pf-pass.pf-on,
.pf-btn.pf-pass:has(input:checked) { background: #16a34a; color: #fff; }
/* Fail variants */
.pf-btn.pf-fail { border-color: #dc2626; color: #dc2626; background: transparent; }
.pf-btn.pf-fail.pf-on,
.pf-btn.pf-fail:has(input:checked) { background: #dc2626; color: #fff; }
flex:1; min-height:0; border-radius:6px; border:1px solid #e2e8f0;
background:#f8fafc; position:relative; overflow:hidden;
}
.signature-pad-wrap canvas { width:100%; height:100%; cursor:crosshair; display:block; }
.sig-clear {
position:absolute; top:4px; right:4px;
font-size:.65rem; padding:.15rem .4rem;
background:rgba(255,255,255,.85); border:1px solid #cbd5e1; border-radius:4px;
cursor:pointer; color:#64748b;
}
.sig-clear:hover { color:#dc2626; border-color:#dc2626; }
.rating-stars { display:flex; flex-direction:row; flex-wrap:nowrap; gap:.2rem; align-items:center; padding-top:.05rem; }
.rating-stars button {
border:none; background:none; font-size:1.2rem; color:#cbd5e1;
cursor:pointer; padding:0; line-height:1; transition:color .1s; flex-shrink:0;
}
.rating-stars button:hover,
.rating-stars button.on { color:#f59e0b; }
.section-divider {
height:100%; display:flex; align-items:center; padding:0;
border-top:2px solid #e2e8f0; padding-top:.4rem;
}
.section-divider strong { font-weight:700; color:#374151; font-size:.95rem; }
.tbl-field { width:100%; border-collapse:collapse; font-size:.8rem; }
.tbl-field th {
background:#f1f5f9; font-weight:600; color:#374151;
padding:.3rem .5rem; border:1px solid #e2e8f0; white-space:nowrap; font-size:.78rem;
}
.tbl-field td { border:1px solid #e2e8f0; padding:.15rem .25rem; }
.tbl-input { width:100%; border:none; outline:none; font-size:.8rem; padding:.1rem .2rem; background:transparent; }
.tbl-input:focus { background:#eff6ff; }
/* sticky footer bar */
.insp-footer {
position:sticky; bottom:0; background:#1a1d23;
border-radius:0 0 12px 12px;
padding:.75rem 1.75rem;
display:flex; align-items:center; justify-content:space-between; gap:1rem;
margin-top:1.5rem;
}
.insp-footer .meta { color:#94a3b8; font-size:.8rem; }
.insp-footer .meta strong { color:#e2e8f0; }
/* iPad: footer stacks vertically, full-width buttons */
@media (max-width: 1024px) {
.insp-footer {
flex-direction: column;
align-items: stretch;
padding: .75rem 1rem;
gap: .5rem;
}
.insp-footer .btn { width: 100%; justify-content: center; }
.insp-footer .meta { text-align: center; }
/* Header: stack on narrow screens */
.insp-header {
flex-direction: column;
align-items: flex-start;
gap: .75rem;
padding: 1rem;
}
.insp-header .d-flex.gap-2 { width: 100%; flex-wrap: wrap; }
.insp-header .btn { flex: 1; justify-content: center; }
.insp-wrap { padding: 0 .5rem 5rem; }
}
/* Touch: ensure 44px tap targets on footer buttons */
@media (pointer: coarse) {
.insp-footer .btn { min-height: 48px; font-size: 1rem; }
}
</style>
{% endblock %}
{% block content %}
<div class="insp-wrap mt-3">
<form method="post" action="{{ url_for('inspections.execute', inspection_id=inspection.id) }}" enctype="multipart/form-data" id="inspectionForm" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="submit_lat" id="submitLat">
<input type="hidden" name="submit_lng" id="submitLng">
{# ── 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 gap-2 align-items-center">
<span class="freq-badge">{{ inspection.template.frequency|title }}</span>
<button type="button"
class="btn btn-sm btn-outline-danger btn-outline-light"
id="flagIssueBtn"
data-bs-toggle="offcanvas"
data-bs-target="#flagIssuePanel">
<i class="bi bi-exclamation-triangle"></i> Flag for Attention
</button>
</div>
</div>
{# ── Form body ── #}
<div class="insp-body">
{% if form_fields %}
<div class="form-grid">
{% for field in form_fields %}
{% set fid = field.id %}
{% set saved = saved_responses.get(fid, '') %}
<div class="fg-cell"
style="grid-column: {{ field.col }} / span {{ field.colSpan }};
grid-row: {{ field.row }} / span {{ field.rowSpan }};">
{# ── Section label (display only) ── #}
{% if field.type == 'section' %}
<div class="section-divider"><strong>{{ field.label }}</strong></div>
{# ── Static label ── #}
{% elif field.type == 'label' %}
{% set fs_map = {'small':'0.78rem','normal':'0.9rem','large':'1.05rem','x-large':'1.25rem'} %}
<div style="font-size:{{ fs_map.get(field.font_size or 'normal','0.9rem') }};
font-weight:{{ field.font_weight or 'normal' }};
color:#374151;line-height:1.5;white-space:pre-wrap;overflow:hidden;height:100%;">
{{ field.text_content or '' }}
</div>
{# ── Submit / Print / Email buttons (display in preview; no action needed here) ── #}
{% elif field.type in ('button_submit','button_print','button_email') %}
{# rendered by the sticky footer instead #}
{# ── Text ── #}
{% elif field.type == 'text' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<input type="text" class="form-control" name="field_{{ fid }}"
value="{{ saved }}" placeholder="{{ field.placeholder or '' }}"
{% if field.required %}required{% endif %}>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{# ── Textarea ── #}
{% elif field.type == 'textarea' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<textarea class="form-control" name="field_{{ fid }}"
placeholder="{{ field.placeholder or '' }}"
{% if field.required %}required{% endif %}>{{ saved }}</textarea>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{# ── Number ── #}
{% elif field.type == 'number' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<input type="number" class="form-control" name="field_{{ fid }}"
value="{{ saved }}" placeholder="{{ field.placeholder or '' }}"
{% if field.required %}required{% endif %}>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{# ── Date ── #}
{% elif field.type == 'date' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<input type="date" class="form-control" name="field_{{ fid }}"
value="{{ saved }}" {% if field.required %}required{% endif %}>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{# ── Email ── #}
{% elif field.type == 'email' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<input type="email" class="form-control" name="field_{{ fid }}"
value="{{ saved }}" placeholder="{{ field.placeholder or 'name@example.com' }}"
{% if field.required %}required{% endif %}>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{# ── Checkbox ── #}
{% elif field.type == 'checkbox' %}
<div class="form-check mt-1">
<input class="form-check-input" type="checkbox" name="field_{{ fid }}"
id="f_{{ fid }}" value="true" {{ 'checked' if saved == 'true' }}>
<label class="form-check-label" for="f_{{ fid }}">
{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}
</label>
</div>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{# ── Checkbox group ── #}
{% elif field.type == 'checkbox_group' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
{% for opt in field.options %}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="field_{{ fid }}"
id="cg_{{ fid }}_{{ loop.index0 }}" value="{{ opt }}"
{{ 'checked' if opt in (saved if saved is iterable and saved is not string else []) }}>
<label class="form-check-label" for="cg_{{ fid }}_{{ loop.index0 }}">{{ opt }}</label>
</div>
{% endfor %}
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{# ── Radio ── #}
{% elif field.type == 'radio' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
{% for opt in field.options %}
<div class="form-check">
<input class="form-check-input" type="radio" name="field_{{ fid }}"
id="rg_{{ fid }}_{{ loop.index0 }}" value="{{ opt }}"
{{ 'checked' if saved == opt }}>
<label class="form-check-label" for="rg_{{ fid }}_{{ loop.index0 }}">{{ opt }}</label>
</div>
{% endfor %}
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{# ── Pass / Fail ── #}
{% elif field.type == 'pass_fail' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
{% set pf_options = field.options if field.options else ['Pass', 'Fail'] %}
<div class="pf-group">
{% for opt in pf_options %}
{% set is_pass = opt.lower() in ('pass','yes','ok','good','acceptable','compliant') %}
<label class="pf-btn {{ 'pf-pass' if is_pass else 'pf-fail' }}{{ ' pf-on' if saved == opt }}"
data-fid="{{ fid }}">
<input type="radio" name="field_{{ fid }}" value="{{ opt }}"
{{ 'checked' if saved == opt }}
onchange="pfSelect(this)">
<span>{{ opt }}</span>
</label>
{% endfor %}
</div>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{# ── Select / Dropdown ── #}
{% elif field.type == 'select' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<select class="form-select" name="field_{{ fid }}" {% if field.required %}required{% endif %}>
<option value="">— Select —</option>
{% for opt in field.options %}
<option value="{{ opt }}" {{ 'selected' if saved == opt }}>{{ opt }}</option>
{% endfor %}
</select>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{# ── Rating (stars) ── #}
{% elif field.type == 'rating' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<div class="rating-stars" data-rating="{{ saved or 0 }}">
<input type="hidden" name="field_{{ fid }}" id="rating_input_{{ fid }}" value="{{ saved or 0 }}">
{% for i in range(1, 6) %}
<button type="button" data-val="{{ i }}" data-target="rating_input_{{ fid }}"
class="{{ 'on' if (saved|int) >= i }}"
onclick="setRating(this)"></button>
{% endfor %}
</div>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{# ── Image / Photo upload ── #}
{% elif field.type == 'image' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
{# Single <label> element: tap anywhere to pick a photo.
Filename + remove button appear on the right when a file is chosen.
The hidden server_path input stores the AJAX-uploaded path so it is
included in the Flag-Issue draft save (file inputs can't be serialised). #}
<label class="upload-zone" for="field_{{ fid }}" id="zone_{{ fid }}">
<input type="file" name="field_{{ fid }}" id="field_{{ fid }}" accept="image/*"
onchange="uploadPhotoField(this)"
data-upload-url="{{ url_for('inspections.upload_photo_ajax', inspection_id=inspection.id) }}">
<input type="hidden" id="field_{{ fid }}_server_path"
name="field_{{ fid }}_server_path"
value="{{ saved or '' }}">
<div class="upload-main">
<i class="bi bi-cloud-upload" id="icon_{{ fid }}"></i>
<span class="upload-prompt" id="prompt_{{ fid }}">Tap to take / choose photo</span>
</div>
<div class="upload-chosen{% if saved %} has-file{% endif %}" id="chosen_{{ fid }}">
<span class="upload-fname" id="fname_{{ fid }}">{% if saved %}{{ saved.split('/')[-1] }}{% endif %}</span>
<button type="button" class="upload-clear" id="clear_{{ fid }}"
onclick="event.preventDefault(); event.stopPropagation(); clearUpload('{{ fid }}')">
<i class="bi bi-x-circle"></i> Remove
</button>
</div>
</label>
{# Thumbnail shown after AJAX upload or when a saved path exists #}
{% if saved %}
<img src="{{ url_for('static', filename=saved) }}"
id="thumb_{{ fid }}"
alt="Photo"
style="max-height:60px;max-width:100%;border-radius:4px;margin-top:.3rem;object-fit:cover;">
{% else %}
<img id="thumb_{{ fid }}" src="" alt=""
style="max-height:60px;max-width:100%;border-radius:4px;margin-top:.3rem;object-fit:cover;display:none;">
{% endif %}
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{# ── Signature ── #}
{% elif field.type == 'signature' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<div class="signature-pad-wrap">
<canvas id="sig_{{ fid }}" data-field="field_{{ fid }}"></canvas>
<button type="button" class="sig-clear" onclick="clearSig('sig_{{ fid }}')">Clear</button>
<input type="hidden" name="field_{{ fid }}" id="field_{{ fid }}" value="{{ saved }}">
</div>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{# ── Table ── #}
{% elif field.type == 'table' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<div style="overflow:auto;flex:1;min-height:0;">
<table class="tbl-field">
<thead><tr>
{% for hdr in (field.col_headers or ['Column 1','Column 2','Column 3']) %}
<th>{{ hdr }}</th>
{% endfor %}
</tr></thead>
<tbody>
{% set tbl_data = saved if saved is iterable and saved is not string else [] %}
{% for r in range(field.table_rows or 3) %}
<tr>
{% set row_data = tbl_data[r] if r < tbl_data|length else {} %}
{% for hdr in (field.col_headers or ['Column 1','Column 2','Column 3']) %}{% set c_idx = loop.index0 %}
<td>
<input type="text" class="tbl-input"
name="field_{{ fid }}_r{{ r }}_c{{ c_idx }}"
value="{{ row_data.get(hdr, '') }}">
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-layout-text-sidebar-reverse" style="font-size:2.5rem;opacity:.3;"></i>
<p class="mt-2 mb-0">This template has no form fields. Please add fields in the template editor.</p>
</div>
{% endif %}
</div>
{# ── Sticky footer ── #}
<div class="insp-footer">
<div class="meta">
Started: <strong>{{ inspection.inspection_date.strftime('%Y-%m-%d %H:%M') }}</strong>
&nbsp;·&nbsp; Template: <strong>{{ inspection.template.name }}</strong>
&nbsp;·&nbsp; <span id="progressLabel" class="text-info" style="font-size:.78rem;"></span>
<span id="autoSaveStatus" class="text-muted ms-2" style="font-size:.74rem;"></span>
</div>
<div class="d-flex gap-2">
<button type="submit" name="action" value="draft" class="btn btn-sm btn-outline-light">
<i class="bi bi-floppy"></i> Save Draft
</button>
<button type="submit" name="action" value="submit" class="btn btn-success">
<i class="bi bi-check-circle-fill"></i> Submit Inspection
</button>
</div>
</div>
</form>
{# ── Flag Issue offcanvas panel ─────────────────────────────────────────────
Replaces the old full-page navigation. The form posts to the existing
flag_issue endpoint via fetch — no page reload, no photo data loss. #}
<div class="offcanvas offcanvas-end" tabindex="-1" id="flagIssuePanel"
aria-labelledby="flagIssuePanelLabel" style="width:min(480px,100vw);">
<div class="offcanvas-header border-bottom">
<h5 class="offcanvas-title" id="flagIssuePanelLabel">
<i class="bi bi-exclamation-triangle text-warning me-2"></i>Flag Issue
</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas"></button>
</div>
<div class="offcanvas-body">
<p class="text-muted small mb-3">
Facility: <strong>{{ inspection.facility.name }}</strong>
</p>
<form id="flagIssueForm" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="facility_id" value="{{ inspection.facility_id }}">
<div class="mb-3">
<label class="form-label fw-semibold">Severity <span class="text-danger">*</span></label>
<select name="severity" class="form-select" required>
<option value="">— Select —</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Description <span class="text-danger">*</span></label>
<textarea name="description" class="form-control" rows="4"
placeholder="Describe the issue…" required></textarea>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Photo <span class="text-muted small">(optional)</span></label>
<input type="file" name="photo" class="form-control" accept="image/*">
</div>
<div class="mb-4">
<label class="form-label fw-semibold">Assign to</label>
<select name="assigned_to" class="form-select">
<option value="0">— Unassigned —</option>
{% set staff = staff_for_flag_issue %}
{% if staff %}{% for u in staff %}
<option value="{{ u.id }}">{{ u.display_name }}</option>
{% endfor %}{% endif %}
</select>
</div>
<div id="flagIssueError" class="alert alert-danger d-none"></div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-warning flex-fill" id="flagIssueSubmitBtn">
<i class="bi bi-exclamation-triangle"></i> Log Issue
</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="offcanvas">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// ── Pass / Fail pill toggle ───────────────────────────────────────────────────
// Adds/removes .pf-on class instead of inline styles — works with CSS :has() fallback
function pfSelect(radio) {
const group = radio.closest('.pf-group');
if (!group) return;
group.querySelectorAll('.pf-btn').forEach(function(btn) {
btn.classList.remove('pf-on');
});
if (radio.checked) {
radio.closest('.pf-btn').classList.add('pf-on');
}
}
// ── Fluid grid width — ResizeObserver sets --fg-w on .form-grid ──────────────
// Avoids 100vw (includes scrollbar) and 100% on .form-grid itself (reports 0
// before first paint). Measures .insp-body which is always reliably sized.
(function () {
function applyGridWidth() {
const body = document.querySelector('.insp-body');
const grid = document.querySelector('.form-grid');
if (!body || !grid) return;
// Available width = insp-body inner width minus its horizontal padding (1rem each side on iPad)
const style = getComputedStyle(body);
const padH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
const available = body.clientWidth - padH;
grid.style.setProperty('--fg-w', available + 'px');
}
// Run immediately and on every resize
applyGridWidth();
if (typeof ResizeObserver !== 'undefined') {
new ResizeObserver(applyGridWidth).observe(document.querySelector('.insp-body') || document.body);
} else {
window.addEventListener('resize', applyGridWidth);
}
}());
// ── Rating stars ─────────────────────────────────────────────────────────────
function setRating(btn) {
const group = btn.closest('.rating-stars');
const clicked = parseInt(btn.dataset.val);
const current = parseInt(group.dataset.rating || 0);
const input = document.getElementById(btn.dataset.target);
// Toggle off: clicking the currently active star clears the rating to 0.
// This allows removal of a single-star (or any) rating via a second click.
const newVal = (clicked === current) ? 0 : clicked;
group.dataset.rating = newVal;
if (input) input.value = newVal;
group.querySelectorAll('button').forEach(b => {
b.classList.toggle('on', parseInt(b.dataset.val) <= newVal);
});
}
// ── Photo upload on select: immediately uploads to server ────────────────────
// Stores the server path in a hidden field so the Flag-Issue AJAX draft save
// can include it — file inputs cannot be JSON-serialised or sent in a JSON body.
async function uploadPhotoField(input) {
const fid = input.id.replace('field_', '');
const fname = document.getElementById('fname_' + fid);
const chosen = document.getElementById('chosen_' + fid);
const pathHidden = document.getElementById('field_' + fid + '_server_path');
const iconEl = document.getElementById('icon_' + fid);
const promptEl = document.getElementById('prompt_' + fid);
if (!input.files || !input.files.length) return;
const file = input.files[0];
// Show the filename chip immediately (optimistic UI)
if (fname) fname.textContent = file.name;
if (chosen) chosen.classList.add('has-file');
// Show uploading state
if (iconEl) iconEl.className = 'bi bi-hourglass-split';
if (promptEl) promptEl.textContent = 'Uploading…';
try {
const uploadUrl = input.dataset.uploadUrl;
const csrfToken = document.querySelector('input[name="csrf_token"]').value;
const compressed = await compressImageFile(file);
const fd = new FormData();
fd.append('photo', compressed, file.name);
fd.append('csrf_token', csrfToken);
const res = await fetch(uploadUrl, { method: 'POST', body: fd });
const json = await res.json();
if (json.ok && json.path) {
if (pathHidden) pathHidden.value = json.path;
if (iconEl) iconEl.className = 'bi bi-check-circle-fill';
if (promptEl) promptEl.textContent = 'Photo saved';
// Show thumbnail
const thumb = document.getElementById('thumb_' + fid);
if (thumb) { thumb.src = '/static/' + json.path; thumb.style.display = ''; }
} else {
// Upload failed — file is still in the input, will be sent on full form submit
if (iconEl) iconEl.className = 'bi bi-cloud-upload';
if (promptEl) promptEl.textContent = 'Tap to take / choose photo';
console.error('Photo AJAX upload failed:', json.error);
}
} catch (err) {
// Network error — same fallback: file stays in input for final form submit
if (iconEl) iconEl.className = 'bi bi-cloud-upload';
if (promptEl) promptEl.textContent = 'Tap to take / choose photo';
console.error('Photo upload error:', err);
}
}
// ── Clear a photo upload field ────────────────────────────────────────────────
// iOS: input.files is read-only — replace the element to truly reset it.
function clearUpload(fid) {
const input = document.getElementById('field_' + fid);
const fname = document.getElementById('fname_' + fid);
const chosen = document.getElementById('chosen_' + fid);
const pathHidden = document.getElementById('field_' + fid + '_server_path');
const iconEl = document.getElementById('icon_' + fid);
const promptEl = document.getElementById('prompt_' + fid);
if (input) {
const newInput = input.cloneNode(false);
newInput.value = '';
newInput.addEventListener('change', function() { uploadPhotoField(this); });
input.parentNode.replaceChild(newInput, input);
}
if (fname) fname.textContent = '';
if (chosen) chosen.classList.remove('has-file');
if (pathHidden) pathHidden.value = '';
if (iconEl) iconEl.className = 'bi bi-cloud-upload';
if (promptEl) promptEl.textContent = 'Tap to take / choose photo';
const thumb = document.getElementById('thumb_' + fid);
if (thumb) { thumb.src = ''; thumb.style.display = 'none'; }
}
// ── Signature pads ────────────────────────────────────────────────────────────
const sigPads = {};
document.querySelectorAll('[id^="sig_"]').forEach(canvas => {
const fid = canvas.id;
const input = document.getElementById(canvas.dataset.field);
const ctx = canvas.getContext('2d');
let drawing = false;
// size canvas to its container
const resize = () => {
const w = canvas.parentElement.clientWidth;
const h = canvas.parentElement.clientHeight || 80;
canvas.width = w;
canvas.height = h;
ctx.strokeStyle = '#0f172a';
ctx.lineWidth = 1.8;
ctx.lineCap = 'round';
};
resize();
new ResizeObserver(resize).observe(canvas.parentElement);
// Restore saved signature
if (input && input.value && input.value.startsWith('data:')) {
const img = new Image();
img.onload = () => ctx.drawImage(img, 0, 0);
img.src = input.value;
}
canvas.addEventListener('mousedown', e => { drawing = true; ctx.beginPath(); ctx.moveTo(...pos(e, canvas)); });
canvas.addEventListener('mousemove', e => { if (!drawing) return; ctx.lineTo(...pos(e, canvas)); ctx.stroke(); });
canvas.addEventListener('mouseup', () => { drawing = false; if (input) input.value = canvas.toDataURL(); });
canvas.addEventListener('mouseleave', () => { drawing = false; });
// Touch
canvas.addEventListener('touchstart', e => { e.preventDefault(); drawing = true; ctx.beginPath(); ctx.moveTo(...pos(e.touches[0], canvas)); });
canvas.addEventListener('touchmove', e => { e.preventDefault(); if (!drawing) return; ctx.lineTo(...pos(e.touches[0], canvas)); ctx.stroke(); });
canvas.addEventListener('touchend', e => { drawing = false; if (input) input.value = canvas.toDataURL(); });
sigPads[fid] = { ctx, canvas, input };
});
function pos(e, canvas) {
const r = canvas.getBoundingClientRect();
return [e.clientX - r.left, e.clientY - r.top];
}
function clearSig(id) {
const p = sigPads[id];
if (!p) return;
p.ctx.clearRect(0, 0, p.canvas.width, p.canvas.height);
if (p.input) p.input.value = '';
}
function collectSignatures() {
// Ensure all signature canvases flush their dataURL to hidden inputs
Object.values(sigPads).forEach(p => {
if (p.input && p.canvas.width > 0) {
p.input.value = p.canvas.toDataURL();
}
});
}
// ── Client-side image compression ─────────────────────────────────────────────
// Compresses photos via Canvas before upload to prevent 413 errors.
// Target: ≤ 1MB per image at 85% JPEG quality, max 1920px on longest side.
const IMG_MAX_PX = 1920; // max dimension in pixels
const IMG_QUALITY = 0.85; // JPEG quality (01)
const IMG_MAX_BYTES = 1 * 1024 * 1024; // 1MB per compressed image
function compressImageFile(file) {
return new Promise((resolve) => {
// Only compress images that exceed the size threshold
if (!file.type.startsWith('image/') || file.size <= IMG_MAX_BYTES) {
return resolve(file);
}
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
// Calculate scaled dimensions
let { width, height } = img;
if (width > IMG_MAX_PX || height > IMG_MAX_PX) {
if (width >= height) {
height = Math.round(height * IMG_MAX_PX / width);
width = IMG_MAX_PX;
} else {
width = Math.round(width * IMG_MAX_PX / height);
height = IMG_MAX_PX;
}
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
canvas.getContext('2d').drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {
// Create a new File from the compressed blob, preserving the name
const compressed = new File([blob], file.name, { type: 'image/jpeg', lastModified: Date.now() });
resolve(compressed);
}, 'image/jpeg', IMG_QUALITY);
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
});
}
// ── Form submission: compress images then submit natively ────────────────────
// Using native form POST (not fetch) avoids all iOS Safari redirect/fetch quirks.
// Images are compressed via Canvas → DataTransfer → file input replacement before submit.
// Track which button was clicked (submit vs draft)
let _submittingAction = 'submit';
document.querySelectorAll('#inspectionForm button[type="submit"]').forEach(btn => {
btn.addEventListener('click', function() {
_submittingAction = this.value || 'submit';
}, true); // capture phase — fires before submit event
});
document.getElementById('inspectionForm').addEventListener('submit', async function(e) {
e.preventDefault();
// Confirm before final submission (not for drafts)
if (_submittingAction === 'submit') {
if (!confirm('Submit this inspection? This action cannot be undone.')) return;
}
collectSignatures(); // Flush signature canvases to hidden inputs
const form = this;
const fileInputs = Array.from(form.querySelectorAll('input[type="file"]'))
.filter(inp => inp.files && inp.files.length > 0);
// Show spinner on the active button
const activeBtn = Array.from(form.querySelectorAll('button[type="submit"]'))
.find(b => b.value === _submittingAction)
|| form.querySelector('button[type="submit"]');
const origHTML = activeBtn ? activeBtn.innerHTML : '';
form.querySelectorAll('button[type="submit"]').forEach(b => b.disabled = true);
if (activeBtn) {
activeBtn.innerHTML = fileInputs.length
? '<span class="spinner-border spinner-border-sm me-1"></span> Uploading…'
: '<span class="spinner-border spinner-border-sm me-1"></span> Saving…';
}
// Write the action value into a hidden field so the native POST includes it
let actionInput = form.querySelector('input[name="action"]');
if (!actionInput) {
actionInput = document.createElement('input');
actionInput.type = 'hidden';
actionInput.name = 'action';
form.appendChild(actionInput);
}
actionInput.value = _submittingAction;
try {
// Compress images and replace file input contents via DataTransfer
for (const input of fileInputs) {
const compressed = await compressImageFile(input.files[0]);
// DataTransfer lets us programmatically set input.files (iOS 15.4+, all modern browsers)
if (typeof DataTransfer !== 'undefined') {
try {
const dt = new DataTransfer();
dt.items.add(compressed);
input.files = dt.files;
} catch (_) {
// DataTransfer.items not supported — submit original file uncompressed
}
}
}
// Capture GPS on final submit (not draft) — fire-and-forget with short timeout
if (_submittingAction === 'submit' && navigator.geolocation) {
await new Promise(resolve => {
const timer = setTimeout(resolve, 4000); // give up after 4 s
navigator.geolocation.getCurrentPosition(
pos => {
document.getElementById('submitLat').value = pos.coords.latitude;
document.getElementById('submitLng').value = pos.coords.longitude;
clearTimeout(timer);
resolve();
},
() => { clearTimeout(timer); resolve(); }, // permission denied / unavailable — proceed without
{ timeout: 4000, maximumAge: 60000 }
);
});
}
// Native form submit — browser handles multipart encoding and follows the redirect
form.submit();
} catch (err) {
form.querySelectorAll('button[type="submit"]').forEach(b => b.disabled = false);
if (activeBtn) activeBtn.innerHTML = origHTML;
console.error('Inspection submit error:', err);
alert('An error occurred. Please try again.');
}
});
// ── Progress indicator ────────────────────────────────────────────────────────
// Counts answered fields (non-display) and updates the footer label.
(function () {
const DISPLAY_TYPES = new Set(['label', 'section', 'button_submit', 'button_print', 'button_email']);
const totalFields = {{ form_fields | selectattr('type', 'ne', 'label') | selectattr('type', 'ne', 'section') | selectattr('type', 'ne', 'button_submit') | selectattr('type', 'ne', 'button_print') | selectattr('type', 'ne', 'button_email') | list | length }};
function countAnswered() {
if (!totalFields) return;
const form = document.getElementById('inspectionForm');
let answered = 0;
form.querySelectorAll('[name^="field_"]').forEach(el => {
if (el.type === 'file' || el.type === 'hidden') return;
if (el.type === 'radio' && !el.checked) return;
if (el.type === 'checkbox') { /* counted below via group */ return; }
const val = el.value || '';
if (val && val !== '0') answered++;
});
// Image fields: count by server_path hidden inputs
form.querySelectorAll('input[id$="_server_path"]').forEach(el => {
if (el.value) answered++;
});
// Radio groups: count each named group once if any option checked
const radioGroups = new Set();
form.querySelectorAll('input[type="radio"]:checked').forEach(el => {
if (el.name && el.name.startsWith('field_')) radioGroups.add(el.name);
});
answered += radioGroups.size;
// Checkbox fields: count those that are checked
form.querySelectorAll('input[type="checkbox"][name^="field_"]:checked').forEach(() => answered++);
const label = document.getElementById('progressLabel');
if (label) label.textContent = `${Math.min(answered, totalFields)} / ${totalFields} fields`;
}
document.getElementById('inspectionForm')
.addEventListener('input', countAnswered, { passive: true });
countAnswered();
}());
// ── Auto-save draft every 60 seconds ─────────────────────────────────────────
(function () {
const SAVE_URL = {{ url_for('inspections.save_draft_ajax', inspection_id=inspection.id) | tojson }};
const statusEl = document.getElementById('autoSaveStatus');
async function autoSave() {
const form = document.getElementById('inspectionForm');
const responses = {};
collectSignatures();
form.querySelectorAll('input, textarea, select').forEach(el => {
if (!el.name || !el.name.startsWith('field_')) return;
if (el.type === 'file') return;
if (el.type === 'checkbox') {
responses[el.name.replace('field_', '')] = el.checked ? 'true' : 'false';
} else if (el.type === 'radio') {
if (el.checked) responses[el.name.replace('field_', '')] = el.value;
} else {
responses[el.name.replace('field_', '')] = el.value;
}
});
form.querySelectorAll('input[id$="_server_path"]').forEach(el => {
const m = el.id.match(/^field_(.+)_server_path$/);
if (m && el.value) responses[m[1]] = el.value;
});
try {
const res = await fetch(SAVE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('input[name="csrf_token"]').value,
},
body: JSON.stringify({ responses }),
});
const json = await res.json();
if (json.ok && statusEl) {
const t = new Date();
statusEl.textContent = `Auto-saved ${t.getHours()}:${String(t.getMinutes()).padStart(2,'0')}`;
}
} catch (_) { /* silent — network hiccup, will retry next interval */ }
}
setInterval(autoSave, 60000);
}());
// ── Scroll position restore ───────────────────────────────────────────────────
// Saves scroll position to sessionStorage so returning from the flag-issue
// offcanvas (or any navigation) puts the inspector back where they were.
(function () {
const KEY = 'insp_scroll_{{ inspection.id }}';
const saved = sessionStorage.getItem(KEY);
if (saved) { window.scrollTo(0, parseInt(saved, 10)); sessionStorage.removeItem(KEY); }
window.addEventListener('beforeunload', function () {
sessionStorage.setItem(KEY, String(window.scrollY));
});
}());
// ── Flag Issue offcanvas: save draft then submit via AJAX ────────────────────
(function () {
const flagForm = document.getElementById('flagIssueForm');
const submitBtn = document.getElementById('flagIssueSubmitBtn');
const errorBox = document.getElementById('flagIssueError');
const SAVE_URL = {{ url_for('inspections.save_draft_ajax', inspection_id=inspection.id) | tojson }};
const FLAG_URL = {{ url_for('inspections.flag_issue', inspection_id=inspection.id) | tojson }};
if (!flagForm) return;
async function saveDraft() {
const form = document.getElementById('inspectionForm');
const responses = {};
collectSignatures();
form.querySelectorAll('input, textarea, select').forEach(el => {
if (!el.name || !el.name.startsWith('field_')) return;
if (el.type === 'file') return;
if (el.type === 'checkbox') {
responses[el.name.replace('field_', '')] = el.checked ? 'true' : 'false';
} else if (el.type === 'radio') {
if (el.checked) responses[el.name.replace('field_', '')] = el.value;
} else {
responses[el.name.replace('field_', '')] = el.value;
}
});
form.querySelectorAll('input[id$="_server_path"]').forEach(el => {
const m = el.id.match(/^field_(.+)_server_path$/);
if (m && el.value) responses[m[1]] = el.value;
});
await fetch(SAVE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('input[name="csrf_token"]').value,
},
body: JSON.stringify({ responses }),
}).catch(() => {}); // silent — inspection still visible on return
}
flagForm.addEventListener('submit', async function (e) {
e.preventDefault();
errorBox.classList.add('d-none');
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Saving…';
// 1. Save inspection draft so no field data is lost
await saveDraft();
// 2. Post the flag-issue form
try {
const fd = new FormData(flagForm);
const res = await fetch(FLAG_URL, { method: 'POST', body: fd });
if (res.redirected || res.ok) {
// Success — the server redirects back to execute; just reload the page
window.location.reload();
} else {
const text = await res.text();
// Parse first flash message or show generic error
const match = text.match(/alert-danger[^>]*>([\s\S]*?)<\/div>/);
const msg = match ? match[1].replace(/<[^>]+>/g, '').trim() : 'Could not log issue. Please try again.';
errorBox.textContent = msg;
errorBox.classList.remove('d-none');
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="bi bi-exclamation-triangle"></i> Log Issue';
}
} catch (err) {
console.error('Flag Issue submit error:', err);
errorBox.textContent = 'Network error. Please check your connection and try again.';
errorBox.classList.remove('d-none');
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="bi bi-exclamation-triangle"></i> Log Issue';
}
});
}());
</script>
{% endblock %}