05/25 Improvement 1
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
· 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 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';
|
||||
}
|
||||
});
|
||||
}());
|
||||
|
||||
@@ -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' }}">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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;">
|
||||
🏢 {{ 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 %}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user