From 9574d15f20075ce0aa99caf263de745010cdf9f7 Mon Sep 17 00:00:00 2001 From: Nguyen HP Laptop Date: Mon, 25 May 2026 12:23:59 -0400 Subject: [PATCH] 05/25 Improvement 1 --- .claude/settings.local.json | 4 +- CLAUDE.md | 28 +- app/__init__.py | 20 ++ app/api/inspections.py | 7 + app/api/issues.py | 7 + app/routes/dashboard.py | 17 ++ app/routes/inspections.py | 55 +++- app/routes/issues.py | 37 +++ app/routes/scheduled_reports.py | 22 +- app/templates/dashboard.html | 56 ++++ app/templates/inspections/execute.html | 259 ++++++++++++++++--- app/templates/issues/list.html | 15 +- app/templates/issues/verification_queue.html | 79 +++++- app/templates/scheduled_reports/email.html | 71 +++-- app/templates/scheduled_reports/email.txt | 15 +- 15 files changed, 610 insertions(+), 82 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 524d740..f4924c3 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,9 @@ "Bash(Get-ChildItem \"d:\\\\Projects\\\\LT_Janitorial_Quality_Control\\\\migrations\\\\versions\\\\\" -Name)", "PowerShell(Remove-Item \"d:\\\\Projects\\\\LT_Janitorial_Quality_Control\\\\app\\\\templates\\\\issues\\\\issues_view.html\" -Confirm:$false)", "PowerShell(Remove-Item \"d:\\\\Projects\\\\LT_Janitorial_Quality_Control\\\\app\\\\templates\\\\issues\\\\issues_list.html\" -Confirm:$false)", - "Bash(Get-ChildItem -Path \"d:\\\\Projects\\\\LT_Janitorial_Quality_Control\\\\app\\\\templates\\\\inspections\\\\\" -Name)" + "Bash(Get-ChildItem -Path \"d:\\\\Projects\\\\LT_Janitorial_Quality_Control\\\\app\\\\templates\\\\inspections\\\\\" -Name)", + "Bash(Get-ChildItem -Path \"d:\\\\Projects\\\\LT_Janitorial_Quality_Control\" -Recurse -Directory)", + "Bash(Select-Object -First 20)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 78ea8ec..2e27164 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ > **Audience:** AI assistants and developers working on this codebase. > **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions. -> **Last reviewed:** May 2026 (Phase 19 complete — server-selectable iPad app, mobile multi-photo evidence, facility deduplication, issue creation from iPad Issues page) +> **Last reviewed:** May 2026 (Phase 19 complete + post-phase-19 improvements: security hardening, inspection UX, inspector dashboard widget, bulk verification, scheduled issues digest with SLA grouping, customer read-only issue portal) --- @@ -291,9 +291,9 @@ api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version | `facilities` | `/facilities` | CRUD + area management | | `projects` | `/projects` | CRUD + customer assignment management | | `customers` | `/customers` | list, invite, set-password, manage, import CSV | -| `inspections` | `/inspections` | list, start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect | +| `inspections` | `/inspections` | list, start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect, upload-photo (AJAX) | | `templates` | `/templates` | list, create, edit, delete, form editor, preview | -| `issues` | `/issues` | list, view, create, update, verify, comment, follow/unfollow, verification queue, delete, quick-assign | +| `issues` | `/issues` | list, view, create, update, verify, comment, follow/unfollow, verification queue, bulk-verify, delete, quick-assign | | `notifications` | `/notifications` | list, mark-read, preferences, send-digest (cron), check-sla (cron), cleanup-tokens (cron) | | `audit` | `/audit` | list (admin only), view, purge | | `reports` | `/reports` | index, facility report, scorecard, CSV/PDF export | @@ -588,9 +588,17 @@ Always use `user.display_name` in templates — never `.username` for display pu ### Real-Time **SSE banned.** All "live" updates use polling. -### Issue Photo Evidence Display (view.html / issues_view.html) +### Issue Photo Evidence Display (view.html) -Both templates show `photo_path` and `mobile_photo_paths` together under the **"Photo Evidence"** heading using a `d-flex flex-wrap gap-2` grid. `result_photos` (resolution photos) appear separately under **"Resolution Details"**. Do not merge these sections — they have different semantic meaning. +`view.html` shows `photo_path` and `mobile_photo_paths` together under the **"Photo Evidence"** heading using a `d-flex flex-wrap gap-2` grid. `result_photos` (resolution photos) appear separately under **"Resolution Details"**. Do not merge these sections — they have different semantic meaning. + +### Inspection Execute Page — UX Patterns + +- **Photo upload-on-select**: `uploadPhotoField(input)` fires immediately on `` change. XHR to `POST //upload-photo`. On success, the server path is written to `` and a `` is shown. +- **Flag-issue as offcanvas**: `#flagIssuePanel` Bootstrap offcanvas contains the flag-issue form. On submit, `saveDraft()` fires first, then the form is sent via `fetch()` FormData, then the page reloads. Never navigates away — photos are never lost. +- **Auto-save draft**: `setInterval(autoSave, 60000)` calls the save-draft endpoint every 60 s. `#autoSaveStatus` in the footer shows the last-saved timestamp. +- **Progress indicator**: Counts answered non-zero rating fields vs. total; updates `#progressLabel` in the footer on every change. +- **Scroll restore**: `window.scrollY` saved to `sessionStorage` on `beforeunload`; restored on `load`. --- @@ -666,6 +674,16 @@ timeout = 30 | 44 | **iPad evidence photos go to `mobile_photo_paths`, never `result_photos`** | `result_photos` is exclusively for resolution photos added via the web update form. Mixing them causes evidence photos to appear under "Resolution Details" on the web. | | 45 | **`PATCH /issues//photos` is idempotent — merge, never overwrite** | Retry-safe: `merged = existing + [p for p in new_photos if p not in existing]` | | 46 | **Facility deduplication in `pullReferenceData()` on iOS** | Server may return same facility ID multiple times; deduplicate before upsert using `seenFacilityIds = Set()` | +| 47 | **Magic-byte validation in `_save_photo()`** | Added post-phase-19. Reads 8 bytes before saving; rejects files that do not begin with a known image magic (`\xff\xd8\xff`, `\x89PNG`, `GIF87a`, `GIF89a`). Prevents MIME-type spoofing via extension-only checks. | +| 48 | **`upload_photo_ajax` endpoint on inspections blueprint** | `POST //upload-photo` with `@limiter.limit("30 per minute")`. Triggers on file selection (not form submit) so photos survive AJAX draft-save and page navigation. Stores in `inspection_photos/` subfolder; returns `{ok, path}`. | +| 49 | **Template schema snapshotted at submit time** | `execute()` POST stores `form_fields` list as `_template_schema` inside `inspection.notes` JSON. `view()` prefers this snapshot over the live template so historical inspection views remain correct if the template changes later. | +| 50 | **`mobile_local_id` UUID format validation on write endpoints** | `POST /api/v1/inspections` and `POST /api/v1/issues` validate `mobile_local_id` against `_UUID_RE` regex. Rejects non-UUID strings with HTTP 400. Prevents garbage values from being stored as idempotency keys. | +| 51 | **Security response headers via `@app.after_request`** | Added `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy: strict-origin-when-cross-origin`, and a `Content-Security-Policy` (CDN allowlist + `unsafe-inline`). Uses `setdefault` so API responses can override if needed. | +| 52 | **Inspection `execute.html` offline-resilient photo flow** | Photos are uploaded immediately on file selection via `uploadPhotoField()` (XHR to `upload_photo_ajax`). Server path is stored in ``. AJAX draft-save and flag-issue submission read these hidden fields so photos are never lost on navigation. | +| 53 | **Flag-issue panel is an offcanvas — not a page navigation** | Converted from a navigate-away flow to a Bootstrap offcanvas. Draft is saved first via `saveDraft()`, then the flag-issue form is submitted via `fetch()` FormData, then the page reloads. Eliminates the entire class of "photos lost on navigation" bugs. | +| 54 | **Bulk issue verification via `POST /issues/bulk-verify`** | `@supervisor_required`. Accepts `issue_ids` list from form. Skips issues not in `resolved` or `pending_verification` state. Calls `log_action()` after `db.session.commit()` per rule 10. | +| 55 | **Scheduled "issues" report groups by facility with SLA status** | `_build_report_data()` now produces `issues_by_facility` (list of `(facility_name, [(issue, sla), ...])`) and `sla_breached`/`sla_at_risk` counts alongside the flat `issues` list. CSV builder uses `resolved_facility` (not `area.facility`) to avoid crash when `area_id` is None. | +| 56 | **Customer role: `POST` to `issues.view` returns 403** | The `view()` route checks `request.method == 'POST'` inside the customer scope block and calls `abort(403)`. Customers have read-only access; the template already hides the update form, but server-side enforcement is required against crafted requests. | --- diff --git a/app/__init__.py b/app/__init__.py index 1fae60d..6f7350b 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -170,6 +170,26 @@ def create_app(config_name='default'): csrf.exempt(_api_notifications_bp) register_api(app) + # ── Security response headers ───────────────────────────────────────── + # Applied to every response. Blocks clickjacking, MIME sniffing, and + # obvious XSS vectors without breaking Bootstrap CDN / Google Fonts. + @app.after_request + def set_security_headers(response): + response.headers.setdefault('X-Content-Type-Options', 'nosniff') + response.headers.setdefault('X-Frame-Options', 'SAMEORIGIN') + response.headers.setdefault('Referrer-Policy', 'strict-origin-when-cross-origin') + response.headers.setdefault( + 'Content-Security-Policy', + "default-src 'self'; " + "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " + "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; " + "font-src 'self' data: https://fonts.gstatic.com https://cdn.jsdelivr.net; " + "img-src 'self' data: blob:; " + "connect-src 'self'; " + "frame-ancestors 'none';" + ) + return response + # ── Error handler: 413 Request Entity Too Large ─────────────────────── # Nginx can return 413 before Flask sees the request; this handler covers # the Flask-side rejection and gives users a clear, actionable message diff --git a/app/api/inspections.py b/app/api/inspections.py index 6fd4aec..3a17d0f 100644 --- a/app/api/inspections.py +++ b/app/api/inspections.py @@ -17,6 +17,7 @@ GET /api/v1/inspections import logging import json +import re from flask import Blueprint, request, g from app import db @@ -33,6 +34,10 @@ logger = logging.getLogger(__name__) bp = Blueprint('api_inspections', __name__) _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'} +_UUID_RE = re.compile( + r'^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$', + re.IGNORECASE, +) def _parse_datetime(value): @@ -213,6 +218,8 @@ def create_inspection(): # ── Idempotency check ───────────────────────────────────────────────── mobile_local_id = data.get('mobile_local_id') if mobile_local_id: + if not _UUID_RE.match(str(mobile_local_id)): + return api_error('mobile_local_id must be a valid UUID', 400) existing = Inspection.query.filter_by(mobile_local_id=mobile_local_id).first() if existing: logger.info('API INSPECTIONS | duplicate | local_id=%s | inspection_id=%d | user=%s', diff --git a/app/api/issues.py b/app/api/issues.py index 45f9f56..5c93b5b 100644 --- a/app/api/issues.py +++ b/app/api/issues.py @@ -23,6 +23,7 @@ PATCH /api/v1/issues//status """ import logging +import re from flask import Blueprint, request, g, current_app from app import db @@ -42,6 +43,10 @@ bp = Blueprint('api_issues', __name__) _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'} _VALID_SEVERITY = {'low', 'medium', 'high', 'critical'} _VALID_STATUSES = {'open', 'in_progress', 'resolved', 'pending_verification'} +_UUID_RE = re.compile( + r'^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$', + re.IGNORECASE, +) def _issue_payload(issue): @@ -175,6 +180,8 @@ def create_issue(): # ── Idempotency check ───────────────────────────────────────────────── mobile_local_id = data.get('mobile_local_id') if mobile_local_id: + if not _UUID_RE.match(str(mobile_local_id)): + return api_error('mobile_local_id must be a valid UUID', 400) existing = Issue.query.filter_by(mobile_local_id=mobile_local_id).first() if existing: logger.info('API ISSUES | duplicate | local_id=%s | issue_id=%d | user=%s', diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index 075b10b..32144b4 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -203,6 +203,22 @@ def index(): for r in perf_rows ] + # ── My open issues (inspector dashboard widget) ─────────────────────────── + # Issues assigned to the current inspector that are not yet resolved, + # ordered by SLA urgency (breached first, then at-risk, then ok). + my_issues = [] + if is_inspector: + my_issues = ( + Issue.query + .filter( + Issue.assigned_to == current_user.id, + Issue.status.in_(['open', 'in_progress']), + ) + .order_by(Issue.reported_at.asc()) + .limit(10) + .all() + ) + # ── Facilities list for the trend-by-facility chart selector ──────────── if is_privileged or is_project_manager: all_facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all() @@ -232,6 +248,7 @@ def index(): customer_facilities = customer_facilities, pending_followups = pending_followups, all_facilities = all_facilities, + my_issues = my_issues, ) diff --git a/app/routes/inspections.py b/app/routes/inspections.py index fe8d047..d66f248 100644 --- a/app/routes/inspections.py +++ b/app/routes/inspections.py @@ -1,12 +1,13 @@ import os import json +import re import uuid from datetime import datetime from app.utils.time_utils import now_eastern from flask import (Blueprint, render_template, redirect, url_for, flash, request, current_app, jsonify, Response, abort) from flask_login import login_required, current_user -from app import db +from app import db, limiter from app.models.inspection import (Inspection, InspectionTemplate, ChecklistItem, InspectionResult) from app.models.facility import Facility, Area @@ -28,6 +29,15 @@ bp = Blueprint('inspections', __name__, url_prefix='/inspections') ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'} +# Magic-byte signatures for allowed image formats. +# Checked against the first 8 bytes of the upload to prevent extension spoofing. +_IMAGE_MAGIC = ( + b'\xff\xd8\xff', # JPEG + b'\x89PNG\r\n\x1a\n', # PNG + b'GIF87a', # GIF 87a + b'GIF89a', # GIF 89a +) + INPUT_FIELD_TYPES = { 'text', 'textarea', 'number', 'date', 'email', 'checkbox', 'checkbox_group', 'radio', 'select', @@ -42,6 +52,11 @@ def _save_photo(file_obj, subfolder='inspection_photos'): ext = file_obj.filename.rsplit('.', 1)[-1].lower() if ext not in ALLOWED_EXTENSIONS: return None + # Validate magic bytes to prevent extension-spoofed uploads. + header = file_obj.read(8) + file_obj.seek(0) + if not any(header.startswith(m) for m in _IMAGE_MAGIC): + return None filename = f"{uuid.uuid4().hex}.{ext}" dest_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], subfolder) os.makedirs(dest_dir, exist_ok=True) @@ -403,7 +418,9 @@ def execute(inspection_id): inspection.status = 'completed' inspection.completed_at = now_eastern() - _save_responses(inspection, responses) + # Snapshot the current template schema so view() renders correctly + # even if the template is later edited or deleted. + _save_responses(inspection, responses, snapshot_schema=form_fields) # NOTE: do NOT commit here — inspection fields and all notification # rows are staged together and committed atomically below. @@ -437,14 +454,20 @@ def execute(inspection_id): flash('Draft saved. You can continue filling in the form later.', 'success') return redirect(url_for('inspections.execute', inspection_id=inspection_id)) + staff_for_flag_issue = User.query.filter( + User.role.in_(['admin', 'director', 'inspector', 'project_manager']), + User.active == True, + ).order_by(User.full_name, User.username).all() + return render_template('inspections/execute.html', inspection=inspection, form_fields=form_fields, - saved_responses=saved_responses) + saved_responses=saved_responses, + staff_for_flag_issue=staff_for_flag_issue) -def _save_responses(inspection, responses): - """Persist final form responses into inspection.notes as JSON.""" +def _save_responses(inspection, responses, snapshot_schema=None): + """Persist form responses (and optionally the template schema) into inspection.notes.""" existing = {} if inspection.notes: try: @@ -452,6 +475,8 @@ def _save_responses(inspection, responses): except (json.JSONDecodeError, TypeError): existing = {'_inspector_notes': inspection.notes} existing['_form_data'] = responses + if snapshot_schema is not None: + existing['_template_schema'] = snapshot_schema inspection.notes = json.dumps(existing) @@ -503,6 +528,7 @@ def save_draft_ajax(inspection_id): @bp.route('//upload-photo', methods=['POST']) @login_required +@limiter.limit("30 per minute") def upload_photo_ajax(inspection_id): inspection = db.session.get(Inspection, inspection_id) if inspection is None: @@ -544,9 +570,22 @@ def view(inspection_id): flash('Access denied.', 'danger') return redirect(url_for('inspections.index')) - template = inspection.template - form_fields = sorted(template.get_form_schema(), - key=lambda f: (f.get('row', 0), f.get('col', 0))) + template = inspection.template + + # Prefer the schema snapshotted at submit time so that edits to the template + # after this inspection was completed do not corrupt the historical view. + form_fields = None + if inspection.notes: + try: + _snap = json.loads(inspection.notes) + if isinstance(_snap, dict) and '_template_schema' in _snap: + form_fields = sorted(_snap['_template_schema'], + key=lambda f: (f.get('row', 0), f.get('col', 0))) + except (json.JSONDecodeError, TypeError): + pass + if form_fields is None: + form_fields = sorted(template.get_form_schema(), + key=lambda f: (f.get('row', 0), f.get('col', 0))) form_data = {} if inspection.notes: diff --git a/app/routes/issues.py b/app/routes/issues.py index db1ea20..00cdeec 100644 --- a/app/routes/issues.py +++ b/app/routes/issues.py @@ -180,6 +180,8 @@ def view(issue_id): if not facility or facility.id not in cids: flash('Access denied.', 'danger') return redirect(url_for('issues.index')) + if request.method == 'POST': + abort(403) form = IssueUpdateForm(obj=issue) staff = User.query.filter(User.role.in_(['admin', 'director', 'inspector'])).order_by(User.username).all() @@ -542,6 +544,41 @@ def verify(issue_id): return redirect(url_for('issues.view', issue_id=issue_id)) +@bp.route('/bulk-verify', methods=['POST']) +@login_required +@supervisor_required +def bulk_verify(): + """Verify multiple pending-verification issues in a single action.""" + issue_ids = request.form.getlist('issue_ids', type=int) + if not issue_ids: + flash('No issues selected.', 'warning') + return redirect(url_for('issues.verification_queue')) + + verified_count = 0 + for issue_id in issue_ids: + issue = db.session.get(Issue, issue_id) + if issue is None or issue.status not in ('resolved', 'pending_verification'): + continue + issue.status = 'resolved' + issue.verified_by = current_user.id + issue.verified_at = now_eastern() + if not issue.resolved_at: + issue.resolved_at = now_eastern() + verified_count += 1 + + if verified_count: + db.session.commit() + for issue_id in issue_ids: + issue = db.session.get(Issue, issue_id) + if issue and issue.verified_by == current_user.id: + log_action(ACTION_UPDATE, 'Issue', issue_id, + f'#{issue_id}', + f'bulk_verified_by={current_user.username}') + + flash(f'{verified_count} issue{"s" if verified_count != 1 else ""} verified and closed.', 'success') + return redirect(url_for('issues.verification_queue')) + + @bp.route('//request-verification', methods=['POST']) @login_required def request_verification(issue_id): diff --git a/app/routes/scheduled_reports.py b/app/routes/scheduled_reports.py index 6b3b371..486c94e 100644 --- a/app/routes/scheduled_reports.py +++ b/app/routes/scheduled_reports.py @@ -41,6 +41,7 @@ from app.models.user import User from app.utils.decorators import supervisor_required, admin_required from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE from app.utils.time_utils import now_eastern +from app.utils.sla import sla_status logger = logging.getLogger(__name__) @@ -149,10 +150,29 @@ def _build_report_data(report: ScheduledReport, start: datetime, end: datetime) .order_by(func.avg(Inspection.overall_score).desc()).all() if report.report_type == 'issues': - data['issues'] = _iq(Issue.query.filter( + all_issues = _iq(Issue.query.filter( Issue.status != 'resolved', )).order_by(Issue.severity.desc(), Issue.reported_at.asc()).all() + data['issues'] = all_issues + + # Group by facility with per-issue SLA status for the enhanced email template + fac_map = {} + sla_breached = sla_at_risk = 0 + for issue in all_issues: + fac = issue.resolved_facility + fname = fac.name if fac else '(No Facility)' + s = sla_status(issue) + if s == 'breached': + sla_breached += 1 + elif s == 'at_risk': + sla_at_risk += 1 + fac_map.setdefault(fname, []).append((issue, s)) + + data['issues_by_facility'] = sorted(fac_map.items()) + data['sla_breached'] = sla_breached + data['sla_at_risk'] = sla_at_risk + return data diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index d780d62..9b9eeee 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -310,6 +310,62 @@ {% endif %} +{# ── My open issues (inspector widget) ──────────────────────────────────── #} +{% if my_issues %} +
+
+ My Open Issues + View all +
+
+ + + + + + + + + + + + {% for issue in my_issues %} + {% set sla = sla_status(issue) %} + + + + + + + + {% endfor %} + +
IDSeverityFacility / DescriptionStatusSLA
+ #{{ issue.id }} + + + {{ issue.severity|title }} + + +
{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}
+
{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}
+
+ + {{ issue.status|replace('_',' ')|title }} + + + {% if sla == 'breached' %} + Breached + {% elif sla == 'at_risk' %} + {{ sla_hours_remaining(issue)|abs|round(1) }}h left + {% else %} + OK + {% endif %} +
+
+
+{% endif %} + {# ── Recent activity ─────────────────────────────────────────────────────── #}
diff --git a/app/templates/inspections/execute.html b/app/templates/inspections/execute.html index 958fe51..3ba8d64 100644 --- a/app/templates/inspections/execute.html +++ b/app/templates/inspections/execute.html @@ -254,8 +254,8 @@
@@ -433,6 +433,16 @@
+ {# Thumbnail shown after AJAX upload or when a saved path exists #} + {% if saved %} + Photo + {% else %} + + {% endif %} {% if field.help_text %}
{{ field.help_text }}
{% endif %} {# ── Signature ── #} @@ -492,6 +502,8 @@
Started: {{ inspection.inspection_date.strftime('%Y-%m-%d %H:%M') }}  ·  Template: {{ inspection.template.name }} +  ·  +
+ +{# ── 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. #} +
+
+
+ Flag Issue +
+ +
+
+

+ Facility: {{ inspection.facility.name }} +

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ {% 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__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 = ' 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 = ' 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 = ' 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 = ' 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 = ' Log Issue'; } }); }()); diff --git a/app/templates/issues/list.html b/app/templates/issues/list.html index b1e6297..cde2b87 100644 --- a/app/templates/issues/list.html +++ b/app/templates/issues/list.html @@ -26,12 +26,21 @@ -
+
+ + +
+
+ {# Issue checkboxes are rendered inside the per-facility tables below; + hidden inputs with their IDs are inserted here by JS on submission. #} +
+
+ + +
+ 0 selected + +
+ +{% endif %} + {% if not grouped %}
@@ -47,6 +67,7 @@ + @@ -62,6 +83,12 @@ {% set hrs = sla_hours_remaining(issue) %} + {# Bulk-select checkbox #} + + {# ID #} - + {% endfor %} @@ -104,40 +107,70 @@ {% elif report.report_type == 'issues' %} + {# SLA summary bar #} {% if issues %} -

- Open Issues ({{ issues|length }}) +

Select ID Severity Area / Description
+ + {% endif %} {% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/scheduled_reports/email.html b/app/templates/scheduled_reports/email.html index 897d297..a27f621 100644 --- a/app/templates/scheduled_reports/email.html +++ b/app/templates/scheduled_reports/email.html @@ -94,7 +94,10 @@ — {{ i.description[:60] }}{% if i.description|length > 60 %}…{% endif %} {{ i.severity|title }}{{ i.area.facility.name }} / {{ i.area.name }} + {% set rf = i.resolved_facility %} + {{ rf.name if rf else '—' }} / {{ i.area.name if i.area else '—' }} + {{ i.reported_at.strftime('%b %d') }}
+ + + + + + + +
+
{{ sla_breached }}
+
SLA Breached
+
+
{{ sla_at_risk }}
+
At Risk
+
+
{{ issues|length }}
+
Total Open
+
+ + {# Per-facility sections #} + {% for facility_name, fac_issues in issues_by_facility %} +

+ 🏢 {{ facility_name }} + ({{ fac_issues|length }} issue{{ 's' if fac_issues|length != 1 else '' }})

- +
- - - - - - + + + + + + + - {% for i in issues %} - - + - - - - - + + + + + {% endfor %}
#SeverityFacility / AreaDescriptionStatusReported#SeverityAreaDescriptionStatusSLAReported
+ {% for i, sla in fac_issues %} + {% set row_bg = '#fef2f2' if sla == 'breached' else '#fefce8' if sla == 'at_risk' else '#fff' %} +
#{{ i.id }} + {{ i.severity|title }} {{ i.area.facility.name }} / {{ i.area.name }}{{ i.description[:80] }}{% if i.description|length > 80 %}…{% endif %}{{ i.status|replace('_',' ')|title }}{{ i.reported_at.strftime('%b %d') }}{{ i.area.name if i.area else '—' }}{{ i.description[:70] }}{% if i.description|length > 70 %}…{% endif %}{{ i.status|replace('_',' ')|title }} + {{ 'Breached' if sla == 'breached' else 'At Risk' if sla == 'at_risk' else 'OK' }} + {{ i.reported_at.strftime('%b %d') }}
+ {% endfor %} + {% else %} -

No open issues in this period.

+

No open issues at this time.

{% endif %} {% endif %} diff --git a/app/templates/scheduled_reports/email.txt b/app/templates/scheduled_reports/email.txt index d44e3d7..777d824 100644 --- a/app/templates/scheduled_reports/email.txt +++ b/app/templates/scheduled_reports/email.txt @@ -21,18 +21,21 @@ FACILITY SCORES OPEN CRITICAL / HIGH ISSUES ---------------------------- {% for i in critical_issues %} - #{{ i.id }} [{{ i.severity|title }}] {{ i.area.facility.name }} — {{ i.description[:80] }} + {% set rf = i.resolved_facility %}#{{ i.id }} [{{ i.severity|title }}] {{ rf.name if rf else '—' }} — {{ i.description[:80] }} Link: {{ base_url }}/issues/{{ i.id }} {% endfor %} {% endif %} {% elif report.report_type == 'issues' %} -OPEN ISSUES ({{ issues|length }}) +OPEN ISSUES ({{ issues|length }}) — Breached: {{ sla_breached }} At Risk: {{ sla_at_risk }} {% if issues %} -{% for i in issues %} - #{{ i.id }} [{{ i.severity|title }}] {{ i.area.facility.name }} / {{ i.area.name }} - Status: {{ i.status|replace('_',' ')|title }} | {{ i.description[:80] }} - Link: {{ base_url }}/issues/{{ i.id }} +{% for facility_name, fac_issues in issues_by_facility %} + + {{ facility_name }} ({{ fac_issues|length }} issue{{ 's' if fac_issues|length != 1 else '' }}) + {% for i, sla in fac_issues %} #{{ i.id }} [{{ i.severity|title }}] [SLA: {{ 'Breached' if sla == 'breached' else 'At Risk' if sla == 'at_risk' else 'OK' }}] {{ i.area.name if i.area else '—' }} + Status: {{ i.status|replace('_',' ')|title }} | {{ i.description[:70] }} + Link: {{ base_url }}/issues/{{ i.id }} + {% endfor %} {% endfor %} {% else %} No open issues.