Files
sheq-analysis-tool/templates/index.html
T
2026-04-20 15:23:18 +12:00

503 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SHEQanalator alpha</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Source+Sans+3:ital,wght@0,300;0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
:root {
--navy: #0b3254;
--teal: #13b5ea;
--green: #006e47;
--mid-green: #009946;
--light-green: #7bc143;
--purple: #96358d;
--amber: #d97706;
--red: #dc2626;
--bg: #f8fafc;
--card: #ffffff;
--card-alt: #f0f5fa;
--border: #e2e8f0;
--text: #1e293b;
--muted: #64748b;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: "Source Sans Pro", "Source Sans 3", -apple-system, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
font-size: 0.9rem;
}
/* ── Header ─────────────────────────────────────────────── */
header {
background: var(--navy);
color: white;
padding: 0.9rem 1.5rem;
display: flex;
align-items: center;
gap: 1rem;
position: sticky;
top: 0;
z-index: 100;
}
header h1 { font-size: 1.15rem; font-weight: 700; }
header .sub { color: var(--teal); font-size: 0.8rem; margin-left: auto; }
/* ── Layout ─────────────────────────────────────────────── */
.layout {
display: grid;
grid-template-columns: 290px 1fr;
min-height: calc(100vh - 52px);
}
/* ── Sidebar ─────────────────────────────────────────────── */
.sidebar {
background: var(--card);
border-right: 1px solid var(--border);
padding: 1rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0;
}
.sidebar-section { margin-bottom: 1rem; }
.section-title {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
margin-bottom: 0.6rem;
padding-bottom: 0.3rem;
border-bottom: 1px solid var(--border);
}
.sidebar label {
display: block;
font-size: 0.8rem;
font-weight: 600;
color: var(--text);
margin-bottom: 0.2rem;
margin-top: 0.5rem;
}
.sidebar label:first-of-type { margin-top: 0; }
.sidebar input[type="date"],
.sidebar input[type="text"] {
width: 100%;
padding: 0.35rem 0.5rem;
border: 1px solid var(--border);
border-radius: 5px;
font-size: 0.8rem;
font-family: inherit;
background: var(--bg);
}
.sidebar select {
width: 100%;
padding: 0.35rem;
border: 1px solid var(--border);
border-radius: 5px;
font-size: 0.8rem;
font-family: inherit;
background: var(--bg);
height: 100px;
}
.hint { font-size: 0.72rem; color: var(--muted); margin-top: 0.15rem; }
/* ── Buttons ─────────────────────────────────────────────── */
.btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
width: 100%;
padding: 0.5rem 0.8rem;
border: none;
border-radius: 6px;
font-size: 0.82rem;
font-weight: 700;
font-family: inherit;
cursor: pointer;
margin-top: 0.5rem;
transition: opacity 0.15s;
}
.btn:disabled { opacity: 0.5; cursor: wait; }
.btn-navy { background: var(--navy); color: white; }
.btn-teal { background: var(--teal); color: white; }
.btn-navy:hover:not(:disabled) { background: #0d3d66; }
.btn-teal:hover:not(:disabled) { background: #0fa3d4; }
.btn-full {
background: var(--green);
color: white;
padding: 0.6rem;
font-size: 0.85rem;
}
.btn-full:hover:not(:disabled) { background: #005a3a; }
.divider { border-top: 1px solid var(--border); margin: 0.8rem 0; }
/* ── Main content ─────────────────────────────────────────── */
.main { padding: 1.2rem 1.5rem; overflow-y: auto; }
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.7rem;
margin-bottom: 1.2rem;
}
.stat-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.85rem 1rem;
text-align: center;
}
.stat-card .num {
font-size: 1.8rem;
font-weight: 700;
color: var(--navy);
line-height: 1.1;
}
.stat-card .label {
font-size: 0.72rem;
color: var(--muted);
margin-top: 0.25rem;
}
.charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
gap: 0.9rem;
}
.chart-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.9rem;
}
.chart-card h4 {
font-size: 0.85rem;
color: var(--navy);
margin-bottom: 0.5rem;
font-weight: 700;
}
/* ── Status messages ─────────────────────────────────────── */
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
color: var(--muted);
gap: 0.5rem;
}
.placeholder svg { opacity: 0.25; }
.loading { text-align: center; padding: 2rem; color: var(--muted); }
/* ── Toast notification ─────────────────────────────────── */
#toast {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
background: var(--navy);
color: white;
padding: 0.6rem 1rem;
border-radius: 6px;
font-size: 0.82rem;
font-weight: 600;
z-index: 999;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
#toast.show { opacity: 1; }
#toast.error { background: var(--red); }
@media (max-width: 820px) {
.layout { grid-template-columns: 1fr; }
.sidebar { border-right: none; border-bottom: 1px solid var(--border); }
}
</style>
</head>
<body>
<header>
<h1>SHEQanator</h1>
<span class="sub">{{ total_events }} events loaded &bull; Safety Energy &amp; LLC analysis enabled</span>
</header>
<div class="layout">
<!-- ═══════════════════════════════════════ SIDEBAR ══════════════════ -->
<aside class="sidebar">
<!-- Events Explorer -->
<div class="sidebar-section">
<div class="section-title">Events Explorer</div>
<label>Start Date</label>
<input type="date" id="startDate" value="{{ min_date }}">
<label>End Date</label>
<input type="date" id="endDate" value="{{ max_date }}">
<label>Event Types</label>
<select id="eventTypes" multiple>
{% for et in event_types %}
<option value="{{ et }}" selected>{{ et }}</option>
{% endfor %}
</select>
<div class="hint">Ctrl/Cmd+click to multi-select</div>
<label>Actual Consequence</label>
<select id="consequences" multiple>
{% for c in consequences %}
<option value="{{ c }}" selected>{{ c }}</option>
{% endfor %}
</select>
<button class="btn btn-navy" onclick="applyFilters()">
&#9654; Apply Filters
</button>
</div>
<div class="divider"></div>
<!-- Full Report Generation -->
<div class="sidebar-section">
<div class="section-title">Full Safety Report</div>
<div class="hint" style="margin-bottom:0.5rem;">
Analyses Events + Safety Energy + LLC Data across all business units.
Includes leading indicators, effectiveness, at-risk themes, and recommendations.
</div>
<label>Analysis Start Date</label>
<input type="date" id="rptStart" value="2024-01-01">
<label>Export Format</label>
<select id="rptFormat" style="height:auto;">
<option value="docx" selected>DOCX</option>
<option value="pptx">PPTX</option>
</select>
<div class="hint">Choose DOCX for a written board pack or PPTX for a slide deck.</div>
<button class="btn btn-full" id="fullReportBtn" onclick="generateFullReport()">
&#8659; Download Report
</button>
<div id="reportStatus" class="hint" style="margin-top:0.4rem; text-align:center;"></div>
</div>
</aside>
<!-- ═══════════════════════════════════════ MAIN ════════════════════ -->
<main class="main" id="content">
<div class="placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
<p>Click <strong>Apply Filters</strong> to explore Events data</p>
<p style="font-size:0.75rem">Use <strong>Download Report</strong> to generate the executive board-pack DOCX</p>
</div>
</main>
</div>
<!-- Toast -->
<div id="toast"></div>
<script>
const palette = ['#0b3254','#13b5ea','#006e47','#009946','#7bc143','#96358d','#d97706','#dc2626'];
let charts = {};
function showToast(msg, isError = false) {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = 'show' + (isError ? ' error' : '');
setTimeout(() => { t.className = ''; }, 3500);
}
function destroyCharts() {
Object.values(charts).forEach(c => { try { c.destroy(); } catch(e){} });
charts = {};
}
// ── Events Explorer ──────────────────────────────────────────────────────
async function applyFilters() {
const content = document.getElementById('content');
content.innerHTML = '<div class="loading">Loading events data...</div>';
destroyCharts();
const params = {
start_date: document.getElementById('startDate').value,
end_date: document.getElementById('endDate').value,
event_types: Array.from(document.getElementById('eventTypes').selectedOptions).map(o => o.value),
consequences: Array.from(document.getElementById('consequences').selectedOptions).map(o => o.value),
};
try {
const res = await fetch('/api/filter', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(params)
});
const data = await res.json();
if (data.error) {
content.innerHTML = `<div class="loading">${data.error}</div>`;
return;
}
// ── Build HTML ───────────────────────────────────────────────────
let html = `
<div class="stats-grid">
<div class="stat-card"><div class="num">${data.total}</div><div class="label">Total Events</div></div>
<div class="stat-card"><div class="num">${data.median_investigate_days ?? '—'}</div><div class="label">Median Days to Investigate</div></div>
<div class="stat-card"><div class="num">${data.median_close_days ?? '—'}</div><div class="label">Median Days to Close</div></div>
<div class="stat-card"><div class="num">${data.closed_pct != null ? data.closed_pct + '%' : '—'}</div><div class="label">Closure Rate</div></div>
</div>
<div style="font-size:0.8rem;color:var(--muted);margin-bottom:1rem;">${data.date_range}</div>
<div class="charts-grid">
<div class="chart-card"><h4>Event Types</h4><canvas id="chartEvt"></canvas></div>
<div class="chart-card"><h4>Monthly Trend</h4><canvas id="chartMonthly"></canvas></div>
<div class="chart-card"><h4>Actual Consequence</h4><canvas id="chartCons"></canvas></div>
<div class="chart-card"><h4>Day of Week</h4><canvas id="chartDow"></canvas></div>
<div class="chart-card"><h4>Root Causes</h4><canvas id="chartRC"></canvas></div>
<div class="chart-card"><h4>Body Parts Injured</h4><canvas id="chartBP"></canvas></div>
<div class="chart-card"><h4>Critical Risk Protocols</h4><canvas id="chartCRP"></canvas></div>
<div class="chart-card"><h4>Injury Classification</h4><canvas id="chartInj"></canvas></div>
</div>`;
content.innerHTML = html;
// ── Charts ───────────────────────────────────────────────────────
const baseOpts = { plugins: { legend: { display: false } } };
charts.evt = new Chart(document.getElementById('chartEvt'), {
type: 'bar',
data: { labels: Object.keys(data.event_types), datasets: [{ data: Object.values(data.event_types), backgroundColor: palette }]},
options: { indexAxis: 'y', ...baseOpts, scales: { x: { beginAtZero: true }}}
});
charts.monthly = new Chart(document.getElementById('chartMonthly'), {
type: 'line',
data: { labels: Object.keys(data.monthly), datasets: [{
data: Object.values(data.monthly), borderColor: '#13b5ea',
backgroundColor: 'rgba(19,181,234,0.12)', fill: true, tension: 0.3
}]},
options: { ...baseOpts, scales: { x: { ticks: { maxRotation: 45 }}}}
});
charts.cons = new Chart(document.getElementById('chartCons'), {
type: 'doughnut',
data: { labels: Object.keys(data.consequences), datasets: [{
data: Object.values(data.consequences),
backgroundColor: ['#006e47','#d97706','#dc2626','#96358d','#0b3254']
}]},
options: { plugins: { legend: { position: 'bottom', labels: { boxWidth: 12 }}}}
});
charts.dow = new Chart(document.getElementById('chartDow'), {
type: 'bar',
data: { labels: Object.keys(data.day_of_week), datasets: [{ data: Object.values(data.day_of_week), backgroundColor: '#0b3254' }]},
options: { ...baseOpts, scales: { y: { beginAtZero: true }}}
});
if (Object.keys(data.root_causes).length > 0) {
charts.rc = new Chart(document.getElementById('chartRC'), {
type: 'bar',
data: { labels: Object.keys(data.root_causes), datasets: [{ data: Object.values(data.root_causes), backgroundColor: palette }]},
options: { indexAxis: 'y', ...baseOpts }
});
}
if (Object.keys(data.body_parts).length > 0) {
charts.bp = new Chart(document.getElementById('chartBP'), {
type: 'bar',
data: { labels: Object.keys(data.body_parts), datasets: [{ data: Object.values(data.body_parts), backgroundColor: '#006e47' }]},
options: { indexAxis: 'y', ...baseOpts }
});
}
if (Object.keys(data.crp).length > 0) {
charts.crp = new Chart(document.getElementById('chartCRP'), {
type: 'bar',
data: { labels: Object.keys(data.crp), datasets: [{ data: Object.values(data.crp), backgroundColor: '#13b5ea' }]},
options: { indexAxis: 'y', ...baseOpts }
});
}
charts.inj = new Chart(document.getElementById('chartInj'), {
type: 'doughnut',
data: { labels: Object.keys(data.injury_classification), datasets: [{
data: Object.values(data.injury_classification), backgroundColor: palette
}]},
options: { plugins: { legend: { position: 'bottom', labels: { boxWidth: 12 }}}}
});
} catch (e) {
content.innerHTML = `<div class="loading" style="color:var(--red)">Error: ${e.message}</div>`;
showToast('Failed to load data: ' + e.message, true);
}
}
// ── Full Report Generation ───────────────────────────────────────────────
async function generateFullReport() {
const btn = document.getElementById('fullReportBtn');
const status = document.getElementById('reportStatus');
btn.disabled = true;
btn.textContent = '⏳ Generating report…';
status.textContent = 'Loading data and running analysis. This may take 3060 seconds…';
status.style.color = 'var(--muted)';
const params = {
start_date: document.getElementById('rptStart').value,
export_format: document.getElementById('rptFormat').value,
};
try {
const res = await fetch('/api/generate_full_report', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(params)
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(err.error || `HTTP ${res.status}`);
}
const blob = await res.blob();
const ts = new Date().toISOString().replace(/[-:T]/g, '').slice(0,13);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const format = document.getElementById('rptFormat').value;
a.href = url;
a.download = `SHEQ_Safety_Performance_${ts}.${format}`;
a.click();
URL.revokeObjectURL(url);
status.textContent = '✓ Report downloaded successfully';
status.style.color = 'var(--green)';
showToast('Full report downloaded');
} catch (e) {
status.textContent = '✗ Error: ' + e.message;
status.style.color = 'var(--red)';
showToast('Report generation failed: ' + e.message, true);
} finally {
btn.disabled = false;
btn.textContent = '⬇ Download Report';
}
}
// Auto-load on page ready
window.addEventListener('DOMContentLoaded', () => applyFilters());
</script>
</body>
</html>