Initial commit

This commit is contained in:
sas.fajri
2026-04-30 14:27:01 +07:00
commit e29e943c27
70 changed files with 8909 additions and 0 deletions

View File

@@ -0,0 +1,182 @@
{{define "title"}}Abnormal Monitoring — CpOne{{end}}
{{define "header-title"}}Abnormal Monitoring{{end}}
{{define "content"}}
{{$proj := .CurrentProject}}
{{$group := .Group}}
<section class="card p-5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-brand-500">Ongoing Project</p>
<h2 class="mt-1 text-lg font-semibold text-slate-900">
{{if $proj.Label}}{{$proj.Label}}{{else}}MCU #{{$proj.McuID}}{{end}}
</h2>
<p class="mt-0.5 text-sm text-slate-500">
{{$proj.Number}} &bull; {{$proj.CorporateName}} &bull;
<span class="num">{{$proj.StartDate | fmtDate}}</span> &ndash; <span class="num">{{$proj.EndDate | fmtDate}}</span>
</p>
</div>
<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>
</div>
</section>
<section class="card p-3">
<div class="flex flex-wrap items-center gap-2 text-sm">
<a href="{{b "/abnormal"}}"
class="rounded-xl px-4 py-2 font-semibold transition
{{if eq $group ""}}bg-brand-500 text-white{{else}}border border-brand-500 text-brand-500 hover:bg-brand-50{{end}}">
Semua Kelainan
</a>
{{range .Groups}}
<a href="{{b "/abnormal"}}?group={{. | urlquery}}"
class="rounded-xl px-4 py-2 font-semibold transition
{{if eq . $group}}bg-brand-500 text-white{{else}}border border-brand-500 text-brand-500 hover:bg-brand-50{{end}}">
{{.}}
</a>
{{end}}
</div>
</section>
<section class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<article class="card border-l-4 border-l-brand-400 p-4">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Total Peserta</p>
<p class="num mt-2 text-3xl font-semibold text-slate-900">{{.Summary.Total}}</p>
<p class="mt-1 text-xs text-slate-400">Peserta aktif dalam project</p>
</article>
<article class="card border-l-4 border-l-emerald-400 p-4">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Normal</p>
<p class="num mt-2 text-3xl font-semibold text-emerald-600">{{.Summary.Normal}}</p>
<p class="mt-1 text-xs text-slate-400">Tanpa temuan kelainan</p>
</article>
<article class="card border-l-4 border-l-red-400 p-4">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Abnormal</p>
<p class="num mt-2 text-3xl font-semibold text-red-500">{{.Summary.Abnormal}}</p>
<p class="mt-1 text-xs text-slate-400">
{{if eq $group ""}}Ada temuan kelainan{{else}}Kelainan: {{$group}}{{end}}
</p>
</article>
<article class="card border-l-4 border-l-amber-400 p-4">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Abnormal Rate</p>
<p class="num mt-2 text-3xl font-semibold text-amber-600">{{.Summary.AbnormalRate}}%</p>
<p class="mt-1 text-xs text-slate-400">Persentase dari total peserta</p>
</article>
</section>
<section class="grid gap-5 xl:grid-cols-2">
<article class="card p-5">
<p class="mb-3 text-sm font-semibold text-slate-700">Normal vs Abnormal</p>
<div id="staff-chart" class="h-72 w-full"></div>
</article>
<article class="card p-5">
<p class="mb-3 text-sm font-semibold text-slate-700">Distribusi Kelompok Usia</p>
<div id="age-chart" class="h-72 w-full"></div>
</article>
<article class="card p-5">
<p class="mb-3 text-sm font-semibold text-slate-700">Gender</p>
<div id="gender-chart" class="h-72 w-full"></div>
</article>
<article id="dept-wrap" class="card p-5">
<p class="mb-3 text-sm font-semibold text-slate-700">Departemen</p>
<div id="dept-chart" class="h-72 w-full"></div>
</article>
</section>
<script>
const staffData = {{.StaffJSON}};
const ageData = {{.AgeJSON}};
const genderData = {{.GenderJSON}};
const deptData = {{.DeptJSON}};
const normalColor = '#3b50a0';
const abnormalColor = '#EF4444';
const staffEl = document.getElementById('staff-chart');
const ageEl = document.getElementById('age-chart');
const genderEl = document.getElementById('gender-chart');
const deptEl = document.getElementById('dept-chart');
const deptWrap = document.getElementById('dept-wrap');
if (staffEl && typeof echarts !== 'undefined') {
const staffChart = echarts.init(staffEl);
staffChart.setOption({
color: [normalColor, abnormalColor],
tooltip: { trigger: 'item' },
legend: { bottom: 0 },
series: [{
type: 'pie',
radius: ['45%', '70%'],
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { formatter: '{b}: {c}' },
data: [
{ value: staffData.normal, name: 'Normal' },
{ value: staffData.abnormal, name: 'Abnormal' }
]
}]
});
window.addEventListener('resize', () => staffChart.resize());
}
if (ageEl && ageData && typeof echarts !== 'undefined') {
const ageChart = echarts.init(ageEl);
ageChart.setOption({
color: [abnormalColor],
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: 40, right: 20, top: 20, bottom: 30 },
xAxis: { type: 'category', data: ageData.labels },
yAxis: { type: 'value' },
series: [{ name: 'Abnormal', type: 'bar', data: ageData.abnormal, barMaxWidth: 48 }]
});
window.addEventListener('resize', () => ageChart.resize());
}
if (genderEl && genderData && typeof echarts !== 'undefined') {
const genderChart = echarts.init(genderEl);
genderChart.setOption({
color: [normalColor, abnormalColor],
tooltip: { trigger: 'item' },
legend: { bottom: 0 },
series: [{
type: 'pie',
radius: ['45%', '70%'],
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { formatter: '{b}: {c}' },
data: genderData.labels.map(function(l, i) {
return { name: l, value: genderData.abnormal[i] };
})
}]
});
window.addEventListener('resize', () => genderChart.resize());
}
if (deptEl && deptData && deptData.labels && deptData.labels.length > 0 && typeof echarts !== 'undefined') {
const deptChart = echarts.init(deptEl);
deptChart.setOption({
color: [normalColor],
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: 8, right: 24, top: 8, bottom: 8, containLabel: true },
xAxis: { type: 'value' },
yAxis: {
type: 'category',
data: deptData.labels.slice().reverse(),
axisLabel: { overflow: 'truncate', width: 160 }
},
series: [{
name: 'Abnormal',
type: 'bar',
data: deptData.abnormal.slice().reverse(),
barMaxWidth: 32,
label: { show: true, position: 'right' }
}]
});
window.addEventListener('resize', () => deptChart.resize());
} else if (deptWrap) {
deptWrap.classList.add('hidden');
}
</script>
{{end}}

View File

@@ -0,0 +1,287 @@
{{define "title"}}Arrival Tracking — CpOne{{end}}
{{define "header-title"}}Arrival Tracking{{end}}
{{define "content"}}
{{$proj := .CurrentProject}}
<section class="card p-5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-brand-500">Ongoing Project</p>
<h2 class="mt-1 text-lg font-semibold text-slate-900">
{{if $proj.Label}}{{$proj.Label}}{{else}}MCU #{{$proj.McuID}}{{end}}
</h2>
<p class="mt-0.5 text-sm text-slate-500">
{{$proj.Number}} &bull; {{$proj.CorporateName}} &bull;
<span class="num">{{$proj.StartDate | fmtDate}}</span> &ndash; <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 "/arrival"}}" class="flex flex-wrap items-center gap-2">
<input type="hidden" name="search" value="{{.Search}}"/>
<input type="hidden" name="dept" value="{{.Department}}"/>
<label class="text-xs font-medium text-slate-500">Tanggal Check-in</label>
<select name="date"
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">
{{range .AvailableDates}}
<option value="{{.}}" {{if eq . $.Date}}selected{{end}}>{{. | 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>
</div>
</div>
</section>
<section class="grid gap-4 sm:grid-cols-3">
<article class="card p-5">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Checked In</p>
<p class="num mt-2 text-3xl font-semibold text-slate-900">{{.Summary.CheckedIn}}</p>
<p class="mt-1 text-xs text-slate-400">Sudah check-in pada tanggal ini</p>
</article>
<article class="card p-5">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Not Check-in Yet</p>
<p class="num mt-2 text-3xl font-semibold text-slate-900">{{.Summary.Pending}}</p>
<p class="mt-1 text-xs text-slate-400">Belum masuk ke area MCU</p>
</article>
<article class="card p-5">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Total Schedule</p>
<p class="num mt-2 text-3xl font-semibold text-slate-900">{{.Summary.Total}}</p>
<p class="mt-1 text-xs text-slate-400">Peserta yang dijadwalkan hari ini</p>
</article>
</section>
<section class="grid gap-5 xl:grid-cols-2">
<article class="card p-5">
<div class="mb-3">
<p class="text-sm font-semibold text-slate-700">Check-in Overview</p>
<p class="text-xs text-slate-400">Inner ring: checked-in summary, outer ring: distribution by department / posisi</p>
</div>
<div id="arrival-overview-chart" class="h-72 w-full"></div>
</article>
<article class="card p-5">
<div class="mb-3">
<p class="text-sm font-semibold text-slate-700">Per Station Distribution</p>
<p class="text-xs text-slate-400">Current observed station loads</p>
</div>
<div id="station-distribution-chart" class="h-64 w-full"></div>
</article>
</section>
<section class="card p-4">
<form method="get" action="{{b "/arrival"}}" class="grid gap-3 md:grid-cols-3">
<input type="hidden" name="date" value="{{.Date}}"/>
<div class="md:col-span-2">
<label for="search" class="mb-2 block text-sm font-medium text-slate-600">Search Participant</label>
<input id="search" name="search" value="{{.Search}}" type="text" placeholder="Name or Employee ID"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200"/>
</div>
<div>
<label for="dept" class="mb-2 block text-sm font-medium text-slate-600">Department</label>
<select id="dept" name="dept"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200">
<option value="" {{if eq .Department ""}}selected{{end}}>All Departments</option>
{{range .DepartmentOptions}}
<option value="{{.}}" {{if eq . $.Department}}selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
<div class="md:col-span-3 flex justify-end">
<button type="submit" class="rounded-xl bg-brand-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-600">
Filter
</button>
</div>
</form>
</section>
<section class="card overflow-hidden">
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3">
<div>
<h2 class="text-base font-semibold text-slate-700">Live Arrival List</h2>
<p class="text-xs text-slate-400">Tanggal: {{.Date | fmtDate}}</p>
</div>
<span class="text-xs font-medium text-slate-400">{{len .FilteredRows}} ditampilkan</span>
</div>
<div class="border-b border-slate-100 px-5 py-3">
<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 font-medium text-slate-600">Not Performed Yet</span>
<span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 font-medium text-amber-700">In Progress</span>
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 font-medium text-emerald-700">Performed</span>
</div>
</div>
{{if .FilteredRows}}
<div class="hidden overflow-x-auto md:block">
<table class="min-w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500">
<tr>
<th class="px-4 py-3 font-medium">Time</th>
<th class="px-4 py-3 font-medium">Employee ID</th>
<th class="px-4 py-3 font-medium">Name</th>
<th class="px-4 py-3 font-medium">Department</th>
<th class="px-4 py-3 font-medium">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{{range .FilteredRows}}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3 num">{{if .InTime}}{{.InTime}}{{else}}-{{end}}</td>
<td class="px-4 py-3 num">{{.NIP}}</td>
<td class="px-4 py-3 font-medium text-slate-700">{{.Name}}</td>
<td class="px-4 py-3 text-slate-500">{{.Department}}</td>
<td class="px-4 py-3">
{{if .Stations}}
<div class="flex flex-wrap gap-1.5">
{{range .Stations}}
{{if eq .Tone "success"}}
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">{{.Name}}</span>
{{else if eq .Tone "warning"}}
<span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700">{{.Name}}</span>
{{else if eq .Tone "danger"}}
<span class="rounded-full border border-rose-200 bg-rose-50 px-2 py-1 text-xs font-medium text-rose-700">{{.Name}}</span>
{{else}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">{{.Name}}</span>
{{end}}
{{end}}
</div>
{{else}}
{{if eq .StatusTone "success"}}
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">{{.Status}}</span>
{{else if eq .StatusTone "warning"}}
<span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700">{{.Status}}</span>
{{else}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">{{.Status}}</span>
{{end}}
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="grid gap-3 p-4 md:hidden">
{{range .FilteredRows}}
<article class="rounded-xl border border-slate-200 p-3">
<p class="font-semibold text-slate-700">{{.Name}}</p>
<p class="mt-1 text-xs text-slate-400">{{if .InTime}}{{.InTime}}{{else}}-{{end}} • {{.NIP}} • {{.Department}}</p>
<div class="mt-2 flex flex-wrap gap-1.5">
{{if .Stations}}
{{range .Stations}}
{{if eq .Tone "success"}}
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">{{.Name}}</span>
{{else if eq .Tone "warning"}}
<span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700">{{.Name}}</span>
{{else if eq .Tone "danger"}}
<span class="rounded-full border border-rose-200 bg-rose-50 px-2 py-1 text-xs font-medium text-rose-700">{{.Name}}</span>
{{else}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">{{.Name}}</span>
{{end}}
{{end}}
{{else}}
{{if eq .StatusTone "success"}}
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">{{.Status}}</span>
{{else if eq .StatusTone "warning"}}
<span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700">{{.Status}}</span>
{{else}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">{{.Status}}</span>
{{end}}
{{end}}
</div>
</article>
{{end}}
</div>
{{else}}
<div class="px-5 py-10 text-center text-sm text-slate-400">
Belum ada data arrival pada tanggal ini.
</div>
{{end}}
</section>
<script>
(function() {
const overviewData = {{.OverviewJSON}};
const stationData = {{.DepartmentJSON}};
const deptColors = ['#f59e0b', '#8b5cf6', '#f97316', '#06b6d4', '#ec4899', '#84cc16', '#14b8a6'];
const overviewEl = document.getElementById('arrival-overview-chart');
if (overviewEl && overviewData && typeof echarts !== 'undefined') {
const overviewChart = echarts.init(overviewEl);
const outerData = (overviewData.depts || []).map(function(d) {
return { value: d.value, name: d.name };
});
overviewChart.setOption({
color: ['#3b50a0', '#cbd5e1'].concat(deptColors),
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: {
type: 'scroll',
orient: 'vertical',
right: 0,
top: 'middle',
textStyle: { color: '#64748b', fontSize: 11 },
selectedMode: false,
pageIconColor: '#3b50a0',
pageTextStyle: { color: '#64748b' }
},
series: [
{
name: 'Check-in Summary',
type: 'pie',
radius: ['28%', '45%'],
center: ['38%', '48%'],
label: { color: '#334155', formatter: '{b}' },
data: [
{ value: overviewData.checkedIn || 0, name: 'Checked In' },
{ value: overviewData.pending || 0, name: 'Not Check-in Yet' }
]
},
{
name: 'Dept Detail',
type: 'pie',
radius: ['55%', '72%'],
center: ['38%', '48%'],
label: { show: false },
labelLine: { show: false },
data: outerData
}
]
});
window.addEventListener('resize', () => overviewChart.resize());
}
const stationEl = document.getElementById('station-distribution-chart');
if (stationEl && stationData && typeof echarts !== 'undefined') {
const stationChart = echarts.init(stationEl);
var revLabels = (stationData.labels || []).slice().reverse();
var revCounts = (stationData.counts || []).slice().reverse();
stationChart.setOption({
color: ['#3b50a0'],
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: 10, right: 20, top: 10, bottom: 10, containLabel: true },
xAxis: { type: 'value', axisLabel: { color: '#64748b' } },
yAxis: {
type: 'category',
axisLabel: { color: '#64748b' },
data: revLabels
},
series: [
{
name: 'Patients',
type: 'bar',
barWidth: 18,
data: revCounts,
itemStyle: { borderRadius: [0, 6, 6, 0] }
}
]
});
window.addEventListener('resize', () => stationChart.resize());
}
})();
</script>
{{end}}

View File

@@ -0,0 +1,105 @@
{{define "password"}}
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Ganti Password — CpOne Dashboard</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=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet"/>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['Plus Jakarta Sans', 'sans-serif'] },
colors: {
brand: {
50: '#eef0fb',
100: '#dde2f7',
200: '#bbc5ef',
300: '#8f9fe4',
400: '#6677d6',
500: '#3b50a0',
600: '#2d3d7a',
700: '#212d5a',
800: '#161e3c',
900: '#0b0f1e',
}
}
}
}
}
</script>
</head>
<body class="min-h-screen bg-slate-100 font-sans text-slate-800">
<!-- Header -->
<header class="bg-brand-500 text-white">
<div class="mx-auto flex w-full max-w-7xl items-center justify-between px-4 py-3 sm:px-6 lg:px-8">
<a href="{{b "/projects"}}" class="shrink-0 rounded-lg bg-white px-3 py-1.5">
<img src="{{b "/static/img/logo.png"}}" alt="Logo" class="h-8 w-auto">
</a>
<div class="flex items-center gap-2 text-sm">
<span class="rounded-full bg-white/15 px-3 py-1 text-xs font-semibold tracking-wide">{{.Username}}</span>
<a href="{{b "/logout"}}" class="rounded-lg px-3 py-1.5 font-medium opacity-75 transition hover:bg-white/15 hover:opacity-100">Logout</a>
</div>
</div>
</header>
<main class="mx-auto w-full max-w-md px-4 py-10 sm:px-6">
<div class="mb-6">
<a href="{{b "/projects"}}" class="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-700">
<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="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"/>
</svg>
Kembali
</a>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-7 shadow-sm">
<h1 class="mb-1 text-lg font-semibold">Ganti Password</h1>
<p class="mb-6 text-sm text-slate-500">Masukkan password saat ini untuk verifikasi, lalu isi password baru.</p>
{{if .Error}}
<div class="mb-5 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{.Error}}
</div>
{{end}}
{{if .Success}}
<div class="mb-5 rounded-xl border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">
{{.Success}}
</div>
{{end}}
<form method="POST" action="{{b "/password"}}" class="space-y-4">
<div class="space-y-1.5">
<label for="current_password" class="block text-sm font-medium">Password Saat Ini</label>
<input id="current_password" name="current_password" type="password" required
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"/>
</div>
<div class="space-y-1.5">
<label for="new_password" class="block text-sm font-medium">Password Baru</label>
<input id="new_password" name="new_password" type="password" required minlength="6"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"/>
</div>
<div class="space-y-1.5">
<label for="confirm_password" class="block text-sm font-medium">Konfirmasi Password Baru</label>
<input id="confirm_password" name="confirm_password" type="password" required minlength="6"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"/>
</div>
<button type="submit"
class="w-full rounded-xl bg-brand-500 px-5 py-3 text-sm font-semibold text-white transition hover:bg-brand-600 active:bg-brand-700">
Simpan Password Baru
</button>
</form>
</div>
</main>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,306 @@
{{define "title"}}Dashboard — CpOne{{end}}
{{define "header-title"}}MCU Live Dashboard{{end}}
{{define "content"}}
{{$proj := .Project}}
<style>
@keyframes sseFlash {
0% { box-shadow: 0 0 0 0 rgba(59, 80, 160, 0.45); background-color: #eef2ff; }
100% { box-shadow: 0 0 0 0 rgba(59, 80, 160, 0); background-color: transparent; }
}
.sse-updated {
animation: sseFlash 3.8s 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}} &bull; {{$proj.CorporateName}} &bull;
<span class="num">{{$proj.StartDate | fmtDate}}</span> &ndash; <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-swap="kpi" hx-swap="innerHTML">
{{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-swap="stations" hx-swap="innerHTML">
<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-swap="arrivals" hx-swap="innerHTML">
<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']);
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;
}
target.classList.remove('sse-updated');
void target.offsetWidth;
target.classList.add('sse-updated');
});
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}}

View File

@@ -0,0 +1,37 @@
{{define "arrivals"}}
<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>
<div class="flex items-center gap-3">
{{if .IsLive}}
<span class="flex items-center gap-1.5 text-xs font-medium text-emerald-600">
<span class="live-dot h-1.5 w-1.5 rounded-full bg-emerald-500"></span> Live
</span>
{{end}}
<button onclick="openPatientsModal()"
class="text-xs font-medium text-brand-500 hover:text-brand-700 transition-colors">
Lihat selengkapnya
</button>
</div>
</div>
{{if .Rows}}
<ul class="divide-y divide-slate-50 px-3 py-2">
{{range .Rows}}
<li class="flex items-center gap-3 rounded-xl px-2 py-2.5 transition-colors hover:bg-slate-50">
<div class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-brand-50 text-xs font-bold text-brand-600">
{{.Name | initials}}
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-slate-800">{{.Name}}</p>
<p class="text-xs text-slate-400">
<span class="num">{{fmtDateTime .Date .InTime}}</span> &bull; {{.Station | stationShort}}
</p>
</div>
</li>
{{end}}
</ul>
{{else}}
<div class="px-5 py-8 text-center text-sm text-slate-400">
Belum ada arrival pada tanggal ini.
</div>
{{end}}
{{end}}

View File

@@ -0,0 +1,43 @@
{{define "kpi"}}
<!-- Total Staff -->
<article class="card border-l-4 border-l-brand-300 p-4">
<div class="flex items-start justify-between">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Total Staff</p>
<svg class="h-4 w-4 text-brand-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</div>
<p class="num mt-3 text-3xl font-semibold text-slate-900">{{.TotalStaff}}</p>
{{if gt .InvitedStaff 0}}
<p class="mt-1 text-xs font-medium text-emerald-600">{{printf "%.1f%%" (pct .TotalStaff .InvitedStaff)}} dari invited</p>
{{else}}
<p class="mt-1 text-xs text-slate-400">Yang benar-benar datang</p>
{{end}}
</article>
<!-- Checked In -->
<article class="card border-l-4 border-l-brand-500 p-4">
<div class="flex items-start justify-between">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">In Progress</p>
<svg class="h-4 w-4 text-brand-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<p class="num mt-3 text-3xl font-semibold text-slate-900">{{.CheckedIn}}</p>
<p class="mt-1 text-xs text-slate-400">Masih dalam proses</p>
</article>
<!-- Checked Out -->
<article class="card border-l-4 border-l-emerald-500 p-4">
<div class="flex items-start justify-between">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Checked Out</p>
<svg class="h-4 w-4 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
</svg>
</div>
<p class="num mt-3 text-3xl font-semibold text-slate-900">{{.CheckedOut}}</p>
{{if gt .CheckedIn 0}}
<p class="mt-1 text-xs text-slate-400">{{printf "%.1f%%" (pct .CheckedOut .CheckedIn)}} selesai</p>
{{end}}
</article>
{{end}}

View File

@@ -0,0 +1,80 @@
{{define "patients"}}
{{if not .Patients}}
<div class="flex flex-col items-center justify-center py-16 text-slate-400">
<svg class="mb-3 h-10 w-10 text-slate-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
</svg>
<p class="text-sm font-medium">Belum ada data pasien pada tanggal ini.</p>
</div>
{{else}}
<div class="divide-y divide-slate-100">
{{range .Patients}}
{{if and (gt .DoneCount 0) (eq .DoneCount (len .Stations))}}
<div class="px-5 py-4 bg-emerald-50/60 transition-colors hover:bg-emerald-50">
{{else}}
<div class="px-5 py-4 hover:bg-slate-50 transition-colors">
{{end}}
<!-- Row atas: nama + waktu -->
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2.5">
<div class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-brand-50 text-xs font-bold text-brand-600">
{{.Name | initials}}
</div>
<div>
<p class="font-semibold text-slate-800">{{.Name}}</p>
{{if $.IsRange}}<p class="text-xs text-slate-400 num">{{.Date | fmtDate}}</p>{{end}}
</div>
</div>
<div class="flex items-center gap-3 text-xs">
<span class="num text-slate-500">
Masuk: <span class="font-semibold text-slate-700">{{.InTime}}</span>
</span>
{{if .HasOut}}
<span class="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2.5 py-1 font-semibold text-emerald-700 num">
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
Keluar: {{.OutTime}}
</span>
{{else}}
<span class="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2.5 py-1 font-semibold text-amber-600">
<span class="h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse"></span>
Dalam proses
</span>
{{end}}
<span class="text-slate-400">
<span class="font-semibold text-brand-600">{{.DoneCount}}</span>/{{len .Stations}} station selesai
</span>
</div>
</div>
<!-- Row bawah: station badges -->
{{if .Stations}}
<div class="mt-2.5 flex flex-wrap gap-1.5 pl-10">
{{range .Stations}}
{{if .Done}}
<span class="inline-flex max-w-full flex-col gap-0.5 rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-medium text-emerald-800 cursor-default">
<span class="inline-flex items-center gap-1">
<svg class="h-3 w-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
{{.Station}}
</span>
<span class="num pl-4 text-[10px] font-semibold text-emerald-700/80">
Proses: {{if .ProcessAt}}{{.ProcessAt}}{{else}}-{{end}} | Selesai: {{if .DoneAt}}{{.DoneAt}}{{else}}-{{end}}
</span>
</span>
{{else}}
<span class="inline-flex max-w-full flex-col gap-0.5 rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-500 cursor-default">
<span class="inline-flex items-center gap-1">
<svg class="h-3 w-3 flex-shrink-0 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{{.Station}}
</span>
<span class="num pl-4 text-[10px] font-semibold text-slate-400">
Proses: {{if .ProcessAt}}{{.ProcessAt}}{{else}}-{{end}} | Selesai: {{if .DoneAt}}{{.DoneAt}}{{else}}-{{end}}
</span>
</span>
{{end}}
{{end}}
</div>
{{end}}
</div>
{{end}}
</div>
{{end}}
{{end}}

View File

@@ -0,0 +1,53 @@
{{define "stations"}}
<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>
{{if .IsLive}}
<span class="flex items-center gap-1.5 text-xs font-medium text-emerald-600">
<span class="live-dot h-1.5 w-1.5 rounded-full bg-emerald-500"></span> Live
</span>
{{end}}
</div>
{{if .Rows}}
<div class="p-3">
<table class="min-w-full text-sm">
<thead>
<tr class="text-left text-xs font-semibold uppercase tracking-wide text-slate-400">
<th class="px-3 py-2">Station</th>
<th class="px-3 py-2 text-right">Sudah</th>
<th class="px-3 py-2 text-right">Belum</th>
<th class="px-3 py-2 text-right">Total</th>
<th class="px-3 py-2 w-40">Progress</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
{{range .Rows}}
<tr class="hover:bg-slate-50 transition-colors">
<td class="px-3 py-2.5 font-medium text-slate-700">
{{.Station | stationShort}}
</td>
<td class="num px-3 py-2.5 text-right font-semibold text-slate-900">{{.Processed}}</td>
<td class="num px-3 py-2.5 text-right text-amber-600">{{.Pending}}</td>
<td class="num px-3 py-2.5 text-right text-slate-400">{{.Total}}</td>
<td class="px-3 py-2.5">
<div class="flex items-center gap-2">
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-100">
<div class="h-full rounded-full transition-all"
style="width: {{printf "%.1f" .Pct}}%; background: {{if ge .Pct 90.0}}#10b981{{else if ge .Pct 60.0}}#3b50a0{{else}}#f59e0b{{end}}">
</div>
</div>
<span class="num text-xs font-semibold {{if ge .Pct 90.0}}text-emerald-600{{else if ge .Pct 60.0}}text-brand-600{{else}}text-amber-600{{end}}">
{{printf "%.0f%%" .Pct}}
</span>
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="px-5 py-8 text-center text-sm text-slate-400">
Belum ada data station pada tanggal ini.
</div>
{{end}}
{{end}}

View File

@@ -0,0 +1,90 @@
{{define "base"}}
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{{block "title" .}}CpOne Dashboard{{end}}</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=Plus+Jakarta+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500;600&display=swap" rel="stylesheet"/>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<script src="https://unpkg.com/htmx.org@1.9.12/dist/ext/sse.js"></script>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Plus Jakarta Sans', 'sans-serif'],
mono: ['IBM Plex Mono', 'monospace'],
},
colors: {
brand: {
50: '#eef0fb',
100: '#dde2f7',
200: '#bbc5ef',
300: '#8f9fe4',
400: '#6677d6',
500: '#3b50a0',
600: '#2d3d7a',
700: '#212d5a',
800: '#161e3c',
900: '#0b0f1e',
}
}
}
}
}
</script>
<style>
body { font-family: 'Plus Jakarta Sans', sans-serif; }
.num { font-family: 'IBM Plex Mono', monospace; }
.live-dot { animation: pulse-dot 2s infinite; }
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.card { @apply rounded-2xl border border-slate-200 bg-white shadow-sm transition-shadow hover:shadow-md; }
</style>
<link rel="stylesheet" href="{{b "/static/css/custom.css"}}"/>
</head>
<body class="min-h-screen bg-slate-100 text-slate-800">
<!-- Header -->
<header class="bg-brand-500 text-white">
<div class="mx-auto flex w-full max-w-7xl items-center justify-between px-4 py-3 sm:px-6 lg:px-8">
<div class="flex items-center gap-4">
<a href="{{b "/dashboard"}}" class="shrink-0 rounded-lg bg-white px-3 py-1.5">
<img src="{{b "/static/img/logo.png"}}" alt="Logo" class="h-8 w-auto">
</a>
<div>
<p class="text-sm font-semibold leading-tight">{{block "header-title" .}}Dashboard{{end}}</p>
</div>
</div>
<div class="hidden items-center gap-1 text-sm sm:flex">
<nav class="flex items-center gap-1">
<a href="{{b "/dashboard"}}" class="rounded-lg px-3 py-1.5 font-medium transition hover:bg-white/15">Dashboard</a>
<a href="{{b "/arrival"}}" class="rounded-lg px-3 py-1.5 font-medium transition hover:bg-white/15">Arrival</a>
<a href="{{b "/progress"}}" class="rounded-lg px-3 py-1.5 font-medium transition hover:bg-white/15">Progress</a>
<a href="{{b "/abnormal"}}" class="rounded-lg px-3 py-1.5 font-medium transition hover:bg-white/15">Abnormal</a>
<a href="{{b "/result"}}" class="rounded-lg px-3 py-1.5 font-medium transition hover:bg-white/15">Result</a>
</nav>
{{if .Username}}
<div class="ml-3 flex items-center gap-2 border-l border-white/20 pl-3">
<a href="{{b "/password"}}" class="rounded-full bg-white/15 px-3 py-1 text-xs font-semibold tracking-wide transition hover:bg-white/25">{{.Username}}</a>
<a href="{{b "/logout"}}" class="rounded-lg px-3 py-1.5 font-medium opacity-75 transition hover:bg-white/15 hover:opacity-100">Logout</a>
</div>
{{end}}
</div>
</div>
</header>
<main class="mx-auto w-full max-w-7xl space-y-5 px-4 py-5 sm:px-6 lg:px-8">
{{block "content" .}}{{end}}
</main>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,129 @@
{{define "login"}}
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Login — CpOne Dashboard</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=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet"/>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['Plus Jakarta Sans', 'sans-serif'] },
colors: {
brand: {
50: '#eef0fb',
100: '#dde2f7',
200: '#bbc5ef',
300: '#8f9fe4',
400: '#6677d6',
500: '#3b50a0',
600: '#2d3d7a',
700: '#212d5a',
800: '#161e3c',
900: '#0b0f1e',
}
}
}
}
}
</script>
</head>
<body class="min-h-screen bg-slate-100 font-sans text-slate-800">
<main class="grid min-h-screen lg:grid-cols-2">
<!-- Left panel -->
<section class="relative hidden overflow-hidden lg:flex lg:flex-col lg:justify-between lg:p-12">
<div class="absolute inset-0 bg-gradient-to-br from-brand-800 via-brand-700 to-brand-500"></div>
<div class="absolute -left-24 -top-16 h-80 w-80 rounded-full bg-white/5 blur-3xl"></div>
<div class="absolute -bottom-20 right-0 h-96 w-96 rounded-full bg-white/5 blur-3xl"></div>
<div class="relative z-10">
<div class="inline-flex rounded-lg bg-white px-4 py-2">
<img src="{{b "/static/img/logo.png"}}" alt="Logo" class="h-8 w-auto">
</div>
</div>
<div class="relative z-10 space-y-4 text-white">
<p class="inline-flex rounded-full border border-white/25 bg-white/10 px-4 py-1.5 text-sm backdrop-blur">
Corporate MCU Platform
</p>
<h1 class="max-w-md text-3xl font-semibold leading-snug">
Monitor Arrival, Sampling, dan Lab Verification dalam Satu Tempat
</h1>
<p class="max-w-sm text-sm text-brand-200">
Real-time dashboard untuk visibilitas operasional MCU perusahaan.
</p>
</div>
</section>
<!-- Right panel -->
<section class="flex items-center justify-center p-6 sm:p-10">
<div class="w-full max-w-md">
<!-- Logo mobile -->
<div class="mb-8 flex justify-center lg:hidden">
<div class="rounded-xl bg-brand-500 px-5 py-3">
<img src="{{b "/static/img/logo.png"}}" alt="Logo" class="h-10 w-auto brightness-0 invert">
</div>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-7 shadow-sm sm:p-8">
<div class="mb-7 space-y-1">
<h2 class="text-xl font-semibold">Masuk ke akun Anda</h2>
<p class="text-sm text-slate-500">Gunakan username dan password yang terdaftar.</p>
</div>
{{if .Error}}
<div class="mb-5 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{.Error}}
</div>
{{end}}
<form method="POST" action="{{b "/mcu-login"}}" class="space-y-5">
<div class="space-y-1.5">
<label for="username" class="block text-sm font-medium">Username</label>
<input
id="username"
name="username"
type="text"
required
autocomplete="username"
placeholder="Masukkan username"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
/>
</div>
<div class="space-y-1.5">
<label for="password" class="block text-sm font-medium">Password</label>
<input
id="password"
name="password"
type="password"
required
autocomplete="current-password"
placeholder="Masukkan password"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
/>
</div>
<button
type="submit"
class="w-full rounded-xl bg-brand-500 px-5 py-3 text-sm font-semibold text-white transition hover:bg-brand-600 active:bg-brand-700"
>
Masuk
</button>
</form>
</div>
<p class="mt-5 text-center text-xs text-slate-400">CpOne Dashboard &mdash; Laboratorium &amp; Klinik Westerindo</p>
</div>
</section>
</main>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,196 @@
{{define "title"}}Result Progress — CpOne{{end}}
{{define "header-title"}}Result Progress{{end}}
{{define "content"}}
{{$proj := .CurrentProject}}
<section class="card p-5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-brand-500">Ongoing Project</p>
<h2 class="mt-1 text-lg font-semibold text-slate-900">
{{if $proj.Label}}{{$proj.Label}}{{else}}MCU #{{$proj.McuID}}{{end}}
</h2>
<p class="mt-0.5 text-sm text-slate-500">
{{$proj.Number}} &bull; {{$proj.CorporateName}} &bull;
<span class="num">{{$proj.StartDate | fmtDate}}</span> &ndash; <span class="num">{{$proj.EndDate | fmtDate}}</span>
</p>
</div>
<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>
</div>
</section>
<section class="grid gap-4 sm:grid-cols-3">
<article class="card p-5">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Total Patients</p>
<p class="num mt-2 text-3xl font-semibold text-slate-900">{{.Summary.Total}}</p>
<p class="mt-1 text-xs text-slate-400">Peserta dalam project ini</p>
</article>
<article class="card p-5">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Validated</p>
<p class="num mt-2 text-3xl font-semibold text-emerald-600">{{.Summary.Validated}}</p>
<p class="mt-1 text-xs text-slate-400">Resume sudah divalidasi dokter</p>
</article>
<article class="card p-5">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Published</p>
<p class="num mt-2 text-3xl font-semibold text-brand-500">{{.Summary.Published}}</p>
<p class="mt-1 text-xs text-slate-400">Hasil sudah dikirim ke peserta</p>
</article>
</section>
<section class="card p-5">
<h2 class="mb-4 text-sm font-semibold text-slate-700">Resume Progress</h2>
<div class="space-y-4">
<div>
<div class="mb-1.5 flex items-center justify-between text-sm">
<span class="font-medium text-slate-700">Validated</span>
<span class="text-xs text-slate-400">
{{.Summary.Validated}} / {{.Summary.Total}}
<span class="ml-1 font-semibold text-emerald-600">{{.ValidatedPct}}%</span>
</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-slate-100">
<div class="h-2 rounded-full bg-emerald-500 transition-all" style="width: {{.ValidatedPct}}%"></div>
</div>
</div>
<div>
<div class="mb-1.5 flex items-center justify-between text-sm">
<span class="font-medium text-slate-700">Published</span>
<span class="text-xs text-slate-400">
{{.Summary.Published}} / {{.Summary.Total}}
<span class="ml-1 font-semibold text-brand-500">{{.PublishedPct}}%</span>
</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-slate-100">
<div class="h-2 rounded-full bg-brand-500 transition-all" style="width: {{.PublishedPct}}%"></div>
</div>
</div>
</div>
</section>
<section class="card p-4">
<form method="get" action="{{b "/progress"}}" class="grid gap-3 md:grid-cols-3">
<div class="md:col-span-2">
<label for="search" class="mb-2 block text-sm font-medium text-slate-600">Search Patient</label>
<input id="search" name="search" value="{{.Search}}" type="text" placeholder="Nama atau Employee ID"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200"/>
</div>
<div>
<label for="status" class="mb-2 block text-sm font-medium text-slate-600">Status</label>
<select id="status" name="status"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200">
<option value="" {{if eq .Status ""}}selected{{end}}>All</option>
<option value="validated" {{if eq .Status "validated"}}selected{{end}}>Validated</option>
<option value="published" {{if eq .Status "published"}}selected{{end}}>Published</option>
<option value="not_validated" {{if eq .Status "not_validated"}}selected{{end}}>Not Validated</option>
<option value="not_published" {{if eq .Status "not_published"}}selected{{end}}>Not Published</option>
</select>
</div>
<div class="md:col-span-3 flex justify-end">
<button type="submit" class="rounded-xl bg-brand-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-600">
Filter
</button>
</div>
</form>
</section>
<section class="card overflow-hidden">
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3">
<div>
<h2 class="text-base font-semibold text-slate-700">Patient Resume List</h2>
<p class="text-xs text-slate-400">Data dari mcu_patient_resume_status</p>
</div>
<span class="text-xs font-medium text-slate-400">{{len .FilteredRows}} ditampilkan</span>
</div>
<div class="border-b border-slate-100 px-5 py-3">
<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 font-medium text-emerald-700">Validated</span>
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 font-medium text-slate-600">Not Validated</span>
<span class="rounded-full border border-brand-400/40 bg-brand-50 px-2 py-1 font-medium text-brand-500">Published</span>
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 font-medium text-slate-600">Not Published</span>
</div>
</div>
{{if .FilteredRows}}
<div class="hidden overflow-x-auto md:block">
<table class="min-w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500">
<tr>
<th class="px-4 py-3 font-medium">Name</th>
<th class="px-4 py-3 font-medium">NIP</th>
<th class="px-4 py-3 font-medium">Posisi</th>
<th class="px-4 py-3 font-medium">Resume Status</th>
<th class="px-4 py-3 font-medium">Validated</th>
<th class="px-4 py-3 font-medium">Published</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{{range .FilteredRows}}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3 font-medium text-slate-700">{{.Name}}</td>
<td class="px-4 py-3 num text-slate-500">{{.NIP}}</td>
<td class="px-4 py-3 text-slate-500">{{.Posisi}}</td>
<td class="px-4 py-3">
{{if .ResumeStatus}}
<span class="rounded-full border border-slate-200 bg-slate-50 px-2 py-1 text-xs font-medium text-slate-600">{{.ResumeStatus}}</span>
{{else}}
<span class="text-xs text-slate-400"></span>
{{end}}
</td>
<td class="px-4 py-3">
{{if eq .Validated "Y"}}
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">Y</span>
{{else}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-500">N</span>
{{end}}
</td>
<td class="px-4 py-3">
{{if eq .Published "Y"}}
<span class="rounded-full border border-brand-400/40 bg-brand-50 px-2 py-1 text-xs font-medium text-brand-500">Y</span>
{{else}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-500">N</span>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="grid gap-3 p-4 md:hidden">
{{range .FilteredRows}}
<article class="rounded-xl border border-slate-200 p-3">
<div class="flex items-start justify-between gap-3">
<div>
<p class="font-semibold text-slate-700">{{.Name}}</p>
<p class="mt-0.5 text-xs text-slate-400">{{.NIP}} &bull; {{.Posisi}}</p>
</div>
{{if .ResumeStatus}}
<span class="rounded-full border border-slate-200 bg-slate-50 px-2 py-1 text-xs font-medium text-slate-600">{{.ResumeStatus}}</span>
{{end}}
</div>
<div class="mt-2 flex gap-2">
{{if eq .Validated "Y"}}
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">Validated</span>
{{else}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-500">Not Validated</span>
{{end}}
{{if eq .Published "Y"}}
<span class="rounded-full border border-brand-400/40 bg-brand-50 px-2 py-1 text-xs font-medium text-brand-500">Published</span>
{{else}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-500">Not Published</span>
{{end}}
</div>
</article>
{{end}}
</div>
{{else}}
<div class="px-5 py-10 text-center text-sm text-slate-400">
Belum ada data resume untuk project ini.
</div>
{{end}}
</section>
{{end}}

View File

@@ -0,0 +1,119 @@
{{define "projects"}}
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Pilih Project — CpOne Dashboard</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=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet"/>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['Plus Jakarta Sans', 'sans-serif'] },
colors: {
brand: {
50: '#eef0fb',
100: '#dde2f7',
200: '#bbc5ef',
300: '#8f9fe4',
400: '#6677d6',
500: '#3b50a0',
600: '#2d3d7a',
700: '#212d5a',
800: '#161e3c',
900: '#0b0f1e',
}
}
}
}
}
</script>
</head>
<body class="min-h-screen bg-slate-100 font-sans text-slate-800">
<!-- Header -->
<header class="bg-brand-500 text-white">
<div class="mx-auto flex w-full max-w-7xl items-center justify-between px-4 py-3 sm:px-6 lg:px-8">
<a href="{{b "/projects"}}" class="shrink-0 rounded-lg bg-white px-3 py-1.5">
<img src="{{b "/static/img/logo.png"}}" alt="Logo" class="h-8 w-auto">
</a>
<div class="flex items-center gap-2 text-sm">
<span class="rounded-full bg-white/15 px-3 py-1 text-xs font-semibold tracking-wide">{{.Username}}</span>
<a href="{{b "/logout"}}" class="rounded-lg px-3 py-1.5 font-medium opacity-75 transition hover:bg-white/15 hover:opacity-100">Logout</a>
</div>
</div>
</header>
<main class="mx-auto w-full max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div class="mb-6">
<h1 class="text-xl font-semibold text-slate-800">Pilih Project</h1>
<p class="mt-1 text-sm text-slate-500">Pilih salah satu project MCU untuk mulai monitoring.</p>
</div>
{{if .CurrentProject}}
<div class="mb-6 rounded-2xl border border-brand-100 bg-brand-50 px-4 py-3 text-sm text-brand-700">
Project aktif saat ini:
<span class="font-semibold">{{if .CurrentProject.Label}}{{.CurrentProject.Label}}{{else}}MCU #{{.CurrentProject.McuID}}{{end}}</span>
<span class="text-brand-500"></span>
<a href="{{b "/dashboard"}}" class="font-semibold underline decoration-brand-200 underline-offset-2">Buka dashboard</a>
</div>
{{end}}
{{if not .Projects}}
<div class="flex flex-col items-center justify-center rounded-2xl border border-dashed border-slate-300 bg-white py-16 text-center">
<svg class="mb-3 h-10 w-10 text-slate-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776"/>
</svg>
<p class="text-sm font-medium text-slate-500">Belum ada project yang di-assign</p>
<p class="mt-1 text-xs text-slate-400">Hubungi administrator untuk mendapatkan akses.</p>
</div>
{{else}}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{{range .Projects}}
<a href="{{b "/projects/select"}}?mcu_id={{.McuID}}"
class="group flex flex-col rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition hover:border-brand-400 hover:shadow-md">
<!-- Company + badge -->
<div class="flex items-start justify-between gap-2">
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-brand-400">{{.Number}}</p>
<p class="mt-0.5 font-semibold leading-snug text-slate-800">{{.CorporateName}}</p>
</div>
<span class="shrink-0 rounded-full bg-brand-50 px-2.5 py-0.5 text-xs font-semibold text-brand-600">
MCU #{{.McuID}}
</span>
</div>
<!-- Label -->
{{if .Label}}
<p class="mt-2 text-sm text-slate-500">{{.Label}}</p>
{{end}}
<!-- Footer info -->
<div class="mt-4 flex items-center justify-between border-t border-slate-100 pt-3 text-xs text-slate-400">
<span>{{.StartDate}}{{if .EndDate}} {{.EndDate}}{{end}}</span>
<span class="font-medium text-slate-500">{{.TotalParticipant}} peserta</span>
</div>
<!-- Hover cta -->
<div class="mt-3 flex items-center gap-1 text-xs font-semibold text-brand-500 opacity-0 transition group-hover:opacity-100">
Buka Dashboard
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"/>
</svg>
</div>
</a>
{{end}}
</div>
{{end}}
</main>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,185 @@
{{define "title"}}Result Reports — CpOne{{end}}
{{define "header-title"}}Consolidated Result Reports{{end}}
{{define "content"}}
{{$proj := .CurrentProject}}
{{/* Section 1: Current project */}}
<section class="card p-5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-brand-500">Ongoing Project</p>
<h2 class="mt-1 text-lg font-semibold text-slate-900">
{{if $proj.Label}}{{$proj.Label}}{{else}}MCU #{{$proj.McuID}}{{end}}
</h2>
<p class="mt-0.5 text-sm text-slate-500">
{{$proj.Number}} &bull; {{$proj.CorporateName}} &bull;
<span class="num">{{$proj.StartDate | fmtDate}}</span> &ndash; <span class="num">{{$proj.EndDate | fmtDate}}</span>
</p>
</div>
<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>
</div>
</section>
{{/* Section 2: Summary cards */}}
<section class="grid gap-4 sm:grid-cols-2">
<article class="card p-5">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Total Patients</p>
<p class="num mt-2 text-3xl font-semibold text-slate-900">{{.Summary.Total}}</p>
<p class="mt-1 text-xs text-slate-400">Peserta dalam project ini</p>
</article>
<article class="card p-5">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Has PDF</p>
<p class="num mt-2 text-3xl font-semibold text-brand-500">{{.Summary.HasPDF}}</p>
<p class="mt-1 text-xs text-slate-400">Laporan hasil sudah tersedia</p>
</article>
</section>
{{/* Section 3: Filter form */}}
<section class="card p-4">
<form method="get" action="{{b "/result"}}" class="grid gap-3 md:grid-cols-3">
<div class="md:col-span-2">
<label for="search" class="mb-2 block text-sm font-medium text-slate-600">Search Patient</label>
<input id="search" name="search" value="{{.Search}}" type="text" placeholder="Nama atau Employee ID"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200"/>
</div>
<div>
<label for="filter" class="mb-2 block text-sm font-medium text-slate-600">Status PDF</label>
<select id="filter" name="filter"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200">
<option value="" {{if eq .Filter ""}}selected{{end}}>All</option>
<option value="has_pdf" {{if eq .Filter "has_pdf"}}selected{{end}}>Has PDF</option>
<option value="no_pdf" {{if eq .Filter "no_pdf"}}selected{{end}}>No PDF</option>
</select>
</div>
<div class="md:col-span-3 flex justify-end">
<button type="submit" class="rounded-xl bg-brand-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-600">
Filter
</button>
</div>
</form>
</section>
{{/* Section 4: Patient list */}}
<section class="card overflow-hidden">
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3">
<div>
<h2 class="text-base font-semibold text-slate-700">Patient Result List</h2>
<p class="text-xs text-slate-400">Data dari published_mcu_dashboard_sync</p>
</div>
<span class="text-xs font-medium text-slate-400">{{len .FilteredRows}} ditampilkan</span>
</div>
{{if .FilteredRows}}
<div class="hidden overflow-x-auto md:block">
<table class="min-w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500">
<tr>
<th class="px-4 py-3 font-medium">Employee ID</th>
<th class="px-4 py-3 font-medium">Patient</th>
<th class="px-4 py-3 font-medium">Department</th>
<th class="px-4 py-3 font-medium">Report Date</th>
<th class="px-4 py-3 font-medium">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{{range .FilteredRows}}
<tr class="hover:bg-slate-50">
<td class="num px-4 py-3 text-slate-500">{{.NIP}}</td>
<td class="px-4 py-3 font-medium text-slate-700">{{.Name}}</td>
<td class="px-4 py-3 text-slate-500">{{.Posisi}}</td>
<td class="num px-4 py-3 text-slate-500">
{{if .ReportDate}}{{.ReportDate | fmtDate}}{{else}}<span class="text-slate-300"></span>{{end}}
</td>
<td class="px-4 py-3">
{{if .FileUrl}}
<button onclick="openPDFModal('{{$.PDFBaseURL}}{{.FileUrl}}', '{{.Name}}')"
class="inline-flex items-center gap-1.5 rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-600">
View PDF
</button>
{{else}}
<span class="text-xs text-slate-300"></span>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="grid gap-3 p-4 md:hidden">
{{range .FilteredRows}}
<article class="rounded-xl border border-slate-200 p-3">
<div class="flex items-start justify-between gap-3">
<div>
<p class="font-semibold text-slate-700">{{.Name}}</p>
<p class="mt-0.5 text-xs text-slate-400">{{.NIP}} &bull; {{.Posisi}}</p>
{{if .ReportDate}}<p class="mt-0.5 num text-xs text-slate-400">{{.ReportDate | fmtDate}}</p>{{end}}
</div>
{{if .FileUrl}}
<button onclick="openPDFModal('{{$.PDFBaseURL}}{{.FileUrl}}', '{{.Name}}')"
class="shrink-0 rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-600">
View PDF
</button>
{{else}}
<span class="text-xs text-slate-300">No PDF</span>
{{end}}
</div>
</article>
{{end}}
</div>
{{else}}
<div class="px-5 py-10 text-center text-sm text-slate-400">
Belum ada data untuk project ini.
</div>
{{end}}
</section>
{{/* PDF Modal */}}
<div id="pdf-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
onclick="if(event.target===this)closePDFModal()">
<div class="flex h-full w-full max-w-5xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl">
<div class="flex shrink-0 items-center justify-between border-b border-slate-200 px-5 py-3">
<p id="pdf-modal-title" class="truncate text-sm font-semibold text-slate-700"></p>
<div class="ml-4 flex shrink-0 items-center gap-3">
<a id="pdf-modal-link" href="#" target="_blank"
class="text-xs font-medium text-brand-500 hover:underline">
Buka di tab baru ↗
</a>
<button onclick="closePDFModal()"
class="rounded-lg p-1.5 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
</div>
<iframe id="pdf-modal-frame" src="" class="min-h-0 flex-1 w-full" frameborder="0"></iframe>
</div>
</div>
<script>
function openPDFModal(url, name) {
document.getElementById('pdf-modal-frame').src = url;
document.getElementById('pdf-modal-title').textContent = name;
document.getElementById('pdf-modal-link').href = url;
const modal = document.getElementById('pdf-modal');
modal.classList.remove('hidden');
modal.classList.add('flex');
document.body.style.overflow = 'hidden';
}
function closePDFModal() {
const modal = document.getElementById('pdf-modal');
modal.classList.add('hidden');
modal.classList.remove('flex');
document.getElementById('pdf-modal-frame').src = '';
document.body.style.overflow = '';
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closePDFModal();
});
</script>
{{end}}