663 lines
22 KiB
HTML
663 lines
22 KiB
HTML
{{define "title"}}Dashboard — CpOne{{end}}
|
|
{{define "header-title"}}MCU Live Dashboard{{end}}
|
|
|
|
{{define "content"}}
|
|
{{$proj := .Project}}
|
|
<style>
|
|
@keyframes sseFlash {
|
|
0% {
|
|
box-shadow: inset 0 0 0 2px rgba(59, 80, 160, 0.68);
|
|
background-color: #dbeafe;
|
|
}
|
|
100% {
|
|
box-shadow: inset 0 0 0 0 rgba(59, 80, 160, 0);
|
|
background-color: transparent;
|
|
}
|
|
}
|
|
|
|
@keyframes sseSweep {
|
|
0% { transform: translateX(-120%); }
|
|
100% { transform: translateX(120%); }
|
|
}
|
|
|
|
@keyframes ssePop {
|
|
0% { transform: scale(1); }
|
|
22% { transform: scale(1.022); }
|
|
100% { transform: scale(1); }
|
|
}
|
|
|
|
@keyframes stationRowPulse {
|
|
0% {
|
|
background-color: #dbeafe;
|
|
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.55);
|
|
}
|
|
100% {
|
|
background-color: transparent;
|
|
box-shadow: inset 0 0 0 0 rgba(59, 130, 246, 0);
|
|
}
|
|
}
|
|
|
|
@keyframes arrivalRowPulse {
|
|
0% {
|
|
background-color: #e0e7ff;
|
|
box-shadow: inset 0 0 0 1px rgba(79, 70, 229, 0.45);
|
|
}
|
|
100% {
|
|
background-color: transparent;
|
|
box-shadow: inset 0 0 0 0 rgba(79, 70, 229, 0);
|
|
}
|
|
}
|
|
|
|
.sse-anim-target {
|
|
position: relative;
|
|
isolation: isolate;
|
|
transform-origin: center;
|
|
will-change: transform, box-shadow, background-color;
|
|
transition: background-color .2s ease, box-shadow .2s ease, transform .2s ease;
|
|
}
|
|
|
|
.sse-anim-target > * {
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
|
|
.sse-updated {
|
|
animation: sseFlash 2.8s ease-out, ssePop 0.95s cubic-bezier(.22,.61,.36,1);
|
|
}
|
|
|
|
.sse-updated::before {
|
|
content: "";
|
|
position: absolute;
|
|
inset: 0;
|
|
z-index: 2;
|
|
pointer-events: none;
|
|
background: rgba(59, 80, 160, 0.22);
|
|
animation: sseFlash 2.8s ease-out;
|
|
}
|
|
|
|
.sse-updated::after {
|
|
content: "";
|
|
position: absolute;
|
|
top: 0;
|
|
bottom: 0;
|
|
left: -45%;
|
|
width: 45%;
|
|
z-index: 3;
|
|
pointer-events: none;
|
|
background: linear-gradient(90deg, rgba(59, 80, 160, 0), rgba(59, 80, 160, 0.35), rgba(59, 80, 160, 0));
|
|
animation: sseSweep 1.1s ease-out 1;
|
|
}
|
|
|
|
.sse-running-bar {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
height: 4px;
|
|
width: 100%;
|
|
pointer-events: none;
|
|
opacity: 0;
|
|
z-index: 4;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.sse-running-bar::after {
|
|
content: "";
|
|
display: block;
|
|
height: 100%;
|
|
width: 40%;
|
|
background: linear-gradient(90deg, rgba(59, 80, 160, 0), rgba(59, 80, 160, 0.95), rgba(59, 80, 160, 0));
|
|
transform: translateX(-120%);
|
|
}
|
|
|
|
.sse-updated .sse-running-bar {
|
|
opacity: 1;
|
|
}
|
|
|
|
.sse-updated .sse-running-bar::after {
|
|
animation: sseSweep 1.05s linear 1;
|
|
}
|
|
|
|
.sse-updating-label {
|
|
position: absolute;
|
|
top: 8px;
|
|
right: 12px;
|
|
z-index: 5;
|
|
padding: 2px 8px;
|
|
border-radius: 9999px;
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
letter-spacing: .03em;
|
|
color: #1e3a8a;
|
|
background: rgba(191, 219, 254, 0.92);
|
|
border: 1px solid rgba(147, 197, 253, 0.95);
|
|
opacity: 0;
|
|
transform: translateY(-4px);
|
|
transition: opacity .18s ease, transform .18s ease;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.sse-updated .sse-updating-label {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
|
|
|
|
#sse-stations.sse-updated {
|
|
box-shadow: none;
|
|
background-color: transparent;
|
|
}
|
|
|
|
#sse-stations .station-row-updated td {
|
|
animation: stationRowPulse 2.6s ease-out;
|
|
}
|
|
|
|
#sse-arrivals .arrival-row-updated {
|
|
animation: arrivalRowPulse 2.6s ease-out;
|
|
}
|
|
</style>
|
|
|
|
<!-- SSE wrapper — satu koneksi, semua section dapat update otomatis -->
|
|
<div hx-ext="sse" sse-connect="{{b "/dashboard/stream"}}?mode=daily&date={{.DateFrom}}">
|
|
|
|
<!-- Project Banner -->
|
|
<section class="card p-5">
|
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
<div>
|
|
<div class="flex items-center gap-2">
|
|
<p class="text-xs font-semibold uppercase tracking-widest text-brand-500">Ongoing Project</p>
|
|
{{if .IsLive}}
|
|
<span class="flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-xs font-semibold text-red-600">
|
|
<span class="h-1.5 w-1.5 rounded-full bg-red-500 animate-pulse"></span> LIVE
|
|
</span>
|
|
{{end}}
|
|
</div>
|
|
<h2 class="mt-1 text-lg font-semibold text-slate-900">{{$proj.Label}}</h2>
|
|
<p class="mt-0.5 text-sm text-slate-500">
|
|
{{$proj.Number}} • {{$proj.CorporateName}} •
|
|
<span class="num">{{$proj.StartDate | fmtDate}}</span> – <span class="num">{{$proj.EndDate | fmtDate}}</span>
|
|
</p>
|
|
</div>
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
<a href="{{b "/projects"}}" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-brand-400 hover:text-brand-600">
|
|
Ganti project
|
|
</a>
|
|
<form method="get" action="{{b "/dashboard"}}" class="flex flex-wrap items-center gap-2" id="dashboard-filter-form">
|
|
<input type="hidden" name="mode" value="daily"/>
|
|
<label class="text-xs font-medium text-slate-500">Tanggal Check-in</label>
|
|
<select name="date"
|
|
id="dashboard-date"
|
|
onchange="this.form.submit()"
|
|
class="num rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm text-slate-700
|
|
focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-200">
|
|
{{if .AvailableDates}}
|
|
{{range .AvailableDates}}
|
|
<option value="{{.}}" {{if eq . $.DateFrom}}selected{{end}}>{{. | fmtDate}}</option>
|
|
{{end}}
|
|
{{else}}
|
|
<option value="{{.DateFrom}}" selected>{{.DateFrom | fmtDate}}</option>
|
|
{{end}}
|
|
</select>
|
|
<button type="submit"
|
|
class="rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-600">
|
|
Lihat
|
|
</button>
|
|
</form>
|
|
{{if gt .KPI.InvitedStaff 0}}
|
|
<div class="rounded-xl bg-brand-50 px-3 py-2 text-center">
|
|
<p class="text-xs text-slate-500">Invited Staff</p>
|
|
<p class="num text-sm font-semibold text-brand-600">{{.KPI.InvitedStaff}}</p>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- KPI Cards — SSE swap -->
|
|
<section id="sse-kpi" class="grid gap-4 sm:grid-cols-3 sse-anim-target"
|
|
sse-swap="kpi" hx-swap="innerHTML">
|
|
<span class="sse-running-bar" aria-hidden="true"></span>
|
|
<span class="sse-updating-label" aria-hidden="true">Updating...</span>
|
|
{{range $i := seq 3}}
|
|
<div class="card h-28 animate-pulse bg-slate-50"></div>
|
|
{{end}}
|
|
</section>
|
|
|
|
<!-- TAT + TAT Chart -->
|
|
<section class="grid gap-5 xl:grid-cols-[1fr_2fr]">
|
|
|
|
<article class="card border-l-4 border-l-brand-500 p-5">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div>
|
|
<p class="text-xs font-semibold uppercase tracking-widest text-brand-500">Avg TAT by Hour</p>
|
|
<p class="mt-0.5 text-sm font-medium text-slate-600">Check-in → Check-out</p>
|
|
</div>
|
|
<span class="num rounded-full bg-brand-50 px-3 py-1 text-xs font-semibold text-brand-600">
|
|
{{.DateFrom | fmtDate}}
|
|
</span>
|
|
</div>
|
|
{{if gt .TAT.CheckedOut 0}}
|
|
<p class="num mt-5 text-4xl font-semibold text-slate-900">
|
|
{{div .TAT.AvgMinutes 60}}<span class="text-xl text-slate-400">h</span>
|
|
{{mod .TAT.AvgMinutes 60}}<span class="text-xl text-slate-400">m</span>
|
|
</p>
|
|
<p class="mt-1 text-xs text-slate-400">Average turnaround untuk pasien yang sudah selesai</p>
|
|
<div class="mt-4 grid grid-cols-2 gap-2">
|
|
<div class="rounded-xl bg-slate-50 px-2 py-2.5 text-center">
|
|
<p class="text-xs text-slate-400">Fastest</p>
|
|
<p class="num mt-1 text-sm font-semibold text-slate-700">
|
|
{{div .TAT.Fastest 60}}h {{mod .TAT.Fastest 60}}m
|
|
</p>
|
|
</div>
|
|
<div class="rounded-xl bg-slate-50 px-2 py-2.5 text-center">
|
|
<p class="text-xs text-slate-400">Median</p>
|
|
<p class="num mt-1 text-sm font-semibold text-slate-700">
|
|
{{div .TAT.Median 60}}h {{mod .TAT.Median 60}}m
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{{else}}
|
|
<p class="mt-6 text-sm text-slate-400">Belum ada data checkout pada tanggal ini.</p>
|
|
{{end}}
|
|
</article>
|
|
|
|
<article class="card p-5">
|
|
<div class="mb-4 flex items-center justify-between">
|
|
<h2 class="text-sm font-semibold text-slate-700">Average TAT by Hour</h2>
|
|
<span class="text-xs text-slate-400">Hourly average across selected date(s)</span>
|
|
</div>
|
|
<div id="tat-chart" class="h-52 w-full"></div>
|
|
</article>
|
|
|
|
</section>
|
|
|
|
<!-- Station Status + Arrival List — SSE swap -->
|
|
<section class="grid gap-5 xl:grid-cols-3">
|
|
|
|
<article id="sse-stations" class="card xl:col-span-2 overflow-hidden sse-anim-target"
|
|
sse-swap="stations" hx-swap="innerHTML">
|
|
<span class="sse-running-bar" aria-hidden="true"></span>
|
|
<span class="sse-updating-label" aria-hidden="true">Updating...</span>
|
|
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3">
|
|
<h2 class="text-sm font-semibold text-slate-700">Station Status</h2>
|
|
<span class="flex items-center gap-1.5 text-xs font-medium text-slate-400 animate-pulse">
|
|
<span class="h-1.5 w-1.5 rounded-full bg-slate-300"></span> Connecting...
|
|
</span>
|
|
</div>
|
|
<div class="p-5 text-sm text-slate-400">Memuat data...</div>
|
|
</article>
|
|
|
|
<article id="sse-arrivals" class="card overflow-hidden sse-anim-target"
|
|
sse-swap="arrivals" hx-swap="innerHTML">
|
|
<span class="sse-running-bar" aria-hidden="true"></span>
|
|
<span class="sse-updating-label" aria-hidden="true">Updating...</span>
|
|
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3">
|
|
<h2 class="text-sm font-semibold text-slate-700">Arrival List</h2>
|
|
<a href="{{b "/arrival"}}" class="text-xs font-medium text-brand-500 hover:text-brand-700">View all</a>
|
|
</div>
|
|
<div class="p-5 text-sm text-slate-400">Memuat data...</div>
|
|
</article>
|
|
|
|
</section>
|
|
|
|
<!-- Trend Chart -->
|
|
<section>
|
|
<article class="card p-5">
|
|
<div class="mb-4 flex items-center justify-between">
|
|
<h2 class="text-sm font-semibold text-slate-700">Arrival to Verification Trend by Hour</h2>
|
|
<span class="num text-xs text-slate-400">
|
|
{{.DateFrom | fmtDate}}
|
|
</span>
|
|
</div>
|
|
<div id="trend-chart" class="h-64 w-full"></div>
|
|
</article>
|
|
</section>
|
|
|
|
</div><!-- end SSE wrapper -->
|
|
<!-- Modal: Semua Pasien -->
|
|
<dialog id="patients-modal"
|
|
class="w-full max-w-6xl rounded-2xl border border-slate-200 bg-white shadow-2xl p-0 backdrop:bg-slate-900/50"
|
|
onclick="if(event.target===this)this.close()">
|
|
<div class="flex flex-col max-h-[85vh]">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between border-b border-slate-100 px-6 py-4 flex-shrink-0">
|
|
<div>
|
|
<h2 class="text-base font-semibold text-slate-900">Semua Pasien</h2>
|
|
<p id="patients-modal-subtitle" class="mt-0.5 text-xs text-slate-400"></p>
|
|
</div>
|
|
<button onclick="document.getElementById('patients-modal').close()"
|
|
class="flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors">
|
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<!-- Body -->
|
|
<div id="patients-modal-body" class="overflow-auto flex-1 min-h-0">
|
|
<div class="flex items-center justify-center py-16 text-slate-400">
|
|
<svg class="h-5 w-5 animate-spin mr-2 text-brand-400" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
|
|
</svg>
|
|
Memuat data...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</dialog>
|
|
|
|
<script>
|
|
function fmtDate(s) {
|
|
if (!s) return '';
|
|
const [y, m, d] = s.split('-');
|
|
return d + '/' + m + '/' + y;
|
|
}
|
|
|
|
function openPatientsModal() {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const modal = document.getElementById('patients-modal');
|
|
const body = document.getElementById('patients-modal-body');
|
|
const subtitle = document.getElementById('patients-modal-subtitle');
|
|
|
|
const mode = params.get('mode') || 'daily';
|
|
const date = params.get('date') || '';
|
|
const dateEnd = params.get('date_end') || '';
|
|
|
|
if (mode === 'daily') {
|
|
subtitle.textContent = date ? 'Tanggal: ' + fmtDate(date) : 'Hari ini';
|
|
} else {
|
|
subtitle.textContent = 'Periode: ' + fmtDate(date) + (dateEnd ? ' s/d ' + fmtDate(dateEnd) : '');
|
|
}
|
|
|
|
body.innerHTML = '<div class="flex items-center justify-center py-16 text-slate-400"><svg class="h-5 w-5 animate-spin mr-2 text-brand-400" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path></svg>Memuat data...</div>';
|
|
modal.showModal();
|
|
|
|
fetch('{{b "/dashboard/patients"}}?' + params.toString())
|
|
.then(r => r.text())
|
|
.then(html => { body.innerHTML = html; })
|
|
.catch(() => { body.innerHTML = '<p class="p-6 text-sm text-red-500">Gagal memuat data.</p>'; });
|
|
}
|
|
|
|
(function() {
|
|
const sseTargets = new Set(['sse-kpi', 'sse-stations', 'sse-arrivals']);
|
|
const sseEventTargetMap = {
|
|
kpi: 'sse-kpi',
|
|
stations: 'sse-stations',
|
|
arrivals: 'sse-arrivals'
|
|
};
|
|
const stationSnapshot = new Map();
|
|
const arrivalSnapshot = new Set();
|
|
let audioCtx = null;
|
|
let audioUnlocked = false;
|
|
let pendingPluk = false;
|
|
|
|
function ensureAudioContext() {
|
|
if (audioCtx) return audioCtx;
|
|
const Ctx = window.AudioContext || window.webkitAudioContext;
|
|
if (!Ctx) return null;
|
|
audioCtx = new Ctx();
|
|
return audioCtx;
|
|
}
|
|
|
|
function unlockAudio() {
|
|
const ctx = ensureAudioContext();
|
|
if (!ctx) return;
|
|
if (ctx.state === 'suspended') {
|
|
ctx.resume().catch(function () {});
|
|
}
|
|
audioUnlocked = true;
|
|
if (pendingPluk) {
|
|
pendingPluk = false;
|
|
setTimeout(playPluk, 60);
|
|
}
|
|
}
|
|
|
|
function playPluk() {
|
|
const ctx = ensureAudioContext();
|
|
if (!ctx) return;
|
|
if (!audioUnlocked) {
|
|
pendingPluk = true;
|
|
return;
|
|
}
|
|
if (ctx.state !== 'running') {
|
|
pendingPluk = true;
|
|
ctx.resume()
|
|
.then(function () {
|
|
setTimeout(function () {
|
|
if (pendingPluk) {
|
|
pendingPluk = false;
|
|
playPluk();
|
|
}
|
|
}, 40);
|
|
})
|
|
.catch(function () {});
|
|
return;
|
|
}
|
|
|
|
const now = ctx.currentTime;
|
|
const osc = ctx.createOscillator();
|
|
const gain = ctx.createGain();
|
|
const lp = ctx.createBiquadFilter();
|
|
|
|
osc.type = 'triangle';
|
|
osc.frequency.setValueAtTime(860, now);
|
|
osc.frequency.exponentialRampToValueAtTime(530, now + 0.12);
|
|
|
|
lp.type = 'lowpass';
|
|
lp.frequency.setValueAtTime(1800, now);
|
|
lp.Q.value = 0.8;
|
|
|
|
gain.gain.setValueAtTime(0.0001, now);
|
|
gain.gain.exponentialRampToValueAtTime(0.3, now + 0.01);
|
|
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.24);
|
|
|
|
osc.connect(lp);
|
|
lp.connect(gain);
|
|
gain.connect(ctx.destination);
|
|
|
|
osc.start(now);
|
|
osc.stop(now + 0.26);
|
|
}
|
|
|
|
function triggerStationRows(target) {
|
|
const rows = target.querySelectorAll('.station-row');
|
|
if (!rows || !rows.length) return;
|
|
let highlighted = 0;
|
|
rows.forEach(function (row, idx) {
|
|
const key = row.dataset.stationKey || '';
|
|
const processed = parseInt(row.dataset.processed || '0', 10);
|
|
const prevProcessed = stationSnapshot.has(key) ? stationSnapshot.get(key) : null;
|
|
stationSnapshot.set(key, processed);
|
|
if (prevProcessed === null || processed <= prevProcessed) return;
|
|
|
|
highlighted++;
|
|
row.classList.remove('station-row-updated');
|
|
void row.offsetWidth;
|
|
setTimeout(function () {
|
|
row.classList.add('station-row-updated');
|
|
}, Math.min(idx * 70, 420));
|
|
setTimeout(function () {
|
|
row.classList.remove('station-row-updated');
|
|
}, 2800 + Math.min(idx * 70, 420));
|
|
});
|
|
return highlighted > 0;
|
|
}
|
|
|
|
function triggerArrivalRows(target) {
|
|
const rows = target.querySelectorAll('.arrival-row');
|
|
if (!rows || !rows.length) return;
|
|
let highlighted = 0;
|
|
rows.forEach(function (row, idx) {
|
|
const key = row.dataset.arrivalKey || '';
|
|
const isNew = key && !arrivalSnapshot.has(key);
|
|
if (key) arrivalSnapshot.add(key);
|
|
if (!isNew) return;
|
|
|
|
highlighted++;
|
|
row.classList.remove('arrival-row-updated');
|
|
void row.offsetWidth;
|
|
setTimeout(function () {
|
|
row.classList.add('arrival-row-updated');
|
|
}, Math.min(idx * 70, 420));
|
|
setTimeout(function () {
|
|
row.classList.remove('arrival-row-updated');
|
|
}, 2800 + Math.min(idx * 70, 420));
|
|
});
|
|
return highlighted > 0;
|
|
}
|
|
|
|
function triggerHighlight(target) {
|
|
if (!target) return;
|
|
const isStations = target.id === 'sse-stations';
|
|
let didHighlightRows = false;
|
|
if (!isStations) {
|
|
if (target.id === 'sse-arrivals') {
|
|
didHighlightRows = !!triggerArrivalRows(target);
|
|
} else {
|
|
target.classList.remove('sse-updated');
|
|
void target.offsetWidth;
|
|
target.classList.add('sse-updated');
|
|
didHighlightRows = true;
|
|
}
|
|
} else {
|
|
didHighlightRows = !!triggerStationRows(target);
|
|
}
|
|
if (didHighlightRows) {
|
|
playPluk();
|
|
}
|
|
clearTimeout(target._sseHighlightTimer);
|
|
target._sseHighlightTimer = setTimeout(function () {
|
|
target.classList.remove('sse-updated');
|
|
}, 2800);
|
|
}
|
|
|
|
function setupMutationHighlight(targetId) {
|
|
const target = document.getElementById(targetId);
|
|
if (!target) return;
|
|
let armed = false;
|
|
let lastTriggerAt = 0;
|
|
|
|
const observer = new MutationObserver(function (mutations) {
|
|
if (!armed) {
|
|
armed = true;
|
|
return;
|
|
}
|
|
const hasRealChange = mutations.some(function (m) {
|
|
return (m.type === 'childList' && (m.addedNodes.length || m.removedNodes.length)) ||
|
|
(m.type === 'characterData' && m.oldValue !== m.target.data);
|
|
});
|
|
if (!hasRealChange) return;
|
|
|
|
const now = Date.now();
|
|
if (now - lastTriggerAt < 800) return;
|
|
lastTriggerAt = now;
|
|
const beforeClass = target.className;
|
|
triggerHighlight(target);
|
|
// Fallback audio for edge cases where row diff doesn't mark highlight.
|
|
if ((targetId === 'sse-stations' || targetId === 'sse-arrivals') && beforeClass === target.className) {
|
|
playPluk();
|
|
}
|
|
});
|
|
|
|
observer.observe(target, {
|
|
childList: true,
|
|
subtree: true,
|
|
characterData: true,
|
|
characterDataOldValue: true
|
|
});
|
|
}
|
|
|
|
// Browser policy: audio baru bisa aktif setelah interaksi user.
|
|
['pointerdown', 'mousedown', 'keydown', 'touchstart', 'focus'].forEach(function (evtName) {
|
|
window.addEventListener(evtName, unlockAudio, { passive: true, once: true });
|
|
});
|
|
|
|
document.body.addEventListener('htmx:afterSwap', function (evt) {
|
|
const target = evt.detail && evt.detail.target ? evt.detail.target : null;
|
|
if (!target || !target.id || !sseTargets.has(target.id)) return;
|
|
|
|
// Skip first hydration so highlight means "new/update", not initial render.
|
|
if (!target.dataset.sseHydrated) {
|
|
target.dataset.sseHydrated = '1';
|
|
return;
|
|
}
|
|
|
|
triggerHighlight(target);
|
|
});
|
|
|
|
// Fallback trigger: beberapa setup SSE HTMX tidak selalu menembakkan afterSwap seperti yang diharapkan.
|
|
document.body.addEventListener('htmx:sseMessage', function (evt) {
|
|
const eventName = evt.detail && evt.detail.event ? evt.detail.event : '';
|
|
const targetId = sseEventTargetMap[eventName];
|
|
if (!targetId) return;
|
|
const target = document.getElementById(targetId);
|
|
if (!target) return;
|
|
// Skip first SSE frame per target, then animate on subsequent updates.
|
|
if (!target.dataset.sseHydrated) {
|
|
target.dataset.sseHydrated = '1';
|
|
return;
|
|
}
|
|
triggerHighlight(target);
|
|
});
|
|
|
|
// Hard fallback: pastikan perubahan konten section tetap men-trigger efek meski event SSE/HTMX tidak konsisten.
|
|
setupMutationHighlight('sse-kpi');
|
|
setupMutationHighlight('sse-stations');
|
|
setupMutationHighlight('sse-arrivals');
|
|
|
|
const palette = ['#3b50a0', '#6677d6', '#10b981', '#f59e0b'];
|
|
const tatData = {{.TATChart}};
|
|
const trendData = {{.TrendChart}};
|
|
|
|
const tatEl = document.getElementById('tat-chart');
|
|
if (tatEl && tatData.labels && tatData.labels.length) {
|
|
const tatChart = echarts.init(tatEl);
|
|
tatChart.setOption({
|
|
color: palette,
|
|
tooltip: {
|
|
trigger: 'axis',
|
|
formatter: p => `${p[0].axisValue}<br/>Avg TAT: <b>${Math.round(p[0].data)} mnt</b>`
|
|
},
|
|
grid: { left: 50, right: 20, top: 16, bottom: 28 },
|
|
xAxis: {
|
|
type: 'category', data: tatData.labels,
|
|
axisLine: { lineStyle: { color: '#e2e8f0' } }, axisTick: { show: false }
|
|
},
|
|
yAxis: {
|
|
type: 'value', name: 'Mnt',
|
|
nameTextStyle: { color: '#94a3b8', fontSize: 11 },
|
|
splitLine: { lineStyle: { color: '#f1f5f9' } }
|
|
},
|
|
series: [{
|
|
name: 'Avg TAT', type: 'bar', barWidth: 24, data: tatData.values,
|
|
itemStyle: { borderRadius: [6, 6, 0, 0], color: '#3b50a0' }
|
|
}]
|
|
});
|
|
window.addEventListener('resize', () => tatChart.resize());
|
|
}
|
|
|
|
const trendEl = document.getElementById('trend-chart');
|
|
if (trendEl && trendData.labels && trendData.labels.length) {
|
|
const trendChart = echarts.init(trendEl);
|
|
trendChart.setOption({
|
|
color: palette,
|
|
tooltip: { trigger: 'axis' },
|
|
legend: {
|
|
data: ['Checked In', 'Checked Out'],
|
|
textStyle: { fontSize: 12, color: '#64748b' }, top: 0
|
|
},
|
|
grid: { left: 40, right: 20, top: 36, bottom: 28 },
|
|
xAxis: {
|
|
type: 'category', data: trendData.labels,
|
|
axisLine: { lineStyle: { color: '#e2e8f0' } }, axisTick: { show: false }
|
|
},
|
|
yAxis: { type: 'value', splitLine: { lineStyle: { color: '#f1f5f9' } } },
|
|
series: [
|
|
{ name: 'Checked In', type: 'line', smooth: true, data: trendData.checkedIn, symbolSize: 5 },
|
|
{ name: 'Checked Out', type: 'line', smooth: true, data: trendData.checkedOut, symbolSize: 5, lineStyle: { type: 'dashed' } }
|
|
]
|
|
});
|
|
window.addEventListener('resize', () => trendChart.resize());
|
|
}
|
|
})();
|
|
</script>
|
|
{{end}}
|