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

739 lines
32 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 ── */
@media (max-width: 1194px) {
.form-grid {
/* Tighter column gap (4px) so empty intermediate columns between fields
don't create a large visual gap — e.g. between rating stars and upload */
--_gap: 4px;
--_cell: calc((min(calc(100vw - 2rem), 900px) - 11 * 4px) / 12);
grid-template-columns: repeat(12, var(--_cell));
grid-auto-rows: minmax(calc(var(--_cell) * 0.72), auto);
gap: 4px var(--_gap);
width: 100%;
}
.insp-body { padding: 1rem; }
/* Rating stars: scale down to fit compressed grid cells on iPad */
.rating-stars button { font-size: .85rem; }
/* Image upload cells: don't clip the chosen-file row */
.upload-zone { overflow: visible; }
}
.fg-cell {
overflow:hidden; display:flex; flex-direction:column; padding:.18rem .45rem;
}
.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; }
.signature-pad-wrap {
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.username }}</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 Issue
</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 %}
{# ── 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. #}
<label class="upload-zone" for="field_{{ fid }}" id="zone_{{ fid }}">
<input type="file" name="field_{{ fid }}" id="field_{{ fid }}" accept="image/*"
onchange="showFileName(this)">
<div class="upload-main">
<i class="bi bi-cloud-upload"></i>
<span class="upload-prompt">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>
// ── 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);
});
}
// ── File upload: show filename + reveal the chosen panel ────────────────────
function showFileName(input) {
const fid = input.id.replace('field_', '');
const fname = document.getElementById('fname_' + fid);
const chosen = document.getElementById('chosen_' + fid);
if (input.files && input.files.length > 0) {
if (fname) fname.textContent = input.files[0].name;
if (chosen) chosen.classList.add('has-file');
}
}
// ── 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);
if (input) {
const newInput = input.cloneNode(false);
newInput.value = '';
newInput.addEventListener('change', function() { showFileName(this); });
input.parentNode.replaceChild(newInput, input);
}
if (fname) fname.textContent = '';
if (chosen) chosen.classList.remove('has-file');
}
// ── 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;
});
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 Issue';
}
} catch (err) {
console.error('Flag Issue draft save error:', err);
// Fall back to navigating directly without saving
window.location.href = redirectUrl;
}
});
}());
</script>
{% endblock %}