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
+3 -1
View File
@@ -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)"
]
}
}
+23 -5
View File
@@ -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 `<input type="file">` change. XHR to `POST /<id>/upload-photo`. On success, the server path is written to `<input type="hidden" id="field_<fid>_server_path">` and a `<img id="thumb_<fid>">` 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/<id>/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<Int>()` |
| 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 /<inspection_id>/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 `<input type="hidden" id="field_<fid>_server_path">`. 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. |
---
+20
View File
@@ -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
+7
View File
@@ -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',
+7
View File
@@ -23,6 +23,7 @@ PATCH /api/v1/issues/<id>/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',
+17
View File
@@ -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,
)
+44 -5
View File
@@ -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('/<int:inspection_id>/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:
@@ -545,6 +571,19 @@ def view(inspection_id):
return redirect(url_for('inspections.index'))
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)))
+37
View File
@@ -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('/<int:issue_id>/request-verification', methods=['POST'])
@login_required
def request_verification(issue_id):
+21 -1
View File
@@ -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
+56
View File
@@ -310,6 +310,62 @@
</div>
{% endif %}
{# ── My open issues (inspector widget) ──────────────────────────────────── #}
{% if my_issues %}
<div class="card shadow-sm mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span class="fw-semibold"><i class="bi bi-person-check me-1 text-primary"></i>My Open Issues</span>
<a href="{{ url_for('issues.index') }}" class="btn btn-sm btn-outline-secondary">View all</a>
</div>
<div class="card-body p-0">
<table class="table table-hover mb-0" style="font-size:.85rem;">
<thead class="table-light">
<tr>
<th width="50">ID</th>
<th width="80">Severity</th>
<th>Facility / Description</th>
<th width="90">Status</th>
<th width="110">SLA</th>
</tr>
</thead>
<tbody>
{% for issue in my_issues %}
{% set sla = sla_status(issue) %}
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
<td class="text-muted">
<a href="{{ url_for('issues.view', issue_id=issue.id) }}" class="text-decoration-none fw-semibold">#{{ issue.id }}</a>
</td>
<td>
<span class="badge bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning text-dark' if issue.severity == 'medium' else 'secondary' }}">
{{ issue.severity|title }}
</span>
</td>
<td>
<div>{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}</div>
<div class="text-muted small">{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}</div>
</td>
<td>
<span class="badge bg-{{ 'warning text-dark' if issue.status == 'in_progress' else 'danger' }}">
{{ issue.status|replace('_',' ')|title }}
</span>
</td>
<td>
{% if sla == 'breached' %}
<span class="badge bg-danger"><i class="bi bi-alarm me-1"></i>Breached</span>
{% elif sla == 'at_risk' %}
<span class="badge bg-warning text-dark"><i class="bi bi-hourglass-split me-1"></i>{{ sla_hours_remaining(issue)|abs|round(1) }}h left</span>
{% else %}
<span class="badge bg-secondary">OK</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{# ── Recent activity ─────────────────────────────────────────────────────── #}
<div class="card shadow-sm">
<div class="card-header bg-light fw-semibold">
+220 -37
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)
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';
}
});
}());
+12 -3
View File
@@ -26,12 +26,21 @@
<label class="form-label small mb-1">Status</label>
<select name="status" class="form-select form-select-sm">
<option value="">All</option>
{% for s in ['open','in_progress','resolved'] %}
{% for s in ['open','in_progress','pending_verification','resolved'] %}
<option value="{{ s }}" {{ 'selected' if status_filter == s }}>{{ s|replace('_',' ')|title }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<div class="col-md-2">
<label class="form-label small mb-1">SLA</label>
<select name="sla" class="form-select form-select-sm">
<option value="">All</option>
<option value="breached" {{ 'selected' if sla_filter == 'breached' }}>Breached</option>
<option value="at_risk" {{ 'selected' if sla_filter == 'at_risk' }}>At Risk</option>
<option value="ok" {{ 'selected' if sla_filter == 'ok' }}>OK</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label small mb-1">Facility</label>
<select name="facility_id" class="form-select form-select-sm">
<option value="">All Facilities</option>
@@ -69,7 +78,7 @@
{% for issue in issues.items %}
{% set is_following = issue.id in followed_ids %}
{% set sla = sla_status(issue) %}
<tr>
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
<td><small>{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</small></td>
<td>
<span class="badge bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning text-dark' if issue.severity == 'medium' else 'secondary' }}">
+78 -1
View File
@@ -6,7 +6,7 @@
<div>
<h2><i class="bi bi-patch-check text-info me-2"></i>Verification Queue</h2>
<p class="text-muted mb-0">
Issues awaiting supervisor sign-off before they are fully closed.
Issues awaiting director sign-off before they are fully closed.
</p>
</div>
<div class="d-flex gap-2 align-items-center">
@@ -21,6 +21,26 @@
</div>
</div>
{# ── Bulk-verify toolbar — shown when at least one issue is pending ── #}
{% if grouped %}
<form method="POST" action="{{ url_for('issues.bulk_verify') }}" id="bulkVerifyForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{# Issue checkboxes are rendered inside the per-facility tables below;
hidden inputs with their IDs are inserted here by JS on submission. #}
<div class="card bg-light border-0 mb-3 p-2 d-flex flex-row align-items-center gap-3 flex-wrap" id="bulkToolbar">
<div class="form-check mb-0">
<input class="form-check-input" type="checkbox" id="selectAllIssues">
<label class="form-check-label fw-semibold" for="selectAllIssues">Select all</label>
</div>
<span class="text-muted small" id="selectedCount">0 selected</span>
<button type="submit" class="btn btn-success btn-sm" id="bulkVerifyBtn" disabled
onclick="return injectBulkIds(this.form)">
<i class="bi bi-patch-check-fill me-1"></i>Verify Selected
</button>
</div>
</form>
{% endif %}
{% if not grouped %}
<div class="card shadow-sm">
<div class="card-body text-center py-5 text-muted">
@@ -47,6 +67,7 @@
<table class="table table-hover mb-0">
<thead class="table-light" style="font-size:.82rem;">
<tr>
<th width="36"><span class="visually-hidden">Select</span></th>
<th width="60">ID</th>
<th width="90">Severity</th>
<th>Area / Description</th>
@@ -62,6 +83,12 @@
{% set hrs = sla_hours_remaining(issue) %}
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
{# Bulk-select checkbox #}
<td class="align-middle text-center">
<input class="form-check-input issue-checkbox" type="checkbox"
value="{{ issue.id }}" aria-label="Select issue #{{ issue.id }}">
</td>
{# ID #}
<td class="text-muted small align-middle">
<a href="{{ url_for('issues.view', issue_id=issue.id) }}"
@@ -157,3 +184,53 @@
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
// ── Bulk-verify checkbox management ─────────────────────────────────────────
(function () {
const selectAll = document.getElementById('selectAllIssues');
const countEl = document.getElementById('selectedCount');
const verifyBtn = document.getElementById('bulkVerifyBtn');
function updateToolbar() {
const checked = document.querySelectorAll('.issue-checkbox:checked');
const n = checked.length;
if (countEl) countEl.textContent = n + ' selected';
if (verifyBtn) verifyBtn.disabled = n === 0;
if (selectAll) selectAll.indeterminate = n > 0 && n < document.querySelectorAll('.issue-checkbox').length;
if (selectAll) selectAll.checked = n > 0 && n === document.querySelectorAll('.issue-checkbox').length;
}
document.querySelectorAll('.issue-checkbox').forEach(cb => {
cb.addEventListener('change', updateToolbar);
});
if (selectAll) {
selectAll.addEventListener('change', function () {
document.querySelectorAll('.issue-checkbox').forEach(cb => { cb.checked = this.checked; });
updateToolbar();
});
}
updateToolbar();
}());
// ── Inject checked issue IDs into the bulk-verify form before submit ─────────
function injectBulkIds(form) {
// Remove any previously injected inputs
form.querySelectorAll('input[name="issue_ids"]').forEach(el => el.remove());
const checked = document.querySelectorAll('.issue-checkbox:checked');
if (!checked.length) { alert('Please select at least one issue.'); return false; }
if (!confirm('Verify and close ' + checked.length + ' selected issue(s)?')) return false;
checked.forEach(cb => {
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = 'issue_ids';
hidden.value = cb.value;
form.appendChild(hidden);
});
return true;
}
</script>
{% endblock %}
+52 -19
View File
@@ -94,7 +94,10 @@
— {{ i.description[:60] }}{% if i.description|length > 60 %}…{% endif %}
</td>
<td style="padding:6px 8px;font-weight:600;color:#dc2626;">{{ i.severity|title }}</td>
<td style="padding:6px 8px;">{{ i.area.facility.name }} / {{ i.area.name }}</td>
<td style="padding:6px 8px;">
{% set rf = i.resolved_facility %}
{{ rf.name if rf else '—' }} / {{ i.area.name if i.area else '—' }}
</td>
<td style="padding:6px 8px;color:#64748b;">{{ i.reported_at.strftime('%b %d') }}</td>
</tr>
{% endfor %}
@@ -104,40 +107,70 @@
{% elif report.report_type == 'issues' %}
{# SLA summary bar #}
{% if issues %}
<h3 style="font-size:.9rem;border-bottom:1px solid #e2e8f0;padding-bottom:6px;">
Open Issues ({{ issues|length }})
<table width="100%" cellspacing="0" cellpadding="0" style="margin-bottom:20px;">
<tr>
<td align="center" style="padding:10px;background:#fef2f2;border-radius:8px;">
<div style="font-size:1.4rem;font-weight:700;color:#dc2626;">{{ sla_breached }}</div>
<div style="font-size:.75rem;color:#64748b;">SLA Breached</div>
</td>
<td width="12"></td>
<td align="center" style="padding:10px;background:#fefce8;border-radius:8px;">
<div style="font-size:1.4rem;font-weight:700;color:#b45309;">{{ sla_at_risk }}</div>
<div style="font-size:.75rem;color:#64748b;">At Risk</div>
</td>
<td width="12"></td>
<td align="center" style="padding:10px;background:#f0f9ff;border-radius:8px;">
<div style="font-size:1.4rem;font-weight:700;color:#0369a1;">{{ issues|length }}</div>
<div style="font-size:.75rem;color:#64748b;">Total Open</div>
</td>
</tr>
</table>
{# Per-facility sections #}
{% for facility_name, fac_issues in issues_by_facility %}
<h3 style="font-size:.85rem;border-bottom:1px solid #e2e8f0;padding-bottom:4px;margin-top:20px;">
&#127970; {{ facility_name }}
<span style="font-weight:400;color:#64748b;font-size:.78rem;">({{ fac_issues|length }} issue{{ 's' if fac_issues|length != 1 else '' }})</span>
</h3>
<table width="100%" style="border-collapse:collapse;font-size:.82rem;">
<table width="100%" style="border-collapse:collapse;font-size:.82rem;margin-bottom:8px;">
<thead>
<tr style="background:#f8fafc;">
<th style="padding:6px 8px;">#</th>
<th style="padding:6px 8px;">Severity</th>
<th style="padding:6px 8px;">Facility / Area</th>
<th style="padding:6px 8px;">Description</th>
<th style="padding:6px 8px;">Status</th>
<th style="padding:6px 8px;">Reported</th>
<th style="padding:5px 8px;text-align:left;">#</th>
<th style="padding:5px 8px;text-align:left;">Severity</th>
<th style="padding:5px 8px;text-align:left;">Area</th>
<th style="padding:5px 8px;text-align:left;">Description</th>
<th style="padding:5px 8px;text-align:left;">Status</th>
<th style="padding:5px 8px;text-align:left;">SLA</th>
<th style="padding:5px 8px;text-align:left;">Reported</th>
</tr>
</thead>
<tbody>
{% for i in issues %}
<tr style="border-bottom:1px solid #f1f5f9;">
<td style="padding:6px 8px;">
{% for i, sla in fac_issues %}
{% set row_bg = '#fef2f2' if sla == 'breached' else '#fefce8' if sla == 'at_risk' else '#fff' %}
<tr style="border-bottom:1px solid #f1f5f9;background:{{ row_bg }};">
<td style="padding:5px 8px;">
<a href="{{ base_url }}/issues/{{ i.id }}" style="color:#1d4ed8;">#{{ i.id }}</a>
</td>
<td style="padding:6px 8px;font-weight:600;color:{{ '#dc2626' if i.severity in ['critical','high'] else '#b45309' if i.severity == 'medium' else '#64748b' }}">
<td style="padding:5px 8px;font-weight:600;color:{{ '#dc2626' if i.severity in ['critical','high'] else '#b45309' if i.severity == 'medium' else '#64748b' }}">
{{ i.severity|title }}
</td>
<td style="padding:6px 8px;">{{ i.area.facility.name }} / {{ i.area.name }}</td>
<td style="padding:6px 8px;">{{ i.description[:80] }}{% if i.description|length > 80 %}…{% endif %}</td>
<td style="padding:6px 8px;">{{ i.status|replace('_',' ')|title }}</td>
<td style="padding:6px 8px;color:#64748b;">{{ i.reported_at.strftime('%b %d') }}</td>
<td style="padding:5px 8px;color:#64748b;">{{ i.area.name if i.area else '—' }}</td>
<td style="padding:5px 8px;">{{ i.description[:70] }}{% if i.description|length > 70 %}…{% endif %}</td>
<td style="padding:5px 8px;">{{ i.status|replace('_',' ')|title }}</td>
<td style="padding:5px 8px;font-weight:600;color:{{ '#dc2626' if sla == 'breached' else '#b45309' if sla == 'at_risk' else '#64748b' }}">
{{ 'Breached' if sla == 'breached' else 'At Risk' if sla == 'at_risk' else 'OK' }}
</td>
<td style="padding:5px 8px;color:#64748b;">{{ i.reported_at.strftime('%b %d') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endfor %}
{% else %}
<p style="color:#64748b;">No open issues in this period.</p>
<p style="color:#64748b;">No open issues at this time.</p>
{% endif %}
{% endif %}
+8 -5
View File
@@ -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] }}
{% 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.