05/25 Improvement 1

This commit is contained in:
2026-05-25 12:23:59 -04:00
parent e375f1f70e
commit 9574d15f20
15 changed files with 610 additions and 82 deletions
+221 -38
View File
@@ -254,8 +254,8 @@
<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) }}">
data-bs-toggle="offcanvas"
data-bs-target="#flagIssuePanel">
<i class="bi bi-exclamation-triangle"></i> Flag for Attention
</button>
</div>
@@ -433,6 +433,16 @@
</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 ── #}
@@ -492,6 +502,8 @@
<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">
@@ -504,6 +516,66 @@
</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() }}">
<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 %}
@@ -602,6 +674,9 @@ async function uploadPhotoField(input) {
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';
@@ -637,6 +712,8 @@ function clearUpload(fid) {
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 ────────────────────────────────────────────────────────────
@@ -819,21 +896,58 @@ document.getElementById('inspectionForm').addEventListener('submit', async funct
}
});
// ── Flag Issue: save draft via AJAX then navigate ────────────────────────────
// ── Progress indicator ────────────────────────────────────────────────────────
// Counts answered fields (non-display) and updates the footer label.
(function () {
const btn = document.getElementById('flagIssueBtn');
if (!btn) return;
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 }};
btn.addEventListener('click', async function () {
const saveDraftUrl = btn.dataset.flagUrl;
const redirectUrl = btn.dataset.flagRedirect;
function countAnswered() {
if (!totalFields) return;
const form = document.getElementById('inspectionForm');
let answered = 0;
// Collect all current form field values (non-file inputs only)
const form = document.getElementById('inspectionForm');
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; // files can't be JSON-serialised
if (el.type === 'file') return;
if (el.type === 'checkbox') {
responses[el.name.replace('field_', '')] = el.checked ? 'true' : 'false';
} else if (el.type === 'radio') {
@@ -842,27 +956,12 @@ document.getElementById('inspectionForm').addEventListener('submit', async funct
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;
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;
});
// 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, {
const res = await fetch(SAVE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -871,17 +970,101 @@ document.getElementById('inspectionForm').addEventListener('submit', async funct
body: JSON.stringify({ responses }),
});
const json = await res.json();
if (json.ok) {
window.location.href = redirectUrl;
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 {
alert('Could not save draft: ' + (json.error || 'Unknown error'));
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-exclamation-triangle"></i> Flag for Attention';
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 draft save error:', err);
// Fall back to navigating directly without saving
window.location.href = redirectUrl;
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';
}
});
}());