503 lines
21 KiB
HTML
503 lines
21 KiB
HTML
<!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 • Safety Energy & 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()">
|
||
▶ 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()">
|
||
⇓ 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 30–60 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>
|