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)", "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_view.html\" -Confirm:$false)",
"PowerShell(Remove-Item \"d:\\\\Projects\\\\LT_Janitorial_Quality_Control\\\\app\\\\templates\\\\issues\\\\issues_list.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. > **Audience:** AI assistants and developers working on this codebase.
> **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions. > **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 | | `facilities` | `/facilities` | CRUD + area management |
| `projects` | `/projects` | CRUD + customer assignment management | | `projects` | `/projects` | CRUD + customer assignment management |
| `customers` | `/customers` | list, invite, set-password, manage, import CSV | | `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 | | `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) | | `notifications` | `/notifications` | list, mark-read, preferences, send-digest (cron), check-sla (cron), cleanup-tokens (cron) |
| `audit` | `/audit` | list (admin only), view, purge | | `audit` | `/audit` | list (admin only), view, purge |
| `reports` | `/reports` | index, facility report, scorecard, CSV/PDF export | | `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 ### Real-Time
**SSE banned.** All "live" updates use polling. **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. | | 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]` | | 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>()` | | 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) csrf.exempt(_api_notifications_bp)
register_api(app) 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 ─────────────────────── # ── Error handler: 413 Request Entity Too Large ───────────────────────
# Nginx can return 413 before Flask sees the request; this handler covers # Nginx can return 413 before Flask sees the request; this handler covers
# the Flask-side rejection and gives users a clear, actionable message # 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 logging
import json import json
import re
from flask import Blueprint, request, g from flask import Blueprint, request, g
from app import db from app import db
@@ -33,6 +34,10 @@ logger = logging.getLogger(__name__)
bp = Blueprint('api_inspections', __name__) bp = Blueprint('api_inspections', __name__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'} _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): def _parse_datetime(value):
@@ -213,6 +218,8 @@ def create_inspection():
# ── Idempotency check ───────────────────────────────────────────────── # ── Idempotency check ─────────────────────────────────────────────────
mobile_local_id = data.get('mobile_local_id') mobile_local_id = data.get('mobile_local_id')
if 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() existing = Inspection.query.filter_by(mobile_local_id=mobile_local_id).first()
if existing: if existing:
logger.info('API INSPECTIONS | duplicate | local_id=%s | inspection_id=%d | user=%s', 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 logging
import re
from flask import Blueprint, request, g, current_app from flask import Blueprint, request, g, current_app
from app import db from app import db
@@ -42,6 +43,10 @@ bp = Blueprint('api_issues', __name__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'} _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
_VALID_SEVERITY = {'low', 'medium', 'high', 'critical'} _VALID_SEVERITY = {'low', 'medium', 'high', 'critical'}
_VALID_STATUSES = {'open', 'in_progress', 'resolved', 'pending_verification'} _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): def _issue_payload(issue):
@@ -175,6 +180,8 @@ def create_issue():
# ── Idempotency check ───────────────────────────────────────────────── # ── Idempotency check ─────────────────────────────────────────────────
mobile_local_id = data.get('mobile_local_id') mobile_local_id = data.get('mobile_local_id')
if 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() existing = Issue.query.filter_by(mobile_local_id=mobile_local_id).first()
if existing: if existing:
logger.info('API ISSUES | duplicate | local_id=%s | issue_id=%d | user=%s', 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 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 ──────────── # ── Facilities list for the trend-by-facility chart selector ────────────
if is_privileged or is_project_manager: if is_privileged or is_project_manager:
all_facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all() all_facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
@@ -232,6 +248,7 @@ def index():
customer_facilities = customer_facilities, customer_facilities = customer_facilities,
pending_followups = pending_followups, pending_followups = pending_followups,
all_facilities = all_facilities, all_facilities = all_facilities,
my_issues = my_issues,
) )
+47 -8
View File
@@ -1,12 +1,13 @@
import os import os
import json import json
import re
import uuid import uuid
from datetime import datetime from datetime import datetime
from app.utils.time_utils import now_eastern from app.utils.time_utils import now_eastern
from flask import (Blueprint, render_template, redirect, url_for, from flask import (Blueprint, render_template, redirect, url_for,
flash, request, current_app, jsonify, Response, abort) flash, request, current_app, jsonify, Response, abort)
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app import db from app import db, limiter
from app.models.inspection import (Inspection, InspectionTemplate, from app.models.inspection import (Inspection, InspectionTemplate,
ChecklistItem, InspectionResult) ChecklistItem, InspectionResult)
from app.models.facility import Facility, Area from app.models.facility import Facility, Area
@@ -28,6 +29,15 @@ bp = Blueprint('inspections', __name__, url_prefix='/inspections')
ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'} 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 = { INPUT_FIELD_TYPES = {
'text', 'textarea', 'number', 'date', 'email', 'text', 'textarea', 'number', 'date', 'email',
'checkbox', 'checkbox_group', 'radio', 'select', '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() ext = file_obj.filename.rsplit('.', 1)[-1].lower()
if ext not in ALLOWED_EXTENSIONS: if ext not in ALLOWED_EXTENSIONS:
return None 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}" filename = f"{uuid.uuid4().hex}.{ext}"
dest_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], subfolder) dest_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], subfolder)
os.makedirs(dest_dir, exist_ok=True) os.makedirs(dest_dir, exist_ok=True)
@@ -403,7 +418,9 @@ def execute(inspection_id):
inspection.status = 'completed' inspection.status = 'completed'
inspection.completed_at = now_eastern() 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 # NOTE: do NOT commit here — inspection fields and all notification
# rows are staged together and committed atomically below. # 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') flash('Draft saved. You can continue filling in the form later.', 'success')
return redirect(url_for('inspections.execute', inspection_id=inspection_id)) 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', return render_template('inspections/execute.html',
inspection=inspection, inspection=inspection,
form_fields=form_fields, 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): def _save_responses(inspection, responses, snapshot_schema=None):
"""Persist final form responses into inspection.notes as JSON.""" """Persist form responses (and optionally the template schema) into inspection.notes."""
existing = {} existing = {}
if inspection.notes: if inspection.notes:
try: try:
@@ -452,6 +475,8 @@ def _save_responses(inspection, responses):
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
existing = {'_inspector_notes': inspection.notes} existing = {'_inspector_notes': inspection.notes}
existing['_form_data'] = responses existing['_form_data'] = responses
if snapshot_schema is not None:
existing['_template_schema'] = snapshot_schema
inspection.notes = json.dumps(existing) inspection.notes = json.dumps(existing)
@@ -503,6 +528,7 @@ def save_draft_ajax(inspection_id):
@bp.route('/<int:inspection_id>/upload-photo', methods=['POST']) @bp.route('/<int:inspection_id>/upload-photo', methods=['POST'])
@login_required @login_required
@limiter.limit("30 per minute")
def upload_photo_ajax(inspection_id): def upload_photo_ajax(inspection_id):
inspection = db.session.get(Inspection, inspection_id) inspection = db.session.get(Inspection, inspection_id)
if inspection is None: if inspection is None:
@@ -544,9 +570,22 @@ def view(inspection_id):
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('inspections.index')) return redirect(url_for('inspections.index'))
template = inspection.template template = inspection.template
form_fields = sorted(template.get_form_schema(),
key=lambda f: (f.get('row', 0), f.get('col', 0))) # 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 = {} form_data = {}
if inspection.notes: if inspection.notes:
+37
View File
@@ -180,6 +180,8 @@ def view(issue_id):
if not facility or facility.id not in cids: if not facility or facility.id not in cids:
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('issues.index')) return redirect(url_for('issues.index'))
if request.method == 'POST':
abort(403)
form = IssueUpdateForm(obj=issue) form = IssueUpdateForm(obj=issue)
staff = User.query.filter(User.role.in_(['admin', 'director', 'inspector'])).order_by(User.username).all() 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)) 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']) @bp.route('/<int:issue_id>/request-verification', methods=['POST'])
@login_required @login_required
def request_verification(issue_id): 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.decorators import supervisor_required, admin_required
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
from app.utils.time_utils import now_eastern from app.utils.time_utils import now_eastern
from app.utils.sla import sla_status
logger = logging.getLogger(__name__) 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() .order_by(func.avg(Inspection.overall_score).desc()).all()
if report.report_type == 'issues': if report.report_type == 'issues':
data['issues'] = _iq(Issue.query.filter( all_issues = _iq(Issue.query.filter(
Issue.status != 'resolved', Issue.status != 'resolved',
)).order_by(Issue.severity.desc(), Issue.reported_at.asc()).all() )).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 return data
+56
View File
@@ -310,6 +310,62 @@
</div> </div>
{% endif %} {% 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 ─────────────────────────────────────────────────────── #} {# ── Recent activity ─────────────────────────────────────────────────────── #}
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-header bg-light fw-semibold"> <div class="card-header bg-light fw-semibold">
+221 -38
View File
@@ -254,8 +254,8 @@
<button type="button" <button type="button"
class="btn btn-sm btn-outline-danger btn-outline-light" class="btn btn-sm btn-outline-danger btn-outline-light"
id="flagIssueBtn" id="flagIssueBtn"
data-flag-url="{{ url_for('inspections.save_draft_ajax', inspection_id=inspection.id) }}" data-bs-toggle="offcanvas"
data-flag-redirect="{{ url_for('inspections.flag_issue', inspection_id=inspection.id) }}"> data-bs-target="#flagIssuePanel">
<i class="bi bi-exclamation-triangle"></i> Flag for Attention <i class="bi bi-exclamation-triangle"></i> Flag for Attention
</button> </button>
</div> </div>
@@ -433,6 +433,16 @@
</button> </button>
</div> </div>
</label> </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 %} {% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{# ── Signature ── #} {# ── Signature ── #}
@@ -492,6 +502,8 @@
<div class="meta"> <div class="meta">
Started: <strong>{{ inspection.inspection_date.strftime('%Y-%m-%d %H:%M') }}</strong> Started: <strong>{{ inspection.inspection_date.strftime('%Y-%m-%d %H:%M') }}</strong>
&nbsp;·&nbsp; Template: <strong>{{ inspection.template.name }}</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>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="submit" name="action" value="draft" class="btn btn-sm btn-outline-light"> <button type="submit" name="action" value="draft" class="btn btn-sm btn-outline-light">
@@ -504,6 +516,66 @@
</div> </div>
</form> </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> </div>
{% endblock %} {% endblock %}
@@ -602,6 +674,9 @@ async function uploadPhotoField(input) {
if (pathHidden) pathHidden.value = json.path; if (pathHidden) pathHidden.value = json.path;
if (iconEl) iconEl.className = 'bi bi-check-circle-fill'; if (iconEl) iconEl.className = 'bi bi-check-circle-fill';
if (promptEl) promptEl.textContent = 'Photo saved'; 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 { } else {
// Upload failed — file is still in the input, will be sent on full form submit // Upload failed — file is still in the input, will be sent on full form submit
if (iconEl) iconEl.className = 'bi bi-cloud-upload'; if (iconEl) iconEl.className = 'bi bi-cloud-upload';
@@ -637,6 +712,8 @@ function clearUpload(fid) {
if (pathHidden) pathHidden.value = ''; if (pathHidden) pathHidden.value = '';
if (iconEl) iconEl.className = 'bi bi-cloud-upload'; if (iconEl) iconEl.className = 'bi bi-cloud-upload';
if (promptEl) promptEl.textContent = 'Tap to take / choose photo'; 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 ──────────────────────────────────────────────────────────── // ── 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 () { (function () {
const btn = document.getElementById('flagIssueBtn'); const DISPLAY_TYPES = new Set(['label', 'section', 'button_submit', 'button_print', 'button_email']);
if (!btn) return; 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 () { function countAnswered() {
const saveDraftUrl = btn.dataset.flagUrl; if (!totalFields) return;
const redirectUrl = btn.dataset.flagRedirect; const form = document.getElementById('inspectionForm');
let answered = 0;
// Collect all current form field values (non-file inputs only) form.querySelectorAll('[name^="field_"]').forEach(el => {
const form = document.getElementById('inspectionForm'); 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 = {}; const responses = {};
collectSignatures();
form.querySelectorAll('input, textarea, select').forEach(el => { form.querySelectorAll('input, textarea, select').forEach(el => {
if (!el.name || !el.name.startsWith('field_')) return; 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') { if (el.type === 'checkbox') {
responses[el.name.replace('field_', '')] = el.checked ? 'true' : 'false'; responses[el.name.replace('field_', '')] = el.checked ? 'true' : 'false';
} else if (el.type === 'radio') { } else if (el.type === 'radio') {
@@ -842,27 +956,12 @@ document.getElementById('inspectionForm').addEventListener('submit', async funct
responses[el.name.replace('field_', '')] = el.value; responses[el.name.replace('field_', '')] = el.value;
} }
}); });
form.querySelectorAll('input[id$="_server_path"]').forEach(el => {
// Flush signature canvases to their hidden inputs before reading values const m = el.id.match(/^field_(.+)_server_path$/);
collectSignatures(); if (m && el.value) responses[m[1]] = el.value;
form.querySelectorAll('input[type="hidden"]').forEach(el => {
if (!el.name || !el.name.startsWith('field_')) return;
responses[el.name.replace('field_', '')] = el.value;
}); });
// Include server paths for image fields that were already AJAX-uploaded.
// These are stored in no-name hidden inputs (id="field_<fid>_server_path")
// so they don't interfere with the multipart form POST.
form.querySelectorAll('input[id$="_server_path"]').forEach(function(pathEl) {
const m = pathEl.id.match(/^field_(.+)_server_path$/);
if (m && pathEl.value) responses[m[1]] = pathEl.value;
});
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Saving…';
try { try {
const res = await fetch(saveDraftUrl, { const res = await fetch(SAVE_URL, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -871,17 +970,101 @@ document.getElementById('inspectionForm').addEventListener('submit', async funct
body: JSON.stringify({ responses }), body: JSON.stringify({ responses }),
}); });
const json = await res.json(); const json = await res.json();
if (json.ok) { if (json.ok && statusEl) {
window.location.href = redirectUrl; 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 { } else {
alert('Could not save draft: ' + (json.error || 'Unknown error')); responses[el.name.replace('field_', '')] = el.value;
btn.disabled = false; }
btn.innerHTML = '<i class="bi bi-exclamation-triangle"></i> Flag for Attention'; });
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) { } catch (err) {
console.error('Flag Issue draft save error:', err); console.error('Flag Issue submit error:', err);
// Fall back to navigating directly without saving errorBox.textContent = 'Network error. Please check your connection and try again.';
window.location.href = redirectUrl; 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> <label class="form-label small mb-1">Status</label>
<select name="status" class="form-select form-select-sm"> <select name="status" class="form-select form-select-sm">
<option value="">All</option> <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> <option value="{{ s }}" {{ 'selected' if status_filter == s }}>{{ s|replace('_',' ')|title }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </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> <label class="form-label small mb-1">Facility</label>
<select name="facility_id" class="form-select form-select-sm"> <select name="facility_id" class="form-select form-select-sm">
<option value="">All Facilities</option> <option value="">All Facilities</option>
@@ -69,7 +78,7 @@
{% for issue in issues.items %} {% for issue in issues.items %}
{% set is_following = issue.id in followed_ids %} {% set is_following = issue.id in followed_ids %}
{% set sla = sla_status(issue) %} {% 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><small>{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</small></td>
<td> <td>
<span class="badge bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning text-dark' if issue.severity == 'medium' else 'secondary' }}"> <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> <div>
<h2><i class="bi bi-patch-check text-info me-2"></i>Verification Queue</h2> <h2><i class="bi bi-patch-check text-info me-2"></i>Verification Queue</h2>
<p class="text-muted mb-0"> <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> </p>
</div> </div>
<div class="d-flex gap-2 align-items-center"> <div class="d-flex gap-2 align-items-center">
@@ -21,6 +21,26 @@
</div> </div>
</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 %} {% if not grouped %}
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body text-center py-5 text-muted"> <div class="card-body text-center py-5 text-muted">
@@ -47,6 +67,7 @@
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead class="table-light" style="font-size:.82rem;"> <thead class="table-light" style="font-size:.82rem;">
<tr> <tr>
<th width="36"><span class="visually-hidden">Select</span></th>
<th width="60">ID</th> <th width="60">ID</th>
<th width="90">Severity</th> <th width="90">Severity</th>
<th>Area / Description</th> <th>Area / Description</th>
@@ -62,6 +83,12 @@
{% set hrs = sla_hours_remaining(issue) %} {% set hrs = sla_hours_remaining(issue) %}
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}"> <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 #} {# ID #}
<td class="text-muted small align-middle"> <td class="text-muted small align-middle">
<a href="{{ url_for('issues.view', issue_id=issue.id) }}" <a href="{{ url_for('issues.view', issue_id=issue.id) }}"
@@ -157,3 +184,53 @@
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% 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 %} — {{ i.description[:60] }}{% if i.description|length > 60 %}…{% endif %}
</td> </td>
<td style="padding:6px 8px;font-weight:600;color:#dc2626;">{{ i.severity|title }}</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> <td style="padding:6px 8px;color:#64748b;">{{ i.reported_at.strftime('%b %d') }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -104,40 +107,70 @@
{% elif report.report_type == 'issues' %} {% elif report.report_type == 'issues' %}
{# SLA summary bar #}
{% if issues %} {% if issues %}
<h3 style="font-size:.9rem;border-bottom:1px solid #e2e8f0;padding-bottom:6px;"> <table width="100%" cellspacing="0" cellpadding="0" style="margin-bottom:20px;">
Open Issues ({{ issues|length }}) <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> </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> <thead>
<tr style="background:#f8fafc;"> <tr style="background:#f8fafc;">
<th style="padding:6px 8px;">#</th> <th style="padding:5px 8px;text-align:left;">#</th>
<th style="padding:6px 8px;">Severity</th> <th style="padding:5px 8px;text-align:left;">Severity</th>
<th style="padding:6px 8px;">Facility / Area</th> <th style="padding:5px 8px;text-align:left;">Area</th>
<th style="padding:6px 8px;">Description</th> <th style="padding:5px 8px;text-align:left;">Description</th>
<th style="padding:6px 8px;">Status</th> <th style="padding:5px 8px;text-align:left;">Status</th>
<th style="padding:6px 8px;">Reported</th> <th style="padding:5px 8px;text-align:left;">SLA</th>
<th style="padding:5px 8px;text-align:left;">Reported</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for i in issues %} {% for i, sla in fac_issues %}
<tr style="border-bottom:1px solid #f1f5f9;"> {% set row_bg = '#fef2f2' if sla == 'breached' else '#fefce8' if sla == 'at_risk' else '#fff' %}
<td style="padding:6px 8px;"> <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> <a href="{{ base_url }}/issues/{{ i.id }}" style="color:#1d4ed8;">#{{ i.id }}</a>
</td> </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 }} {{ i.severity|title }}
</td> </td>
<td style="padding:6px 8px;">{{ i.area.facility.name }} / {{ i.area.name }}</td> <td style="padding:5px 8px;color:#64748b;">{{ i.area.name if i.area else '—' }}</td>
<td style="padding:6px 8px;">{{ i.description[:80] }}{% if i.description|length > 80 %}…{% endif %}</td> <td style="padding:5px 8px;">{{ i.description[:70] }}{% if i.description|length > 70 %}…{% endif %}</td>
<td style="padding:6px 8px;">{{ i.status|replace('_',' ')|title }}</td> <td style="padding:5px 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;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> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% endfor %}
{% else %} {% 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 %}
{% endif %} {% endif %}
+9 -6
View File
@@ -21,18 +21,21 @@ FACILITY SCORES
OPEN CRITICAL / HIGH ISSUES OPEN CRITICAL / HIGH ISSUES
---------------------------- ----------------------------
{% for i in critical_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 }} Link: {{ base_url }}/issues/{{ i.id }}
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% elif report.report_type == 'issues' %} {% elif report.report_type == 'issues' %}
OPEN ISSUES ({{ issues|length }}) OPEN ISSUES ({{ issues|length }}) — Breached: {{ sla_breached }} At Risk: {{ sla_at_risk }}
{% if issues %} {% if issues %}
{% for i in issues %} {% for facility_name, fac_issues in issues_by_facility %}
#{{ i.id }} [{{ i.severity|title }}] {{ i.area.facility.name }} / {{ i.area.name }}
Status: {{ i.status|replace('_',' ')|title }} | {{ i.description[:80] }} {{ facility_name }} ({{ fac_issues|length }} issue{{ 's' if fac_issues|length != 1 else '' }})
Link: {{ base_url }}/issues/{{ i.id }} {% 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 %} {% endfor %}
{% else %} {% else %}
No open issues. No open issues.