05/25 Improvement 1
This commit is contained in:
@@ -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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -545,6 +571,19 @@ def view(inspection_id):
|
|||||||
return redirect(url_for('inspections.index'))
|
return redirect(url_for('inspections.index'))
|
||||||
|
|
||||||
template = inspection.template
|
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(),
|
form_fields = sorted(template.get_form_schema(),
|
||||||
key=lambda f: (f.get('row', 0), f.get('col', 0)))
|
key=lambda f: (f.get('row', 0), f.get('col', 0)))
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
· Template: <strong>{{ inspection.template.name }}</strong>
|
· Template: <strong>{{ inspection.template.name }}</strong>
|
||||||
|
· <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 => {
|
||||||
|
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 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';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}());
|
}());
|
||||||
|
|||||||
@@ -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' }}">
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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;">
|
||||||
|
🏢 {{ 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 %}
|
||||||
|
|||||||
@@ -21,19 +21,22 @@ 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 '' }})
|
||||||
|
{% 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 }}
|
Link: {{ base_url }}/issues/{{ i.id }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
No open issues.
|
No open issues.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
Reference in New Issue
Block a user