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

889 lines
39 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: 4px 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: 4px;
--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() }}">
{# ── 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-flag-url="{{ url_for('inspections.save_draft_ajax', inspection_id=inspection.id) }}"
data-flag-redirect="{{ url_for('inspections.flag_issue', inspection_id=inspection.id) }}">
<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" 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>
{% 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>
</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>
</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';
} 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';
}
// ── 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
}
}
}
// 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.');
}
});
// ── Flag Issue: save draft via AJAX then navigate ────────────────────────────
(function () {
const btn = document.getElementById('flagIssueBtn');
if (!btn) return;
btn.addEventListener('click', async function () {
const saveDraftUrl = btn.dataset.flagUrl;
const redirectUrl = btn.dataset.flagRedirect;
// Collect all current form field values (non-file inputs only)
const form = document.getElementById('inspectionForm');
const responses = {};
form.querySelectorAll('input, textarea, select').forEach(el => {
if (!el.name || !el.name.startsWith('field_')) return;
if (el.type === 'file') return; // files can't be JSON-serialised
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;
}
});
// Flush signature canvases to their hidden inputs before reading values
collectSignatures();
form.querySelectorAll('input[type="hidden"]').forEach(el => {
if (!el.name || !el.name.startsWith('field_')) return;
responses[el.name.replace('field_', '')] = el.value;
});
// Include server paths for image fields that were already AJAX-uploaded.
// These are stored in no-name hidden inputs (id="field_<fid>_server_path")
// so they don't interfere with the multipart form POST.
form.querySelectorAll('input[id$="_server_path"]').forEach(function(pathEl) {
const m = pathEl.id.match(/^field_(.+)_server_path$/);
if (m && pathEl.value) responses[m[1]] = pathEl.value;
});
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Saving…';
try {
const res = await fetch(saveDraftUrl, {
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) {
window.location.href = redirectUrl;
} else {
alert('Could not save draft: ' + (json.error || 'Unknown error'));
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-exclamation-triangle"></i> Flag for Attention';
}
} catch (err) {
console.error('Flag Issue draft save error:', err);
// Fall back to navigating directly without saving
window.location.href = redirectUrl;
}
});
}());
</script>
{% endblock %}