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
+56
View File
@@ -310,6 +310,62 @@
</div>
{% endif %}
{# ── My open issues (inspector widget) ──────────────────────────────────── #}
{% if my_issues %}
<div class="card shadow-sm mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span class="fw-semibold"><i class="bi bi-person-check me-1 text-primary"></i>My Open Issues</span>
<a href="{{ url_for('issues.index') }}" class="btn btn-sm btn-outline-secondary">View all</a>
</div>
<div class="card-body p-0">
<table class="table table-hover mb-0" style="font-size:.85rem;">
<thead class="table-light">
<tr>
<th width="50">ID</th>
<th width="80">Severity</th>
<th>Facility / Description</th>
<th width="90">Status</th>
<th width="110">SLA</th>
</tr>
</thead>
<tbody>
{% for issue in my_issues %}
{% set sla = sla_status(issue) %}
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
<td class="text-muted">
<a href="{{ url_for('issues.view', issue_id=issue.id) }}" class="text-decoration-none fw-semibold">#{{ issue.id }}</a>
</td>
<td>
<span class="badge bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning text-dark' if issue.severity == 'medium' else 'secondary' }}">
{{ issue.severity|title }}
</span>
</td>
<td>
<div>{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}</div>
<div class="text-muted small">{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}</div>
</td>
<td>
<span class="badge bg-{{ 'warning text-dark' if issue.status == 'in_progress' else 'danger' }}">
{{ issue.status|replace('_',' ')|title }}
</span>
</td>
<td>
{% if sla == 'breached' %}
<span class="badge bg-danger"><i class="bi bi-alarm me-1"></i>Breached</span>
{% elif sla == 'at_risk' %}
<span class="badge bg-warning text-dark"><i class="bi bi-hourglass-split me-1"></i>{{ sla_hours_remaining(issue)|abs|round(1) }}h left</span>
{% else %}
<span class="badge bg-secondary">OK</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{# ── Recent activity ─────────────────────────────────────────────────────── #}
<div class="card shadow-sm">
<div class="card-header bg-light fw-semibold">
+221 -38
View File
@@ -254,8 +254,8 @@
<button type="button"
class="btn btn-sm btn-outline-danger btn-outline-light"
id="flagIssueBtn"
data-flag-url="{{ url_for('inspections.save_draft_ajax', inspection_id=inspection.id) }}"
data-flag-redirect="{{ url_for('inspections.flag_issue', inspection_id=inspection.id) }}">
data-bs-toggle="offcanvas"
data-bs-target="#flagIssuePanel">
<i class="bi bi-exclamation-triangle"></i> Flag for Attention
</button>
</div>
@@ -433,6 +433,16 @@
</button>
</div>
</label>
{# Thumbnail shown after AJAX upload or when a saved path exists #}
{% if saved %}
<img src="{{ url_for('static', filename=saved) }}"
id="thumb_{{ fid }}"
alt="Photo"
style="max-height:60px;max-width:100%;border-radius:4px;margin-top:.3rem;object-fit:cover;">
{% else %}
<img id="thumb_{{ fid }}" src="" alt=""
style="max-height:60px;max-width:100%;border-radius:4px;margin-top:.3rem;object-fit:cover;display:none;">
{% endif %}
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{# ── Signature ── #}
@@ -492,6 +502,8 @@
<div class="meta">
Started: <strong>{{ inspection.inspection_date.strftime('%Y-%m-%d %H:%M') }}</strong>
&nbsp;·&nbsp; Template: <strong>{{ inspection.template.name }}</strong>
&nbsp;·&nbsp; <span id="progressLabel" class="text-info" style="font-size:.78rem;"></span>
<span id="autoSaveStatus" class="text-muted ms-2" style="font-size:.74rem;"></span>
</div>
<div class="d-flex gap-2">
<button type="submit" name="action" value="draft" class="btn btn-sm btn-outline-light">
@@ -504,6 +516,66 @@
</div>
</form>
{# ── Flag Issue offcanvas panel ─────────────────────────────────────────────
Replaces the old full-page navigation. The form posts to the existing
flag_issue endpoint via fetch — no page reload, no photo data loss. #}
<div class="offcanvas offcanvas-end" tabindex="-1" id="flagIssuePanel"
aria-labelledby="flagIssuePanelLabel" style="width:min(480px,100vw);">
<div class="offcanvas-header border-bottom">
<h5 class="offcanvas-title" id="flagIssuePanelLabel">
<i class="bi bi-exclamation-triangle text-warning me-2"></i>Flag Issue
</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas"></button>
</div>
<div class="offcanvas-body">
<p class="text-muted small mb-3">
Facility: <strong>{{ inspection.facility.name }}</strong>
</p>
<form id="flagIssueForm" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label fw-semibold">Severity <span class="text-danger">*</span></label>
<select name="severity" class="form-select" required>
<option value="">— Select —</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Description <span class="text-danger">*</span></label>
<textarea name="description" class="form-control" rows="4"
placeholder="Describe the issue…" required></textarea>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Photo <span class="text-muted small">(optional)</span></label>
<input type="file" name="photo" class="form-control" accept="image/*">
</div>
<div class="mb-4">
<label class="form-label fw-semibold">Assign to</label>
<select name="assigned_to" class="form-select">
<option value="0">— Unassigned —</option>
{% set staff = staff_for_flag_issue %}
{% if staff %}{% for u in staff %}
<option value="{{ u.id }}">{{ u.display_name }}</option>
{% endfor %}{% endif %}
</select>
</div>
<div id="flagIssueError" class="alert alert-danger d-none"></div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-warning flex-fill" id="flagIssueSubmitBtn">
<i class="bi bi-exclamation-triangle"></i> Log Issue
</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="offcanvas">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
@@ -602,6 +674,9 @@ async function uploadPhotoField(input) {
if (pathHidden) pathHidden.value = json.path;
if (iconEl) iconEl.className = 'bi bi-check-circle-fill';
if (promptEl) promptEl.textContent = 'Photo saved';
// Show thumbnail
const thumb = document.getElementById('thumb_' + fid);
if (thumb) { thumb.src = '/static/' + json.path; thumb.style.display = ''; }
} else {
// Upload failed — file is still in the input, will be sent on full form submit
if (iconEl) iconEl.className = 'bi bi-cloud-upload';
@@ -637,6 +712,8 @@ function clearUpload(fid) {
if (pathHidden) pathHidden.value = '';
if (iconEl) iconEl.className = 'bi bi-cloud-upload';
if (promptEl) promptEl.textContent = 'Tap to take / choose photo';
const thumb = document.getElementById('thumb_' + fid);
if (thumb) { thumb.src = ''; thumb.style.display = 'none'; }
}
// ── Signature pads ────────────────────────────────────────────────────────────
@@ -819,21 +896,58 @@ document.getElementById('inspectionForm').addEventListener('submit', async funct
}
});
// ── Flag Issue: save draft via AJAX then navigate ────────────────────────────
// ── Progress indicator ────────────────────────────────────────────────────────
// Counts answered fields (non-display) and updates the footer label.
(function () {
const btn = document.getElementById('flagIssueBtn');
if (!btn) return;
const DISPLAY_TYPES = new Set(['label', 'section', 'button_submit', 'button_print', 'button_email']);
const totalFields = {{ form_fields | selectattr('type', 'ne', 'label') | selectattr('type', 'ne', 'section') | selectattr('type', 'ne', 'button_submit') | selectattr('type', 'ne', 'button_print') | selectattr('type', 'ne', 'button_email') | list | length }};
btn.addEventListener('click', async function () {
const saveDraftUrl = btn.dataset.flagUrl;
const redirectUrl = btn.dataset.flagRedirect;
function countAnswered() {
if (!totalFields) return;
const form = document.getElementById('inspectionForm');
let answered = 0;
// Collect all current form field values (non-file inputs only)
const form = document.getElementById('inspectionForm');
form.querySelectorAll('[name^="field_"]').forEach(el => {
if (el.type === 'file' || el.type === 'hidden') return;
if (el.type === 'radio' && !el.checked) return;
if (el.type === 'checkbox') { /* counted below via group */ return; }
const val = el.value || '';
if (val && val !== '0') answered++;
});
// Image fields: count by server_path hidden inputs
form.querySelectorAll('input[id$="_server_path"]').forEach(el => {
if (el.value) answered++;
});
// Radio groups: count each named group once if any option checked
const radioGroups = new Set();
form.querySelectorAll('input[type="radio"]:checked').forEach(el => {
if (el.name && el.name.startsWith('field_')) radioGroups.add(el.name);
});
answered += radioGroups.size;
// Checkbox fields: count those that are checked
form.querySelectorAll('input[type="checkbox"][name^="field_"]:checked').forEach(() => answered++);
const label = document.getElementById('progressLabel');
if (label) label.textContent = `${Math.min(answered, totalFields)} / ${totalFields} fields`;
}
document.getElementById('inspectionForm')
.addEventListener('input', countAnswered, { passive: true });
countAnswered();
}());
// ── Auto-save draft every 60 seconds ─────────────────────────────────────────
(function () {
const SAVE_URL = {{ url_for('inspections.save_draft_ajax', inspection_id=inspection.id) | tojson }};
const statusEl = document.getElementById('autoSaveStatus');
async function autoSave() {
const form = document.getElementById('inspectionForm');
const responses = {};
collectSignatures();
form.querySelectorAll('input, textarea, select').forEach(el => {
if (!el.name || !el.name.startsWith('field_')) return;
if (el.type === 'file') return; // files can't be JSON-serialised
if (el.type === 'file') return;
if (el.type === 'checkbox') {
responses[el.name.replace('field_', '')] = el.checked ? 'true' : 'false';
} else if (el.type === 'radio') {
@@ -842,27 +956,12 @@ document.getElementById('inspectionForm').addEventListener('submit', async funct
responses[el.name.replace('field_', '')] = el.value;
}
});
// Flush signature canvases to their hidden inputs before reading values
collectSignatures();
form.querySelectorAll('input[type="hidden"]').forEach(el => {
if (!el.name || !el.name.startsWith('field_')) return;
responses[el.name.replace('field_', '')] = el.value;
form.querySelectorAll('input[id$="_server_path"]').forEach(el => {
const m = el.id.match(/^field_(.+)_server_path$/);
if (m && el.value) responses[m[1]] = el.value;
});
// Include server paths for image fields that were already AJAX-uploaded.
// These are stored in no-name hidden inputs (id="field_<fid>_server_path")
// so they don't interfere with the multipart form POST.
form.querySelectorAll('input[id$="_server_path"]').forEach(function(pathEl) {
const m = pathEl.id.match(/^field_(.+)_server_path$/);
if (m && pathEl.value) responses[m[1]] = pathEl.value;
});
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Saving…';
try {
const res = await fetch(saveDraftUrl, {
const res = await fetch(SAVE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -871,17 +970,101 @@ document.getElementById('inspectionForm').addEventListener('submit', async funct
body: JSON.stringify({ responses }),
});
const json = await res.json();
if (json.ok) {
window.location.href = redirectUrl;
if (json.ok && statusEl) {
const t = new Date();
statusEl.textContent = `Auto-saved ${t.getHours()}:${String(t.getMinutes()).padStart(2,'0')}`;
}
} catch (_) { /* silent — network hiccup, will retry next interval */ }
}
setInterval(autoSave, 60000);
}());
// ── Scroll position restore ───────────────────────────────────────────────────
// Saves scroll position to sessionStorage so returning from the flag-issue
// offcanvas (or any navigation) puts the inspector back where they were.
(function () {
const KEY = 'insp_scroll_{{ inspection.id }}';
const saved = sessionStorage.getItem(KEY);
if (saved) { window.scrollTo(0, parseInt(saved, 10)); sessionStorage.removeItem(KEY); }
window.addEventListener('beforeunload', function () {
sessionStorage.setItem(KEY, String(window.scrollY));
});
}());
// ── Flag Issue offcanvas: save draft then submit via AJAX ────────────────────
(function () {
const flagForm = document.getElementById('flagIssueForm');
const submitBtn = document.getElementById('flagIssueSubmitBtn');
const errorBox = document.getElementById('flagIssueError');
const SAVE_URL = {{ url_for('inspections.save_draft_ajax', inspection_id=inspection.id) | tojson }};
const FLAG_URL = {{ url_for('inspections.flag_issue', inspection_id=inspection.id) | tojson }};
if (!flagForm) return;
async function saveDraft() {
const form = document.getElementById('inspectionForm');
const responses = {};
collectSignatures();
form.querySelectorAll('input, textarea, select').forEach(el => {
if (!el.name || !el.name.startsWith('field_')) return;
if (el.type === 'file') return;
if (el.type === 'checkbox') {
responses[el.name.replace('field_', '')] = el.checked ? 'true' : 'false';
} else if (el.type === 'radio') {
if (el.checked) responses[el.name.replace('field_', '')] = el.value;
} else {
alert('Could not save draft: ' + (json.error || 'Unknown error'));
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-exclamation-triangle"></i> Flag for Attention';
responses[el.name.replace('field_', '')] = el.value;
}
});
form.querySelectorAll('input[id$="_server_path"]').forEach(el => {
const m = el.id.match(/^field_(.+)_server_path$/);
if (m && el.value) responses[m[1]] = el.value;
});
await fetch(SAVE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('input[name="csrf_token"]').value,
},
body: JSON.stringify({ responses }),
}).catch(() => {}); // silent — inspection still visible on return
}
flagForm.addEventListener('submit', async function (e) {
e.preventDefault();
errorBox.classList.add('d-none');
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Saving…';
// 1. Save inspection draft so no field data is lost
await saveDraft();
// 2. Post the flag-issue form
try {
const fd = new FormData(flagForm);
const res = await fetch(FLAG_URL, { method: 'POST', body: fd });
if (res.redirected || res.ok) {
// Success — the server redirects back to execute; just reload the page
window.location.reload();
} else {
const text = await res.text();
// Parse first flash message or show generic error
const match = text.match(/alert-danger[^>]*>([\s\S]*?)<\/div>/);
const msg = match ? match[1].replace(/<[^>]+>/g, '').trim() : 'Could not log issue. Please try again.';
errorBox.textContent = msg;
errorBox.classList.remove('d-none');
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="bi bi-exclamation-triangle"></i> Log Issue';
}
} catch (err) {
console.error('Flag Issue draft save error:', err);
// Fall back to navigating directly without saving
window.location.href = redirectUrl;
console.error('Flag Issue submit error:', err);
errorBox.textContent = 'Network error. Please check your connection and try again.';
errorBox.classList.remove('d-none');
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="bi bi-exclamation-triangle"></i> Log Issue';
}
});
}());
+12 -3
View File
@@ -26,12 +26,21 @@
<label class="form-label small mb-1">Status</label>
<select name="status" class="form-select form-select-sm">
<option value="">All</option>
{% for s in ['open','in_progress','resolved'] %}
{% for s in ['open','in_progress','pending_verification','resolved'] %}
<option value="{{ s }}" {{ 'selected' if status_filter == s }}>{{ s|replace('_',' ')|title }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<div class="col-md-2">
<label class="form-label small mb-1">SLA</label>
<select name="sla" class="form-select form-select-sm">
<option value="">All</option>
<option value="breached" {{ 'selected' if sla_filter == 'breached' }}>Breached</option>
<option value="at_risk" {{ 'selected' if sla_filter == 'at_risk' }}>At Risk</option>
<option value="ok" {{ 'selected' if sla_filter == 'ok' }}>OK</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label small mb-1">Facility</label>
<select name="facility_id" class="form-select form-select-sm">
<option value="">All Facilities</option>
@@ -69,7 +78,7 @@
{% for issue in issues.items %}
{% set is_following = issue.id in followed_ids %}
{% set sla = sla_status(issue) %}
<tr>
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
<td><small>{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</small></td>
<td>
<span class="badge bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning text-dark' if issue.severity == 'medium' else 'secondary' }}">
+78 -1
View File
@@ -6,7 +6,7 @@
<div>
<h2><i class="bi bi-patch-check text-info me-2"></i>Verification Queue</h2>
<p class="text-muted mb-0">
Issues awaiting supervisor sign-off before they are fully closed.
Issues awaiting director sign-off before they are fully closed.
</p>
</div>
<div class="d-flex gap-2 align-items-center">
@@ -21,6 +21,26 @@
</div>
</div>
{# ── Bulk-verify toolbar — shown when at least one issue is pending ── #}
{% if grouped %}
<form method="POST" action="{{ url_for('issues.bulk_verify') }}" id="bulkVerifyForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{# Issue checkboxes are rendered inside the per-facility tables below;
hidden inputs with their IDs are inserted here by JS on submission. #}
<div class="card bg-light border-0 mb-3 p-2 d-flex flex-row align-items-center gap-3 flex-wrap" id="bulkToolbar">
<div class="form-check mb-0">
<input class="form-check-input" type="checkbox" id="selectAllIssues">
<label class="form-check-label fw-semibold" for="selectAllIssues">Select all</label>
</div>
<span class="text-muted small" id="selectedCount">0 selected</span>
<button type="submit" class="btn btn-success btn-sm" id="bulkVerifyBtn" disabled
onclick="return injectBulkIds(this.form)">
<i class="bi bi-patch-check-fill me-1"></i>Verify Selected
</button>
</div>
</form>
{% endif %}
{% if not grouped %}
<div class="card shadow-sm">
<div class="card-body text-center py-5 text-muted">
@@ -47,6 +67,7 @@
<table class="table table-hover mb-0">
<thead class="table-light" style="font-size:.82rem;">
<tr>
<th width="36"><span class="visually-hidden">Select</span></th>
<th width="60">ID</th>
<th width="90">Severity</th>
<th>Area / Description</th>
@@ -62,6 +83,12 @@
{% set hrs = sla_hours_remaining(issue) %}
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
{# Bulk-select checkbox #}
<td class="align-middle text-center">
<input class="form-check-input issue-checkbox" type="checkbox"
value="{{ issue.id }}" aria-label="Select issue #{{ issue.id }}">
</td>
{# ID #}
<td class="text-muted small align-middle">
<a href="{{ url_for('issues.view', issue_id=issue.id) }}"
@@ -157,3 +184,53 @@
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
// ── Bulk-verify checkbox management ─────────────────────────────────────────
(function () {
const selectAll = document.getElementById('selectAllIssues');
const countEl = document.getElementById('selectedCount');
const verifyBtn = document.getElementById('bulkVerifyBtn');
function updateToolbar() {
const checked = document.querySelectorAll('.issue-checkbox:checked');
const n = checked.length;
if (countEl) countEl.textContent = n + ' selected';
if (verifyBtn) verifyBtn.disabled = n === 0;
if (selectAll) selectAll.indeterminate = n > 0 && n < document.querySelectorAll('.issue-checkbox').length;
if (selectAll) selectAll.checked = n > 0 && n === document.querySelectorAll('.issue-checkbox').length;
}
document.querySelectorAll('.issue-checkbox').forEach(cb => {
cb.addEventListener('change', updateToolbar);
});
if (selectAll) {
selectAll.addEventListener('change', function () {
document.querySelectorAll('.issue-checkbox').forEach(cb => { cb.checked = this.checked; });
updateToolbar();
});
}
updateToolbar();
}());
// ── Inject checked issue IDs into the bulk-verify form before submit ─────────
function injectBulkIds(form) {
// Remove any previously injected inputs
form.querySelectorAll('input[name="issue_ids"]').forEach(el => el.remove());
const checked = document.querySelectorAll('.issue-checkbox:checked');
if (!checked.length) { alert('Please select at least one issue.'); return false; }
if (!confirm('Verify and close ' + checked.length + ' selected issue(s)?')) return false;
checked.forEach(cb => {
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = 'issue_ids';
hidden.value = cb.value;
form.appendChild(hidden);
});
return true;
}
</script>
{% endblock %}
+52 -19
View File
@@ -94,7 +94,10 @@
— {{ i.description[:60] }}{% if i.description|length > 60 %}…{% endif %}
</td>
<td style="padding:6px 8px;font-weight:600;color:#dc2626;">{{ i.severity|title }}</td>
<td style="padding:6px 8px;">{{ i.area.facility.name }} / {{ i.area.name }}</td>
<td style="padding:6px 8px;">
{% set rf = i.resolved_facility %}
{{ rf.name if rf else '—' }} / {{ i.area.name if i.area else '—' }}
</td>
<td style="padding:6px 8px;color:#64748b;">{{ i.reported_at.strftime('%b %d') }}</td>
</tr>
{% endfor %}
@@ -104,40 +107,70 @@
{% elif report.report_type == 'issues' %}
{# SLA summary bar #}
{% if issues %}
<h3 style="font-size:.9rem;border-bottom:1px solid #e2e8f0;padding-bottom:6px;">
Open Issues ({{ issues|length }})
<table width="100%" cellspacing="0" cellpadding="0" style="margin-bottom:20px;">
<tr>
<td align="center" style="padding:10px;background:#fef2f2;border-radius:8px;">
<div style="font-size:1.4rem;font-weight:700;color:#dc2626;">{{ sla_breached }}</div>
<div style="font-size:.75rem;color:#64748b;">SLA Breached</div>
</td>
<td width="12"></td>
<td align="center" style="padding:10px;background:#fefce8;border-radius:8px;">
<div style="font-size:1.4rem;font-weight:700;color:#b45309;">{{ sla_at_risk }}</div>
<div style="font-size:.75rem;color:#64748b;">At Risk</div>
</td>
<td width="12"></td>
<td align="center" style="padding:10px;background:#f0f9ff;border-radius:8px;">
<div style="font-size:1.4rem;font-weight:700;color:#0369a1;">{{ issues|length }}</div>
<div style="font-size:.75rem;color:#64748b;">Total Open</div>
</td>
</tr>
</table>
{# Per-facility sections #}
{% for facility_name, fac_issues in issues_by_facility %}
<h3 style="font-size:.85rem;border-bottom:1px solid #e2e8f0;padding-bottom:4px;margin-top:20px;">
&#127970; {{ facility_name }}
<span style="font-weight:400;color:#64748b;font-size:.78rem;">({{ fac_issues|length }} issue{{ 's' if fac_issues|length != 1 else '' }})</span>
</h3>
<table width="100%" style="border-collapse:collapse;font-size:.82rem;">
<table width="100%" style="border-collapse:collapse;font-size:.82rem;margin-bottom:8px;">
<thead>
<tr style="background:#f8fafc;">
<th style="padding:6px 8px;">#</th>
<th style="padding:6px 8px;">Severity</th>
<th style="padding:6px 8px;">Facility / Area</th>
<th style="padding:6px 8px;">Description</th>
<th style="padding:6px 8px;">Status</th>
<th style="padding:6px 8px;">Reported</th>
<th style="padding:5px 8px;text-align:left;">#</th>
<th style="padding:5px 8px;text-align:left;">Severity</th>
<th style="padding:5px 8px;text-align:left;">Area</th>
<th style="padding:5px 8px;text-align:left;">Description</th>
<th style="padding:5px 8px;text-align:left;">Status</th>
<th style="padding:5px 8px;text-align:left;">SLA</th>
<th style="padding:5px 8px;text-align:left;">Reported</th>
</tr>
</thead>
<tbody>
{% for i in issues %}
<tr style="border-bottom:1px solid #f1f5f9;">
<td style="padding:6px 8px;">
{% for i, sla in fac_issues %}
{% set row_bg = '#fef2f2' if sla == 'breached' else '#fefce8' if sla == 'at_risk' else '#fff' %}
<tr style="border-bottom:1px solid #f1f5f9;background:{{ row_bg }};">
<td style="padding:5px 8px;">
<a href="{{ base_url }}/issues/{{ i.id }}" style="color:#1d4ed8;">#{{ i.id }}</a>
</td>
<td style="padding:6px 8px;font-weight:600;color:{{ '#dc2626' if i.severity in ['critical','high'] else '#b45309' if i.severity == 'medium' else '#64748b' }}">
<td style="padding:5px 8px;font-weight:600;color:{{ '#dc2626' if i.severity in ['critical','high'] else '#b45309' if i.severity == 'medium' else '#64748b' }}">
{{ i.severity|title }}
</td>
<td style="padding:6px 8px;">{{ i.area.facility.name }} / {{ i.area.name }}</td>
<td style="padding:6px 8px;">{{ i.description[:80] }}{% if i.description|length > 80 %}…{% endif %}</td>
<td style="padding:6px 8px;">{{ i.status|replace('_',' ')|title }}</td>
<td style="padding:6px 8px;color:#64748b;">{{ i.reported_at.strftime('%b %d') }}</td>
<td style="padding:5px 8px;color:#64748b;">{{ i.area.name if i.area else '—' }}</td>
<td style="padding:5px 8px;">{{ i.description[:70] }}{% if i.description|length > 70 %}…{% endif %}</td>
<td style="padding:5px 8px;">{{ i.status|replace('_',' ')|title }}</td>
<td style="padding:5px 8px;font-weight:600;color:{{ '#dc2626' if sla == 'breached' else '#b45309' if sla == 'at_risk' else '#64748b' }}">
{{ 'Breached' if sla == 'breached' else 'At Risk' if sla == 'at_risk' else 'OK' }}
</td>
<td style="padding:5px 8px;color:#64748b;">{{ i.reported_at.strftime('%b %d') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endfor %}
{% else %}
<p style="color:#64748b;">No open issues in this period.</p>
<p style="color:#64748b;">No open issues at this time.</p>
{% endif %}
{% endif %}
+9 -6
View File
@@ -21,18 +21,21 @@ FACILITY SCORES
OPEN CRITICAL / HIGH ISSUES
----------------------------
{% for i in critical_issues %}
#{{ i.id }} [{{ i.severity|title }}] {{ i.area.facility.name }} — {{ i.description[:80] }}
{% set rf = i.resolved_facility %}#{{ i.id }} [{{ i.severity|title }}] {{ rf.name if rf else '—' }} — {{ i.description[:80] }}
Link: {{ base_url }}/issues/{{ i.id }}
{% endfor %}
{% endif %}
{% elif report.report_type == 'issues' %}
OPEN ISSUES ({{ issues|length }})
OPEN ISSUES ({{ issues|length }}) — Breached: {{ sla_breached }} At Risk: {{ sla_at_risk }}
{% if issues %}
{% for i in issues %}
#{{ i.id }} [{{ i.severity|title }}] {{ i.area.facility.name }} / {{ i.area.name }}
Status: {{ i.status|replace('_',' ')|title }} | {{ i.description[:80] }}
Link: {{ base_url }}/issues/{{ i.id }}
{% for facility_name, fac_issues in issues_by_facility %}
{{ facility_name }} ({{ fac_issues|length }} issue{{ 's' if fac_issues|length != 1 else '' }})
{% for i, sla in fac_issues %} #{{ i.id }} [{{ i.severity|title }}] [SLA: {{ 'Breached' if sla == 'breached' else 'At Risk' if sla == 'at_risk' else 'OK' }}] {{ i.area.name if i.area else '—' }}
Status: {{ i.status|replace('_',' ')|title }} | {{ i.description[:70] }}
Link: {{ base_url }}/issues/{{ i.id }}
{% endfor %}
{% endfor %}
{% else %}
No open issues.