1978 lines
74 KiB
JavaScript
1978 lines
74 KiB
JavaScript
import http from "node:http";
|
||
import { readFile } from "node:fs/promises";
|
||
import { extname } from "node:path";
|
||
|
||
const PORT = Number(process.env.PORT || 5173);
|
||
const API_BASE =
|
||
process.env.DOCLINK_API_BASE ||
|
||
"https://devbandungraya.aplikasi.web.id/one-api-doctor/doctor_mitra";
|
||
|
||
const sessionKey = "doclink_session";
|
||
|
||
const mockUser = {
|
||
name: "dr. Fajri",
|
||
role: "Dokter Mitra",
|
||
hospital: "Pramita Bandungraya",
|
||
doctorId: "DR-10024",
|
||
};
|
||
|
||
const mockOrders = [
|
||
{
|
||
id: "ORD-24001",
|
||
patient: "Siti Amelia",
|
||
doctor: "dr. Fajri",
|
||
updated: "2m ago",
|
||
status: "Processing",
|
||
tone: "warning",
|
||
mode: "Inpatient",
|
||
age: "34",
|
||
gender: "F",
|
||
tests: ["Hematology", "Glucose", "Urine"],
|
||
diagnosis: "Check-up rutin",
|
||
message: "Prioritize fasting sample and note dizziness symptom.",
|
||
},
|
||
{
|
||
id: "ORD-24002",
|
||
patient: "Budi Santoso",
|
||
doctor: "dr. Fajri",
|
||
updated: "14m ago",
|
||
status: "Ready",
|
||
tone: "success",
|
||
mode: "Outpatient",
|
||
age: "52",
|
||
gender: "M",
|
||
tests: ["Lipid Profile", "Liver Function"],
|
||
diagnosis: "Kontrol hipertensi",
|
||
message: "Call if LDL exceeds threshold.",
|
||
},
|
||
{
|
||
id: "ORD-24003",
|
||
patient: "Nia Putri",
|
||
doctor: "dr. Fajri",
|
||
updated: "40m ago",
|
||
status: "Needs review",
|
||
tone: "danger",
|
||
mode: "Emergency",
|
||
age: "27",
|
||
gender: "F",
|
||
tests: ["Electrolytes", "CBC", "CRP"],
|
||
diagnosis: "Dehydration",
|
||
message: "Urgent release once CRP is complete.",
|
||
},
|
||
];
|
||
|
||
const mockResults = [
|
||
{
|
||
id: "RES-8821",
|
||
patient: "Siti Amelia",
|
||
test: "CBC",
|
||
status: "Released",
|
||
tone: "success",
|
||
date: "13 Apr 2026",
|
||
summary: "Hemoglobin slightly below baseline.",
|
||
value: "11.2 g/dL",
|
||
},
|
||
{
|
||
id: "RES-8822",
|
||
patient: "Budi Santoso",
|
||
test: "Lipid Profile",
|
||
status: "Pending",
|
||
tone: "warning",
|
||
date: "13 Apr 2026",
|
||
summary: "Awaiting final approval.",
|
||
value: "LDL 145 mg/dL",
|
||
},
|
||
{
|
||
id: "RES-8823",
|
||
patient: "Nia Putri",
|
||
test: "CRP",
|
||
status: "Review",
|
||
tone: "danger",
|
||
date: "12 Apr 2026",
|
||
summary: "Result flagged for clinical review.",
|
||
value: "18.4 mg/L",
|
||
},
|
||
];
|
||
|
||
const mockFppGroups = [
|
||
{ group: "HEMATOLOGI", count: 6, desc: "Complete blood count and related panels." },
|
||
{ group: "KLINIK RUTIN", count: 5, desc: "Daily screening and basic chemistry." },
|
||
{ group: "IMUNOLOGI", count: 3, desc: "Inflammation and antibody markers." },
|
||
{ group: "URINALISA", count: 4, desc: "Urine screening and microscopic checks." },
|
||
];
|
||
|
||
const mockPatients = [
|
||
{ name: "Siti Amelia", mrn: "MRN-10011", gender: "Female", lastVisit: "Today", note: "Active order" },
|
||
{ name: "Budi Santoso", mrn: "MRN-10012", gender: "Male", lastVisit: "Yesterday", note: "Repeat visit" },
|
||
{ name: "Nia Putri", mrn: "MRN-10013", gender: "Female", lastVisit: "12 Apr", note: "New registration" },
|
||
];
|
||
|
||
function escapeHtml(value) {
|
||
return String(value)
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """);
|
||
}
|
||
|
||
function statusClass(status) {
|
||
const mapping = {
|
||
Processing: "warning",
|
||
Ready: "success",
|
||
"Needs review": "danger",
|
||
Released: "success",
|
||
Pending: "warning",
|
||
Review: "danger",
|
||
};
|
||
return mapping[status] || "neutral";
|
||
}
|
||
|
||
function statusBadge(text) {
|
||
return `<span class="status ${statusClass(text)}">${escapeHtml(text)}</span>`;
|
||
}
|
||
|
||
function icon(name) {
|
||
const map = {
|
||
search:
|
||
'<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"></circle><path d="M20 20l-3.5-3.5"></path></svg>',
|
||
plus:
|
||
'<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"></path></svg>',
|
||
bell:
|
||
'<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 17H9m8-4V9a5 5 0 10-10 0v4l-2 3h14l-2-3Z"></path></svg>',
|
||
arrow:
|
||
'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14m-6-6 6 6-6 6"></path></svg>',
|
||
login:
|
||
'<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 17l5-5-5-5"></path><path d="M15 12H3"></path><path d="M21 3v18"></path></svg>',
|
||
};
|
||
return map[name] || "";
|
||
}
|
||
|
||
function layout(title, body, { authenticated = false, activePath = "/", subtitle = "", shell = true } = {}) {
|
||
const nav = authenticated ? desktopNav(activePath) : "";
|
||
const mobile = authenticated ? mobileNav(activePath) : "";
|
||
const header = authenticated ? topbar(activePath, subtitle) : "";
|
||
return `<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>${escapeHtml(title)}</title>
|
||
<meta name="description" content="DocLink web rebuild" />
|
||
<link rel="stylesheet" href="/styles.css" />
|
||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||
</head>
|
||
<body>
|
||
<div class="bg-orb orb-one"></div>
|
||
<div class="bg-orb orb-two"></div>
|
||
<div class="bg-grid"></div>
|
||
<div id="app">
|
||
${shell && authenticated ? `
|
||
<div class="page-shell">
|
||
${nav}
|
||
<main class="content">
|
||
<div class="content-inner">
|
||
${header}
|
||
${body}
|
||
</div>
|
||
</main>
|
||
${mobile}
|
||
</div>
|
||
` : body}
|
||
</div>
|
||
<div id="modal-root"></div>
|
||
<script>
|
||
(function () {
|
||
function closeModal() {
|
||
var modalRoot = document.getElementById('modal-root');
|
||
if (modalRoot) modalRoot.innerHTML = '';
|
||
}
|
||
document.addEventListener('click', function (event) {
|
||
var close = event.target.closest && event.target.closest('[data-modal-close]');
|
||
if (!close) return;
|
||
event.preventDefault();
|
||
closeModal();
|
||
});
|
||
document.addEventListener('keydown', function (event) {
|
||
if (event.key === 'Escape') closeModal();
|
||
});
|
||
document.addEventListener('htmx:afterSwap', function (event) {
|
||
if (event.detail && event.detail.target && event.detail.target.id === 'modal-root') {
|
||
var root = document.getElementById('modal-root');
|
||
if (root) root.scrollTop = 0;
|
||
}
|
||
});
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
function topbar(activePath, subtitle) {
|
||
const titleMap = {
|
||
"/": ["Dashboard", "Overview of orders, results, and work queues."],
|
||
"/orders": ["Orders", "Search, review, and create patient orders."],
|
||
"/results": ["Results", "Monitor released and pending laboratory results."],
|
||
"/fpp": ["FPP", "Browse grouped reference packages and filters."],
|
||
"/patients": ["Patients", "Landing for patient registration and lookup."],
|
||
"/settings": ["Settings", "Account profile and password management."],
|
||
};
|
||
const [title, fallbackSubtitle] = titleMap[activePath] || ["DocLink Web", "Responsive clinical workflow shell."];
|
||
const searchForm = activePath === "/orders"
|
||
? `
|
||
<form class="search" hx-get="/fragments/orders/table" hx-target="#orders-fragment" hx-push-url="true">
|
||
${icon("search")}
|
||
<input name="search" type="search" placeholder="Search patients, orders, results" />
|
||
</form>
|
||
`
|
||
: activePath === "/results"
|
||
? `
|
||
<form class="search" hx-get="/fragments/results/table" hx-target="#results-fragment" hx-push-url="true">
|
||
${icon("search")}
|
||
<input name="search" type="search" placeholder="Search results" />
|
||
</form>
|
||
`
|
||
: `
|
||
<a class="btn btn-secondary" href="/orders">Search orders</a>
|
||
`;
|
||
return `
|
||
<header class="topbar">
|
||
<div class="topbar-main">
|
||
<div class="eyebrow">${escapeHtml(activePath === "/" ? "Clinical dashboard" : "Workflow view")}</div>
|
||
<h1 class="page-title">${escapeHtml(title)}</h1>
|
||
<p class="page-subtitle">${escapeHtml(subtitle || fallbackSubtitle)}</p>
|
||
</div>
|
||
<div class="topbar-actions">
|
||
${searchForm}
|
||
<button class="icon-btn" type="button" data-action="alert" title="Notifications">${icon("bell")}</button>
|
||
<a class="btn btn-primary" href="/orders/new">${icon("plus")} New order</a>
|
||
</div>
|
||
</header>
|
||
`;
|
||
}
|
||
|
||
function desktopNav(activePath) {
|
||
const items = [
|
||
["/", "Home", "Dashboard"],
|
||
["/orders", "Order", "Create & list"],
|
||
["/results", "Result", "History"],
|
||
["/fpp", "FPP", "Catalog"],
|
||
["/settings", "Akun", "Profile"],
|
||
];
|
||
const active = (href) => activePath === href || activePath.startsWith(`${href}/`);
|
||
return `
|
||
<aside class="sidebar">
|
||
<div class="sidebar-card">
|
||
<div class="brand">
|
||
<div class="brand-mark">DL</div>
|
||
<div class="brand-text">
|
||
<strong>DocLink Web</strong>
|
||
<span>${escapeHtml(mockUser.hospital)}</span>
|
||
</div>
|
||
</div>
|
||
<div class="nav-group">
|
||
<div class="nav-label">Workspace</div>
|
||
${items
|
||
.map(
|
||
([href, label, hint]) => `
|
||
<a class="nav-link ${active(href) ? "active" : ""}" href="${href}">
|
||
<span>${escapeHtml(label)}</span>
|
||
<small>${escapeHtml(hint)}</small>
|
||
</a>
|
||
`,
|
||
)
|
||
.join("")}
|
||
</div>
|
||
<div class="nav-group">
|
||
<div class="nav-label">Quick access</div>
|
||
<a class="nav-link ${activePath === "/settings/change-password" ? "active" : ""}" href="/settings/change-password">
|
||
<span>Change password</span>
|
||
<small>Security</small>
|
||
</a>
|
||
<a class="nav-link ${activePath === "/problem-login" ? "active" : ""}" href="/problem-login">
|
||
<span>Problem login</span>
|
||
<small>Fallback state</small>
|
||
</a>
|
||
</div>
|
||
<div class="sidebar-footer">
|
||
<strong>${escapeHtml(mockUser.name)}</strong>
|
||
<p>${escapeHtml(mockUser.role)} with access to orders, results, and FPP workflows.</p>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
`;
|
||
}
|
||
|
||
function mobileNav(activePath) {
|
||
const items = [
|
||
["/", "Home", "Dashboard"],
|
||
["/orders", "Order", "Create"],
|
||
["/results", "Result", "History"],
|
||
["/fpp", "FPP", "Catalog"],
|
||
["/settings", "Akun", "Profile"],
|
||
];
|
||
const active = (href) => activePath === href || activePath.startsWith(`${href}/`);
|
||
return `
|
||
<nav class="mobile-nav" aria-label="Primary">
|
||
<div class="mobile-nav-inner">
|
||
${items
|
||
.map(
|
||
([href, label, hint]) => `
|
||
<a class="nav-link ${active(href) ? "active" : ""}" href="${href}">
|
||
<span>${escapeHtml(label)}</span>
|
||
<small>${escapeHtml(hint)}</small>
|
||
</a>
|
||
`,
|
||
)
|
||
.join("")}
|
||
</div>
|
||
</nav>
|
||
`;
|
||
}
|
||
|
||
function panelHeader(title, text, action = "") {
|
||
return `
|
||
<div class="panel-header">
|
||
<div>
|
||
<h2>${escapeHtml(title)}</h2>
|
||
<p>${escapeHtml(text)}</p>
|
||
</div>
|
||
<div>${action}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function dashboardPage() {
|
||
const stats = [
|
||
{ label: "Orders today", value: "28", trend: "+12%", hint: "Vs yesterday" },
|
||
{ label: "Results pending", value: "7", trend: "-3%", hint: "Still waiting release" },
|
||
{ label: "FPP items", value: "14", trend: "+2", hint: "Active reference set" },
|
||
{ label: "Special messages", value: "5", trend: "+1", hint: "Need follow-up" },
|
||
];
|
||
const shortcuts = [
|
||
{ title: "New order", desc: "Start registration flow", href: "/orders/new" },
|
||
{ title: "Pending results", desc: "Review unreleased items", href: "/results/pending" },
|
||
{ title: "FPP catalog", desc: "Inspect lab groups", href: "/fpp" },
|
||
{ title: "Change password", desc: "Update account security", href: "/settings/change-password" },
|
||
];
|
||
return `
|
||
<div class="stack">
|
||
<section class="grid grid-4">
|
||
${stats
|
||
.map(
|
||
(item) => `
|
||
<article class="card metric">
|
||
<div class="kicker">
|
||
<span>${escapeHtml(item.label)}</span>
|
||
<span class="trend">${escapeHtml(item.trend)}</span>
|
||
</div>
|
||
<strong>${escapeHtml(item.value)}</strong>
|
||
<span class="muted">${escapeHtml(item.hint)}</span>
|
||
</article>
|
||
`,
|
||
)
|
||
.join("")}
|
||
</section>
|
||
<section class="panel">
|
||
${panelHeader("Quick actions", "Jump into the common flows without digging through nested screens.")}
|
||
<div class="grid grid-4">
|
||
${shortcuts
|
||
.map(
|
||
(item) => `
|
||
<a class="card" href="${item.href}">
|
||
<strong>${escapeHtml(item.title)}</strong>
|
||
<p class="muted">${escapeHtml(item.desc)}</p>
|
||
<span class="pill">${icon("arrow")} Open</span>
|
||
</a>
|
||
`,
|
||
)
|
||
.join("")}
|
||
</div>
|
||
</section>
|
||
<section class="detail-grid">
|
||
<div class="panel">
|
||
${panelHeader("Recent orders", "A compact snapshot of the latest patient orders and their state.", '<a class="btn btn-secondary" href="/orders">View all</a>')}
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr><th>Patient</th><th>Order</th><th>Status</th><th>Updated</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
${mockOrders
|
||
.map(
|
||
(order) => `
|
||
<tr>
|
||
<td><strong>${escapeHtml(order.patient)}</strong><br /><span class="muted">${escapeHtml(order.mode)}</span></td>
|
||
<td><a href="/orders/${order.id}">${escapeHtml(order.id)}</a><br /><span class="muted">${escapeHtml(order.diagnosis)}</span></td>
|
||
<td>${statusBadge(order.status)}</td>
|
||
<td>${escapeHtml(order.updated)}</td>
|
||
</tr>
|
||
`,
|
||
)
|
||
.join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div class="panel">
|
||
${panelHeader("Today’s notes", "Items that need attention now, not later.")}
|
||
<div class="mini-list">
|
||
${[
|
||
["FPP ready for release", "Review Hematology group before rounding."],
|
||
["Pending sample", "One fasting sample still waiting in the queue."],
|
||
["Password rotation", "Prompt user to refresh credentials after 90 days."],
|
||
]
|
||
.map(
|
||
([title, text]) => `
|
||
<div class="mini-item">
|
||
<div>
|
||
<strong>${escapeHtml(title)}</strong>
|
||
<p>${escapeHtml(text)}</p>
|
||
</div>
|
||
<span class="pill">${icon("arrow")}</span>
|
||
</div>
|
||
`,
|
||
)
|
||
.join("")}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderOrdersTable(orders, selectedOrderId, filter = "All") {
|
||
const selected = orders.find((item) => item.id === selectedOrderId) || orders[0] || mockOrders[0];
|
||
return `
|
||
<div class="detail-grid">
|
||
<section class="panel">
|
||
${panelHeader("Search orders", "Use the filter to match the old app flow without giving up desktop readability.", '<a class="btn btn-primary" href="/orders/new">Create order</a>')}
|
||
<form class="pill-row" hx-get="/fragments/orders/table" hx-target="#orders-fragment" hx-push-url="true">
|
||
<input type="hidden" name="status" value="${escapeHtml(filter)}" />
|
||
${["All", "Processing", "Ready", "Needs review"]
|
||
.map(
|
||
(item) => `
|
||
<button class="pill ${filter === item ? "active" : ""}" name="status" value="${escapeHtml(item)}" type="submit">${escapeHtml(item)}</button>
|
||
`,
|
||
)
|
||
.join("")}
|
||
</form>
|
||
<div id="orders-table" class="desktop-only table-wrap" style="margin-top:16px">
|
||
<table>
|
||
<thead>
|
||
<tr><th>Patient</th><th>Order ID</th><th>Doctor</th><th>Status</th><th>Updated</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
${orders
|
||
.map(
|
||
(order) => `
|
||
<tr>
|
||
<td><strong>${escapeHtml(order.patient)}</strong><br /><span class="muted">${escapeHtml(order.mode)}</span></td>
|
||
<td><a href="/orders/${order.id}">${escapeHtml(order.id)}</a></td>
|
||
<td>${escapeHtml(order.doctor)}</td>
|
||
<td>${statusBadge(order.status)}</td>
|
||
<td>${escapeHtml(order.updated)}</td>
|
||
</tr>
|
||
`,
|
||
)
|
||
.join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="mobile-only list" style="margin-top:16px">
|
||
${orders
|
||
.map(
|
||
(order) => `
|
||
<article class="card">
|
||
<div class="topbar-actions" style="justify-content:space-between">
|
||
<div>
|
||
<strong>${escapeHtml(order.patient)}</strong>
|
||
<p class="muted">${escapeHtml(order.id)} · ${escapeHtml(order.mode)}</p>
|
||
</div>
|
||
${statusBadge(order.status)}
|
||
</div>
|
||
<div class="pill-row" style="margin-top:12px">
|
||
<span class="pill">${escapeHtml(order.doctor)}</span>
|
||
<span class="pill">${escapeHtml(order.updated)}</span>
|
||
</div>
|
||
</article>
|
||
`,
|
||
)
|
||
.join("")}
|
||
</div>
|
||
</section>
|
||
<aside class="panel">
|
||
${panelHeader("Selected order", "Master-detail layout keeps context visible while reviewing the list.", `<a class="btn btn-secondary" href="/orders/${selected.id}">Open detail</a>`)}
|
||
<div class="stack">
|
||
<div class="card">
|
||
<strong>${escapeHtml(selected.patient)}</strong>
|
||
<p class="muted">${escapeHtml(selected.id)} · ${escapeHtml(selected.mode)}</p>
|
||
<div class="pill-row" style="margin-top:12px">
|
||
${statusBadge(selected.status)}
|
||
<span class="pill">${escapeHtml(selected.age)} years</span>
|
||
<span class="pill">${escapeHtml(selected.gender)}</span>
|
||
</div>
|
||
</div>
|
||
<div class="mini-list">
|
||
<div class="mini-item"><div><strong>Diagnosis</strong><p>${escapeHtml(selected.diagnosis)}</p></div></div>
|
||
<div class="mini-item"><div><strong>Requested tests</strong><p>${escapeHtml(selected.tests.join(", "))}</p></div></div>
|
||
<div class="mini-item"><div><strong>Special note</strong><p>${escapeHtml(selected.message)}</p></div></div>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderOrderDetail(order) {
|
||
return `
|
||
<div class="stack">
|
||
<section class="panel">
|
||
${panelHeader(
|
||
`${order.patient} · ${order.id}`,
|
||
"Order detail with the same structure as the proposed master-detail workflow.",
|
||
`<button class="btn btn-secondary" type="button" hx-get="/fragments/modals/pesan-khusus?order_id=${escapeHtml(order.id)}" hx-target="#modal-root" hx-swap="innerHTML">Pesan khusus</button>`,
|
||
)}
|
||
<div class="grid grid-3">
|
||
<div class="card"><span class="muted">Status</span><div style="margin-top:8px">${statusBadge(order.status)}</div></div>
|
||
<div class="card"><span class="muted">Patient</span><strong style="display:block; margin-top:8px">${escapeHtml(order.patient)}</strong><span class="muted">${escapeHtml(order.mode)} · ${escapeHtml(order.age)} years</span></div>
|
||
<div class="card"><span class="muted">Doctor</span><strong style="display:block; margin-top:8px">${escapeHtml(order.doctor)}</strong><span class="muted">${escapeHtml(mockUser.hospital)}</span></div>
|
||
</div>
|
||
</section>
|
||
<section class="detail-grid">
|
||
<div class="panel">
|
||
${panelHeader("Requested tests", "List/table treatment on desktop, cards on smaller screens.")}
|
||
<div class="grid grid-2">
|
||
${order.tests
|
||
.map(
|
||
(test) => `
|
||
<div class="card"><strong>${escapeHtml(test)}</strong><p class="muted">Included in the current order bundle.</p></div>
|
||
`,
|
||
)
|
||
.join("")}
|
||
</div>
|
||
</div>
|
||
<aside class="panel">
|
||
${panelHeader("Clinical note", "A compact summary for quick review.")}
|
||
<div class="note-box">${escapeHtml(order.message)}</div>
|
||
${order.apiSaran ? `<div style="height:14px"></div><div class="note-box">${escapeHtml(typeof order.apiSaran === "string" ? order.apiSaran : JSON.stringify(order.apiSaran))}</div>` : ""}
|
||
<div style="height:14px"></div>
|
||
<div class="mini-list">
|
||
<div class="mini-item"><div><strong>Diagnosis</strong><p>${escapeHtml(order.diagnosis)}</p></div></div>
|
||
<div class="mini-item"><div><strong>Updated</strong><p>${escapeHtml(order.updated)}</p></div></div>
|
||
</div>
|
||
</aside>
|
||
</section>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderOrderForm(step, stepKey = "demografi") {
|
||
const steps = [
|
||
["demografi", "Demografi", "Patient identity and contact details."],
|
||
["diagnosa", "Diagnosa", "Clinical indication and diagnosis."],
|
||
["pemeriksaan", "Pemeriksaan", "Choose examination groups."],
|
||
["qrcode", "QR Code", "Fast entry for existing records."],
|
||
["review", "Review", "Check before submit."],
|
||
];
|
||
const activeIndex = Math.max(0, steps.findIndex((item) => item[0] === stepKey));
|
||
const stepBody = {
|
||
demografi: `
|
||
<div class="grid grid-2">
|
||
<label class="field"><span>Patient name</span><input placeholder="Siti Amelia" /></label>
|
||
<label class="field"><span>Medical record no.</span><input placeholder="MRN-10011" /></label>
|
||
<label class="field"><span>Gender</span><select><option>Female</option><option>Male</option></select></label>
|
||
<label class="field"><span>Date of birth</span><input type="date" /></label>
|
||
<label class="field" style="grid-column:1/-1"><span>Address</span><input placeholder="Bandung" /></label>
|
||
</div>
|
||
`,
|
||
diagnosa: `
|
||
<div class="grid grid-2">
|
||
<label class="field"><span>Diagnosis</span><input placeholder="Kontrol hipertensi" /></label>
|
||
<label class="field"><span>Patient note</span><input placeholder="Fasting sample required" /></label>
|
||
<label class="field" style="grid-column:1/-1"><span>Clinical complaint</span><textarea placeholder="Add symptoms and context"></textarea></label>
|
||
</div>
|
||
`,
|
||
pemeriksaan: `
|
||
<div class="pill-row">
|
||
${["Hematology", "Clinical Chemistry", "Urinalysis", "Immunology", "Microbiology"].map((item) => `<span class="pill">${escapeHtml(item)}</span>`).join("")}
|
||
</div>
|
||
<div style="height:14px"></div>
|
||
<div class="grid grid-2">
|
||
${["CBC", "Glucose", "Lipid Profile", "CRP"].map((item) => `
|
||
<label class="card">
|
||
<input type="checkbox" /> ${escapeHtml(item)}
|
||
<p class="muted">Selectable test item</p>
|
||
</label>
|
||
`).join("")}
|
||
</div>
|
||
`,
|
||
qrcode: `
|
||
<div class="grid grid-2">
|
||
<div class="card">
|
||
<strong>Scan existing patient QR</strong>
|
||
<p class="muted">Use a QR code to pull patient and visit context instantly.</p>
|
||
<div class="note-box">QR preview area</div>
|
||
</div>
|
||
<div class="card">
|
||
<strong>Manual fallback</strong>
|
||
<p class="muted">Still allow manual entry if the scan is unavailable.</p>
|
||
<label class="field"><span>QR token</span><input placeholder="DOCLINK-QR-0001" /></label>
|
||
</div>
|
||
</div>
|
||
`,
|
||
review: `
|
||
<div class="stack">
|
||
<div class="card"><strong>Summary</strong><p class="muted">All steps are merged into a final review before submit.</p></div>
|
||
<div class="grid grid-2">
|
||
<div class="mini-item"><div><strong>Patient</strong><p>Siti Amelia</p></div></div>
|
||
<div class="mini-item"><div><strong>Diagnosis</strong><p>Check-up rutin</p></div></div>
|
||
<div class="mini-item"><div><strong>Tests</strong><p>CBC, Glucose, Urine</p></div></div>
|
||
<div class="mini-item"><div><strong>Message</strong><p>Prioritize fasting sample</p></div></div>
|
||
</div>
|
||
</div>
|
||
`,
|
||
}[stepKey];
|
||
const prev = steps[activeIndex - 1];
|
||
const next = steps[activeIndex + 1];
|
||
return `
|
||
<section class="panel">
|
||
${panelHeader("Create new order", "The new project collapses the old step-heavy flow into a cleaner shell while keeping the same workflow.", '<a class="btn btn-secondary" href="/orders">Cancel</a>')}
|
||
<div class="stepper">
|
||
${steps
|
||
.map(
|
||
(item, index) => `
|
||
<a class="step ${index === activeIndex ? "active" : ""}" href="/orders/new/${item[0]}" hx-get="/fragments/forms/order-step/${item[0]}" hx-target="#order-step-fragment" hx-push-url="true">
|
||
<strong>${escapeHtml(item[1])}</strong>
|
||
<span>${escapeHtml(item[2])}</span>
|
||
</a>
|
||
`,
|
||
)
|
||
.join("")}
|
||
</div>
|
||
</section>
|
||
<section class="panel">
|
||
<div class="stack">
|
||
${stepBody}
|
||
<div class="topbar-actions" style="justify-content: space-between; margin-top: 10px;">
|
||
<a class="btn btn-secondary" href="/orders/new/${prev ? prev[0] : "demografi"}">Back</a>
|
||
<div class="pill-row">
|
||
<a class="btn btn-ghost" href="/orders">Save draft</a>
|
||
<a class="btn btn-primary" href="/orders/new/${next ? next[0] : "review"}">${next ? "Continue" : "Submit order"}</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
`;
|
||
}
|
||
|
||
function renderResultsTable(results, selectedResultId) {
|
||
const selected = results.find((item) => item.id === selectedResultId) || results[0] || mockResults[0];
|
||
return `
|
||
<div class="detail-grid">
|
||
<section class="panel">
|
||
${panelHeader("Result history", "Desktop shows table detail. Mobile collapses into stacked cards.")}
|
||
<div class="grid grid-3" style="margin-bottom:16px">
|
||
<div class="card"><strong>Released</strong><p class="muted">24 today</p></div>
|
||
<div class="card"><strong>Pending</strong><p class="muted">7 need approval</p></div>
|
||
<div class="card"><strong>Reviewed</strong><p class="muted">12 flagged</p></div>
|
||
</div>
|
||
<div id="results-table" class="desktop-only table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr><th>Patient</th><th>Result ID</th><th>Test</th><th>Status</th><th>Date</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
${results
|
||
.map(
|
||
(result) => `
|
||
<tr>
|
||
<td><strong>${escapeHtml(result.patient)}</strong><br /><span class="muted">${escapeHtml(result.summary)}</span></td>
|
||
<td><a href="/results/${result.id}">${escapeHtml(result.id)}</a></td>
|
||
<td>${escapeHtml(result.test)}</td>
|
||
<td>${statusBadge(result.status)}</td>
|
||
<td>${escapeHtml(result.date)}</td>
|
||
</tr>
|
||
`,
|
||
)
|
||
.join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="mobile-only list" style="margin-top:16px">
|
||
${results
|
||
.map(
|
||
(result) => `
|
||
<article class="card">
|
||
<div class="topbar-actions" style="justify-content:space-between">
|
||
<div>
|
||
<strong>${escapeHtml(result.patient)}</strong>
|
||
<p class="muted">${escapeHtml(result.test)} · ${escapeHtml(result.date)}</p>
|
||
</div>
|
||
${statusBadge(result.status)}
|
||
</div>
|
||
<p class="muted" style="margin-top:10px">${escapeHtml(result.summary)}</p>
|
||
</article>
|
||
`,
|
||
)
|
||
.join("")}
|
||
</div>
|
||
</section>
|
||
<aside class="panel">
|
||
${panelHeader("Selected result", "Use the right pane for quick context without losing list position.", `<a class="btn btn-secondary" href="/results/${selected.id}">Open detail</a>`)}
|
||
<div class="stack">
|
||
<div class="card">
|
||
<strong>${escapeHtml(selected.patient)}</strong>
|
||
<p class="muted">${escapeHtml(selected.test)} · ${escapeHtml(selected.id)}</p>
|
||
<div style="margin-top:12px">${statusBadge(selected.status)}</div>
|
||
</div>
|
||
<div class="note-box">${escapeHtml(selected.summary)}</div>
|
||
<div class="mini-item"><div><strong>Value</strong><p>${escapeHtml(selected.value)}</p></div></div>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderResultDetail(result) {
|
||
return `
|
||
<section class="panel">
|
||
${panelHeader(`${result.patient} · ${result.id}`, "Result detail with summary, status, and interpretation fields.", '<a class="btn btn-secondary" href="/results">Back to results</a>')}
|
||
<div class="grid grid-3">
|
||
<div class="card"><span class="muted">Status</span><div style="margin-top:8px">${statusBadge(result.status)}</div></div>
|
||
<div class="card"><span class="muted">Test</span><strong style="display:block; margin-top:8px">${escapeHtml(result.test)}</strong></div>
|
||
<div class="card"><span class="muted">Value</span><strong style="display:block; margin-top:8px">${escapeHtml(result.value)}</strong></div>
|
||
</div>
|
||
<div style="height:18px"></div>
|
||
<div class="detail-grid">
|
||
<div class="panel" style="padding:0; box-shadow:none; background:transparent; border:0">
|
||
<div class="note-box">${escapeHtml(result.summary)}</div>
|
||
</div>
|
||
<aside class="panel">
|
||
<div class="mini-list">
|
||
<div class="mini-item"><div><strong>Date</strong><p>${escapeHtml(result.date)}</p></div></div>
|
||
<div class="mini-item"><div><strong>Interpretation</strong><p>Borderline value requires doctor review.</p></div></div>
|
||
<div class="mini-item"><div><strong>Action</strong><p>Keep in pending state until confirmed.</p></div></div>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</section>
|
||
`;
|
||
}
|
||
|
||
function renderFpp(groups) {
|
||
return `
|
||
<div class="stack">
|
||
<section class="panel">
|
||
${panelHeader("FPP catalog", "Filter blocks and list cards on mobile, richer panel on desktop.")}
|
||
<div class="pill-row">
|
||
${["All", ...mockFppGroups.map((item) => item.group)]
|
||
.map((item) => `<a class="pill ${groups.filter === item ? "active" : ""}" href="/fragments/fpp/list?group=${encodeURIComponent(item)}">${escapeHtml(item)}</a>`)
|
||
.join("")}
|
||
</div>
|
||
</section>
|
||
<section class="grid grid-2">
|
||
${groups.items
|
||
.map(
|
||
(item) => `
|
||
<article class="panel">
|
||
<div class="panel-header">
|
||
<div>
|
||
<h3>${escapeHtml(item.group)}</h3>
|
||
<p>${escapeHtml(item.desc)}</p>
|
||
</div>
|
||
<span class="pill">${escapeHtml(item.count)} items</span>
|
||
</div>
|
||
<div class="grid grid-2">
|
||
${["Filter", "Inspect", "Select", "Export"]
|
||
.map(
|
||
(action) => `
|
||
<div class="card">
|
||
<strong>${escapeHtml(action)}</strong>
|
||
<p class="muted">Workflow action for ${escapeHtml(item.group)}.</p>
|
||
</div>
|
||
`,
|
||
)
|
||
.join("")}
|
||
</div>
|
||
</article>
|
||
`,
|
||
)
|
||
.join("")}
|
||
</section>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderPatients() {
|
||
return `
|
||
<div class="stack">
|
||
<section class="panel">
|
||
${panelHeader("Patient registration", "Landing page for registration, lookup, and entry flow.")}
|
||
<div class="grid grid-3">
|
||
<div class="card">
|
||
<strong>New patient</strong>
|
||
<p class="muted">Open the registration stepper and start a fresh case.</p>
|
||
<a class="btn btn-primary" href="/orders/new/demografi">Start registration</a>
|
||
</div>
|
||
<div class="card">
|
||
<strong>Lookup</strong>
|
||
<p class="muted">Search an existing patient and reuse their visit data.</p>
|
||
<button class="btn btn-secondary" type="button">Search patient</button>
|
||
</div>
|
||
<div class="card">
|
||
<strong>QR entry</strong>
|
||
<p class="muted">Fast path for scan-based intake.</p>
|
||
<a class="btn btn-secondary" href="/orders/new/qrcode">Open QR flow</a>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
<section class="panel">
|
||
${panelHeader("Recent patients", "A compact list that becomes cards on mobile.")}
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr><th>Name</th><th>MRN</th><th>Gender</th><th>Last visit</th><th>Note</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
${mockPatients
|
||
.map(
|
||
(person) => `
|
||
<tr>
|
||
<td><strong>${escapeHtml(person.name)}</strong></td>
|
||
<td>${escapeHtml(person.mrn)}</td>
|
||
<td>${escapeHtml(person.gender)}</td>
|
||
<td>${escapeHtml(person.lastVisit)}</td>
|
||
<td>${escapeHtml(person.note)}</td>
|
||
</tr>
|
||
`,
|
||
)
|
||
.join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderSettings() {
|
||
return `
|
||
<div class="detail-grid">
|
||
<section class="panel">
|
||
${panelHeader("Account", "Profile data and security entry points.")}
|
||
<div class="grid grid-2">
|
||
<div class="card"><span class="muted">Name</span><strong style="display:block; margin-top:8px">${escapeHtml(mockUser.name)}</strong></div>
|
||
<div class="card"><span class="muted">Doctor ID</span><strong style="display:block; margin-top:8px">${escapeHtml(mockUser.doctorId)}</strong></div>
|
||
<div class="card"><span class="muted">Role</span><strong style="display:block; margin-top:8px">${escapeHtml(mockUser.role)}</strong></div>
|
||
<div class="card"><span class="muted">Hospital</span><strong style="display:block; margin-top:8px">${escapeHtml(mockUser.hospital)}</strong></div>
|
||
</div>
|
||
</section>
|
||
<aside class="panel">
|
||
${panelHeader("Settings actions", "Keep the common account actions obvious.")}
|
||
<div class="stack">
|
||
<a class="card" href="/settings/change-password">
|
||
<strong>Change password</strong>
|
||
<p class="muted">Simple form in both mobile and desktop layouts.</p>
|
||
</a>
|
||
<form action="/logout" method="post">
|
||
<button class="card" type="submit" style="text-align:left; width:100%">
|
||
<strong>Logout</strong>
|
||
<p class="muted">Clear the local session and return to login.</p>
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderChangePassword() {
|
||
return `
|
||
<section class="panel">
|
||
${panelHeader("Change password", "Inline validation and a straightforward submit path.")}
|
||
<form class="form" action="/settings/change-password" method="post">
|
||
<div class="field-inline">
|
||
<label class="field"><span>Current password</span><input type="password" name="current_password" placeholder="••••••••" required /></label>
|
||
<label class="field"><span>New password</span><input type="password" name="new_password" placeholder="At least 8 characters" required /></label>
|
||
</div>
|
||
<div class="field-inline">
|
||
<label class="field"><span>Confirm password</span><input type="password" name="confirm_password" placeholder="Repeat new password" required /></label>
|
||
<label class="field"><span>Doctor ID</span><input name="doctor_id" value="${escapeHtml(mockUser.doctorId)}" readonly /></label>
|
||
</div>
|
||
<div class="topbar-actions" style="justify-content:flex-start">
|
||
<button class="btn btn-primary" type="submit">Save password</button>
|
||
<a class="btn btn-secondary" href="/settings">Cancel</a>
|
||
</div>
|
||
</form>
|
||
</section>
|
||
`;
|
||
}
|
||
|
||
function loginPage({ error = "" } = {}) {
|
||
return layout(
|
||
"DocLink Login",
|
||
`
|
||
<main class="auth-screen">
|
||
<section class="auth-card">
|
||
<div class="auth-visual">
|
||
<div class="badge">DocLink rebuild · responsive shell</div>
|
||
<h1>Clinical workflow that works on desktop and mobile.</h1>
|
||
<p>The old app behavior is preserved in a cleaner layout with dashboard, orders, results, FPP, and settings routes.</p>
|
||
<div class="auth-points">
|
||
<div class="auth-point"><strong>Route structure</strong><div>Login, dashboard, order flow, result flow, and account pages.</div></div>
|
||
<div class="auth-point"><strong>Responsive shell</strong><div>Persistent sidebar on desktop, bottom nav on mobile.</div></div>
|
||
<div class="auth-point"><strong>Clean interaction model</strong><div>HTMX fragments plus server-rendered templates.</div></div>
|
||
</div>
|
||
</div>
|
||
<div class="auth-form">
|
||
<div>
|
||
<div class="eyebrow">Sign in</div>
|
||
<h2>Login to DocLink</h2>
|
||
<p class="muted">Use the upstream API or switch to demo mode if the backend is unavailable.</p>
|
||
</div>
|
||
${error ? `<div class="note-box">${escapeHtml(error)}</div>` : ""}
|
||
<form class="form" action="/login" method="post">
|
||
<label class="field"><span>Username</span><input name="username" placeholder="dr.fajri" required /></label>
|
||
<label class="field"><span>Doctor ID</span><input name="doctor_id" placeholder="DR-10024" required /></label>
|
||
<label class="field"><span>Password</span><input type="password" name="password" placeholder="Enter password" required /></label>
|
||
<button class="btn btn-primary" type="submit">${icon("login")} Login</button>
|
||
</form>
|
||
<div class="pill-row">
|
||
<a class="pill" href="/splash">Open splash</a>
|
||
<a class="pill" href="/problem-login">Problem login</a>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
`,
|
||
{ authenticated: false, shell: false, activePath: "/login" },
|
||
);
|
||
}
|
||
|
||
function splashPage() {
|
||
return layout(
|
||
"DocLink Splash",
|
||
`
|
||
<main class="auth-screen">
|
||
<section class="auth-card">
|
||
<div class="auth-visual">
|
||
<div class="badge">Starting DocLink</div>
|
||
<h1>Loading workspace...</h1>
|
||
<p>Checking session and routing into the right entry point.</p>
|
||
</div>
|
||
<div class="auth-form">
|
||
<div class="empty-state">
|
||
<strong>Redirecting</strong>
|
||
<p>We will move you to the correct route in a moment.</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
`,
|
||
{ authenticated: false, shell: false, activePath: "/splash" },
|
||
);
|
||
}
|
||
|
||
function problemLoginPage() {
|
||
return layout(
|
||
"Problem Login",
|
||
`
|
||
<main class="auth-screen">
|
||
<section class="auth-card">
|
||
<div class="auth-visual">
|
||
<div class="badge">Login problem</div>
|
||
<h1>Access needs attention.</h1>
|
||
<p>Use this page when auth state is broken or a session expires.</p>
|
||
</div>
|
||
<div class="auth-form">
|
||
<div class="empty-state">
|
||
<strong>Session expired</strong>
|
||
<p>The session was cleared. Go back to login and sign in again.</p>
|
||
<div style="height:16px"></div>
|
||
<a class="btn btn-primary" href="/login">Back to login</a>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
`,
|
||
{ authenticated: false, shell: false, activePath: "/problem-login" },
|
||
);
|
||
}
|
||
|
||
function orderNewPage(path) {
|
||
const step = path.split("/").filter(Boolean)[2] || "demografi";
|
||
return layout("Create Order", `<div id="order-step-fragment">${renderOrderForm({}, step)}</div>`, {
|
||
authenticated: true,
|
||
activePath: "/orders",
|
||
subtitle: "The new project collapses the old step-heavy flow into a cleaner shell while keeping the same workflow.",
|
||
});
|
||
}
|
||
|
||
function ordersPage({ query = {}, orders = mockOrders, selectedOrderId = mockOrders[0].id } = {}) {
|
||
return layout("Orders", `<div id="orders-fragment">${renderOrdersTable(orders, selectedOrderId, query.status || "All")}</div>`, {
|
||
authenticated: true,
|
||
activePath: "/orders",
|
||
});
|
||
}
|
||
|
||
function resultsPage({ query = {}, results = mockResults, selectedResultId = mockResults[0].id } = {}) {
|
||
return layout("Results", `<div id="results-fragment">${renderResultsTable(results, selectedResultId)}</div>`, {
|
||
authenticated: true,
|
||
activePath: "/results",
|
||
});
|
||
}
|
||
|
||
function fppPage({ group = "All", groups = mockFppGroups } = {}) {
|
||
return layout("FPP", `<div id="fpp-fragment">${renderFpp({ items: groups, filter: group })}</div>`, {
|
||
authenticated: true,
|
||
activePath: "/fpp",
|
||
});
|
||
}
|
||
|
||
function patientsPage() {
|
||
return layout("Patients", renderPatients(), { authenticated: true, activePath: "/patients" });
|
||
}
|
||
|
||
function settingsPage() {
|
||
return layout("Settings", renderSettings(), { authenticated: true, activePath: "/settings" });
|
||
}
|
||
|
||
function changePasswordPage() {
|
||
return layout("Change Password", renderChangePassword(), {
|
||
authenticated: true,
|
||
activePath: "/settings/change-password",
|
||
});
|
||
}
|
||
|
||
function orderDetailPage(order) {
|
||
return layout("Order Detail", renderOrderDetail(order), {
|
||
authenticated: true,
|
||
activePath: "/orders",
|
||
});
|
||
}
|
||
|
||
function resultDetailPage(result) {
|
||
return layout("Result Detail", renderResultDetail(result), {
|
||
authenticated: true,
|
||
activePath: "/results",
|
||
});
|
||
}
|
||
|
||
function specialMessagePage(order) {
|
||
return layout(
|
||
"Pesan Khusus",
|
||
`
|
||
<section class="panel">
|
||
${panelHeader("Pesan khusus", "Desktop can treat this as a modal-style panel; mobile reads it as a dedicated page.", `<a class="btn btn-secondary" href="/orders/${order.id}">Back</a>`)}
|
||
<div class="detail-grid">
|
||
<div class="card">
|
||
<strong>${escapeHtml(order.patient)}</strong>
|
||
<p class="muted">${escapeHtml(order.id)}</p>
|
||
<div style="margin-top:12px">${statusBadge(order.status)}</div>
|
||
<p style="margin-top:14px" class="muted">${escapeHtml(order.message)}</p>
|
||
${order.apiSaran ? `<div class="note-box" style="margin-top:14px">${escapeHtml(typeof order.apiSaran === "string" ? order.apiSaran : JSON.stringify(order.apiSaran))}</div>` : ""}
|
||
</div>
|
||
<form class="form card" action="/orders/${order.id}/pesan-khusus" method="post">
|
||
<label class="field">
|
||
<span>Special message</span>
|
||
<textarea name="pesan_khusus" placeholder="Type a special note for this order..." required>${escapeHtml(order.message)}</textarea>
|
||
</label>
|
||
<div class="topbar-actions" style="justify-content:flex-start">
|
||
<button class="btn btn-primary" type="submit">Save message</button>
|
||
<a class="btn btn-secondary" href="/orders/${order.id}">Cancel</a>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</section>
|
||
`,
|
||
{ authenticated: true, activePath: "/orders" },
|
||
);
|
||
}
|
||
|
||
function emptyRoute(path) {
|
||
return layout("Not Found", `<section class="panel"><div class="empty-state"><strong>Route not found</strong><p>${escapeHtml(path)} is not part of the current rebuild scope.</p><div style="height:14px"></div><a class="btn btn-primary" href="/">Go to dashboard</a></div></section>`, {
|
||
authenticated: true,
|
||
activePath: "/",
|
||
});
|
||
}
|
||
|
||
function cookieHeader(req) {
|
||
return req.headers.cookie || "";
|
||
}
|
||
|
||
function getCookie(req, name) {
|
||
const cookies = cookieHeader(req)
|
||
.split(";")
|
||
.map((part) => part.trim())
|
||
.filter(Boolean);
|
||
for (const item of cookies) {
|
||
const [key, ...rest] = item.split("=");
|
||
if (key === name) return decodeURIComponent(rest.join("="));
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function setCookie(name, value, opts = {}) {
|
||
const parts = [`${name}=${encodeURIComponent(value)}`, "Path=/", "HttpOnly", "SameSite=Lax"];
|
||
if (opts.maxAge != null) parts.push(`Max-Age=${opts.maxAge}`);
|
||
if (opts.secure) parts.push("Secure");
|
||
return parts.join("; ");
|
||
}
|
||
|
||
function deleteCookie(name) {
|
||
return `${name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
|
||
}
|
||
|
||
function readSession(req) {
|
||
const raw = getCookie(req, sessionKey);
|
||
if (!raw) return null;
|
||
try {
|
||
return JSON.parse(raw);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function requireAuth(req, res) {
|
||
const session = readSession(req);
|
||
if (session) return session;
|
||
redirect(res, "/login");
|
||
return null;
|
||
}
|
||
|
||
async function fetchJson(url, options = {}) {
|
||
const response = await fetch(url, options);
|
||
const contentType = response.headers.get("content-type") || "";
|
||
const body = contentType.includes("application/json") ? await response.json() : await response.text();
|
||
if (!response.ok) {
|
||
const error = new Error(`Upstream error ${response.status}`);
|
||
error.status = response.status;
|
||
error.body = body;
|
||
throw error;
|
||
}
|
||
return body;
|
||
}
|
||
|
||
async function apiPost(path, payload, token = "") {
|
||
const headers = { "Content-Type": "application/json" };
|
||
if (token) {
|
||
headers.Authorization = `Bearer ${token}`;
|
||
payload.token = payload.token || token;
|
||
}
|
||
const url = `${API_BASE}${path}`;
|
||
return fetchJson(url, {
|
||
method: "POST",
|
||
headers,
|
||
body: JSON.stringify(payload),
|
||
});
|
||
}
|
||
|
||
function normalizeSession(payload) {
|
||
const data = payload?.data || payload?.result || payload;
|
||
const token = data?.token || data?.access_token || data?.accessToken || payload?.token || "";
|
||
const username = data?.username || data?.M_UserUsername || data?.name || "";
|
||
const doctorId = data?.doctor_id || data?.M_UserID || data?.doctorId || "";
|
||
return {
|
||
token,
|
||
username,
|
||
doctorId,
|
||
raw: payload,
|
||
};
|
||
}
|
||
|
||
function extractArray(payload) {
|
||
if (Array.isArray(payload)) return payload;
|
||
if (!payload || typeof payload !== "object") return null;
|
||
for (const key of ["data", "result", "results", "items", "list"]) {
|
||
const value = payload[key];
|
||
const found = extractArray(value);
|
||
if (found) return found;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function normalizeOrder(raw, index = 0) {
|
||
const testsSource = raw?.details || raw?.tests || raw?.items || raw?.order_details || [];
|
||
const tests = Array.isArray(testsSource)
|
||
? testsSource
|
||
.map((item) => {
|
||
if (typeof item === "string") return item;
|
||
return item?.name || item?.test_name || item?.detail_name || item?.exam || item?.label || "";
|
||
})
|
||
.filter(Boolean)
|
||
: [];
|
||
const status = raw?.status || raw?.order_status || raw?.state || "Processing";
|
||
return {
|
||
id:
|
||
raw?.order_patient_id ||
|
||
raw?.order_id ||
|
||
raw?.id ||
|
||
raw?.OrderPatientID ||
|
||
raw?.OrderID ||
|
||
`ORD-${String(index + 1).padStart(5, "0")}`,
|
||
patient: raw?.patient_name || raw?.name || raw?.patient || raw?.patient_fullname || "Patient",
|
||
doctor: raw?.doctor_name || raw?.doctor || raw?.M_UserUsername || mockUser.name,
|
||
updated: raw?.updated_at || raw?.updated || raw?.created_at || "Today",
|
||
status,
|
||
tone: statusClass(status),
|
||
mode: raw?.mode || raw?.visit_type || raw?.patient_type || "Outpatient",
|
||
age: String(raw?.age || raw?.patient_age || ""),
|
||
gender: raw?.gender || raw?.patient_gender || "",
|
||
tests,
|
||
diagnosis: raw?.patient_diagnosa || raw?.diagnosis || raw?.note || "",
|
||
message: raw?.message || raw?.patient_note || "",
|
||
};
|
||
}
|
||
|
||
function normalizeResult(raw, index = 0) {
|
||
const status = raw?.status || raw?.result_status || "Released";
|
||
return {
|
||
id:
|
||
raw?.result_id ||
|
||
raw?.order_id ||
|
||
raw?.id ||
|
||
raw?.hasil_id ||
|
||
`RES-${String(index + 1).padStart(4, "0")}`,
|
||
patient: raw?.patient_name || raw?.name || raw?.patient || "Patient",
|
||
test: raw?.test_name || raw?.item_name || raw?.test || "Result",
|
||
status,
|
||
tone: statusClass(status),
|
||
date: raw?.date || raw?.created_at || raw?.updated_at || "Today",
|
||
summary: raw?.summary || raw?.note || raw?.result_summary || "",
|
||
value: raw?.value || raw?.result_value || raw?.result || "",
|
||
};
|
||
}
|
||
|
||
async function loadOrders(session, search = "", status = "All") {
|
||
const term = String(search || "").trim();
|
||
try {
|
||
const payload = await apiPost(
|
||
"/order/search_order_pasien_by_doktorid",
|
||
{
|
||
token: session.token,
|
||
OrderPatientM_DoctorID: session.doctorId || mockUser.doctorId,
|
||
search: term,
|
||
},
|
||
session.token,
|
||
);
|
||
const rows = extractArray(payload) || [];
|
||
const orders = rows.map((row, index) => normalizeOrder(row, index)).filter((item) => {
|
||
const matchesSearch =
|
||
!term ||
|
||
[item.id, item.patient, item.status, item.diagnosis, item.message].some((value) =>
|
||
String(value).toLowerCase().includes(term.toLowerCase()),
|
||
);
|
||
const matchesStatus = !status || status === "All" || item.status === status;
|
||
return matchesSearch && matchesStatus;
|
||
});
|
||
if (orders.length) return orders;
|
||
} catch {
|
||
// Fall through to mock data.
|
||
}
|
||
return filterOrders(term, status);
|
||
}
|
||
|
||
async function loadResults(session, search = "") {
|
||
const term = String(search || "").trim();
|
||
try {
|
||
const payload = await apiPost(
|
||
"/order/hasil_belum_keluar_by_id",
|
||
{ token: session.token, order_id: session.orderId || "" },
|
||
session.token,
|
||
);
|
||
const rows = extractArray(payload) || [];
|
||
const results = rows.map((row, index) => normalizeResult(row, index)).filter((item) => {
|
||
if (!term) return true;
|
||
return [item.id, item.patient, item.test, item.status, item.summary].some((value) =>
|
||
String(value).toLowerCase().includes(term.toLowerCase()),
|
||
);
|
||
});
|
||
if (results.length) return results;
|
||
} catch {
|
||
// Fall through to mock data.
|
||
}
|
||
return filterResults(term);
|
||
}
|
||
|
||
async function loadResultDetail(session, resultId) {
|
||
const fallback = mockResults.find((item) => item.id === resultId) || mockResults[0];
|
||
try {
|
||
// Inferred from project-specs note about an additional result base path.
|
||
const payload = await apiPost(
|
||
"/result/getResult",
|
||
{
|
||
token: session.token,
|
||
result_id: resultId,
|
||
order_id: resultId,
|
||
},
|
||
session.token,
|
||
);
|
||
const rows = extractArray(payload);
|
||
if (rows?.length) return normalizeResult(rows[0], 0);
|
||
if (payload && typeof payload === "object") {
|
||
return {
|
||
...fallback,
|
||
...normalizeResult(payload, 0),
|
||
id: resultId || fallback.id,
|
||
};
|
||
}
|
||
} catch {
|
||
// Fall through to local seed data when the upstream result API is unavailable.
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
async function loadFpp(session, group = "All") {
|
||
try {
|
||
const payload = await apiPost("/Fpp/load/1/1", { token: session.token }, session.token);
|
||
const rows = extractArray(payload) || [];
|
||
const items = rows.map((row, index) => ({
|
||
group: row?.group || row?.kategori || row?.name || `GROUP-${index + 1}`,
|
||
count: Number(row?.count || row?.total || row?.qty || 1),
|
||
desc: row?.description || row?.desc || "Laboratory reference item.",
|
||
}));
|
||
if (items.length) {
|
||
const filtered = group === "All" ? items : items.filter((item) => item.group === group);
|
||
return { items: filtered, filter: group };
|
||
}
|
||
} catch {
|
||
// Fall through to mock data.
|
||
}
|
||
const items = group === "All" ? mockFppGroups : mockFppGroups.filter((item) => item.group === group);
|
||
return { items, filter: group };
|
||
}
|
||
|
||
async function loadOrderDetail(session, orderId) {
|
||
const order = mockOrders.find((item) => item.id === orderId) || mockOrders[0];
|
||
const detail = { ...order };
|
||
try {
|
||
const saran = await apiPost(
|
||
"/order/get_order_saran_by_order_patient_id",
|
||
{ token: session.token, order_patient_id: orderId },
|
||
session.token,
|
||
);
|
||
detail.apiSaran = extractArray(saran) || saran?.message || saran?.note || saran?.result || "";
|
||
} catch {
|
||
detail.apiSaran = "";
|
||
}
|
||
try {
|
||
const hasil = await apiPost(
|
||
"/order/hasil_belum_keluar_by_id",
|
||
{ token: session.token, order_id: orderId },
|
||
session.token,
|
||
);
|
||
detail.apiHasil = extractArray(hasil) || hasil?.message || hasil?.note || hasil?.result || "";
|
||
} catch {
|
||
detail.apiHasil = "";
|
||
}
|
||
return detail;
|
||
}
|
||
|
||
function json(res, status, payload) {
|
||
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
||
res.end(JSON.stringify(payload));
|
||
}
|
||
|
||
function redirect(res, location, headers = {}) {
|
||
res.writeHead(302, { Location: location, ...headers });
|
||
res.end();
|
||
}
|
||
|
||
function html(res, status, body, headers = {}) {
|
||
res.writeHead(status, { "Content-Type": "text/html; charset=utf-8", ...headers });
|
||
res.end(body);
|
||
}
|
||
|
||
function isHtmx(req) {
|
||
return req.headers["hx-request"] === "true";
|
||
}
|
||
|
||
async function readBody(req) {
|
||
const chunks = [];
|
||
for await (const chunk of req) chunks.push(chunk);
|
||
const raw = Buffer.concat(chunks).toString("utf8");
|
||
const contentType = req.headers["content-type"] || "";
|
||
if (contentType.includes("application/json")) return raw ? JSON.parse(raw) : {};
|
||
return Object.fromEntries(new URLSearchParams(raw));
|
||
}
|
||
|
||
function filterOrders(search, status) {
|
||
const term = String(search || "").trim().toLowerCase();
|
||
return mockOrders.filter((order) => {
|
||
const matchesSearch =
|
||
!term ||
|
||
[order.id, order.patient, order.status, order.diagnosis, order.message].some((value) =>
|
||
String(value).toLowerCase().includes(term),
|
||
);
|
||
const matchesStatus = !status || status === "All" || order.status === status;
|
||
return matchesSearch && matchesStatus;
|
||
});
|
||
}
|
||
|
||
function filterResults(search) {
|
||
const term = String(search || "").trim().toLowerCase();
|
||
return mockResults.filter((result) => {
|
||
if (!term) return true;
|
||
return [result.id, result.patient, result.test, result.status, result.summary].some((value) =>
|
||
String(value).toLowerCase().includes(term),
|
||
);
|
||
});
|
||
}
|
||
|
||
function fragmentOrdersTable(search = "", status = "All", ordersData = null) {
|
||
const orders = ordersData || filterOrders(search, status);
|
||
const selected = orders[0] || mockOrders[0];
|
||
return `
|
||
<div class="detail-grid">
|
||
<section class="panel">
|
||
${panelHeader("Search orders", "Filter the list without full page refresh.")}
|
||
<form class="pill-row" hx-get="/fragments/orders/table" hx-target="#orders-fragment" hx-push-url="true">
|
||
<input type="hidden" name="search" value="${escapeHtml(search)}" />
|
||
${["All", "Processing", "Ready", "Needs review"]
|
||
.map(
|
||
(item) => `
|
||
<button class="pill ${status === item ? "active" : ""}" name="status" value="${escapeHtml(item)}" type="submit">${escapeHtml(item)}</button>
|
||
`,
|
||
)
|
||
.join("")}
|
||
</form>
|
||
<div id="orders-table" class="table-wrap" style="margin-top:16px">
|
||
<table>
|
||
<thead>
|
||
<tr><th>Patient</th><th>Order ID</th><th>Doctor</th><th>Status</th><th>Updated</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
${orders
|
||
.map(
|
||
(order) => `
|
||
<tr>
|
||
<td><strong>${escapeHtml(order.patient)}</strong><br /><span class="muted">${escapeHtml(order.mode)}</span></td>
|
||
<td><a href="/orders/${order.id}">${escapeHtml(order.id)}</a></td>
|
||
<td>${escapeHtml(order.doctor)}</td>
|
||
<td>${statusBadge(order.status)}</td>
|
||
<td>${escapeHtml(order.updated)}</td>
|
||
</tr>
|
||
`,
|
||
)
|
||
.join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
<aside class="panel">
|
||
${panelHeader("Selected order", "Context stays visible while you review the table.", `<a class="btn btn-secondary" href="/orders/${selected.id}">Open detail</a>`)}
|
||
<div class="stack">
|
||
<div class="card">
|
||
<strong>${escapeHtml(selected.patient)}</strong>
|
||
<p class="muted">${escapeHtml(selected.id)} · ${escapeHtml(selected.mode)}</p>
|
||
<div class="pill-row" style="margin-top:12px">${statusBadge(selected.status)}<span class="pill">${escapeHtml(selected.age)} years</span><span class="pill">${escapeHtml(selected.gender)}</span></div>
|
||
</div>
|
||
<div class="mini-list">
|
||
<div class="mini-item"><div><strong>Diagnosis</strong><p>${escapeHtml(selected.diagnosis)}</p></div></div>
|
||
<div class="mini-item"><div><strong>Requested tests</strong><p>${escapeHtml(selected.tests.join(", "))}</p></div></div>
|
||
<div class="mini-item"><div><strong>Special note</strong><p>${escapeHtml(selected.message)}</p></div></div>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function fragmentResultsTable(search = "", resultsData = null) {
|
||
const results = resultsData || filterResults(search);
|
||
const selected = results[0] || mockResults[0];
|
||
return `
|
||
<div class="detail-grid">
|
||
<section class="panel">
|
||
${panelHeader("Result history", "Desktop shows table detail. Mobile collapses into stacked cards.")}
|
||
<div class="grid grid-3" style="margin-bottom:16px">
|
||
<div class="card"><strong>Released</strong><p class="muted">24 today</p></div>
|
||
<div class="card"><strong>Pending</strong><p class="muted">7 need approval</p></div>
|
||
<div class="card"><strong>Reviewed</strong><p class="muted">12 flagged</p></div>
|
||
</div>
|
||
<div id="results-table" class="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr><th>Patient</th><th>Result ID</th><th>Test</th><th>Status</th><th>Date</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
${results
|
||
.map(
|
||
(result) => `
|
||
<tr>
|
||
<td><strong>${escapeHtml(result.patient)}</strong><br /><span class="muted">${escapeHtml(result.summary)}</span></td>
|
||
<td><a href="/results/${result.id}">${escapeHtml(result.id)}</a></td>
|
||
<td>${escapeHtml(result.test)}</td>
|
||
<td>${statusBadge(result.status)}</td>
|
||
<td>${escapeHtml(result.date)}</td>
|
||
</tr>
|
||
`,
|
||
)
|
||
.join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
<aside class="panel">
|
||
${panelHeader("Selected result", "Use the right pane for quick context without losing list position.", `<a class="btn btn-secondary" href="/results/${selected.id}">Open detail</a>`)}
|
||
<div class="stack">
|
||
<div class="card">
|
||
<strong>${escapeHtml(selected.patient)}</strong>
|
||
<p class="muted">${escapeHtml(selected.test)} · ${escapeHtml(selected.id)}</p>
|
||
<div style="margin-top:12px">${statusBadge(selected.status)}</div>
|
||
</div>
|
||
<div class="note-box">${escapeHtml(selected.summary)}</div>
|
||
<div class="mini-item"><div><strong>Value</strong><p>${escapeHtml(selected.value)}</p></div></div>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function fragmentFpp(group = "All", itemsData = null) {
|
||
const items = itemsData || (group === "All" ? mockFppGroups : mockFppGroups.filter((item) => item.group === group));
|
||
return `
|
||
<div class="stack">
|
||
<section class="panel">
|
||
${panelHeader("FPP catalog", "Filter blocks and list cards on mobile, richer panel on desktop.")}
|
||
<div class="pill-row">
|
||
${["All", ...mockFppGroups.map((item) => item.group)]
|
||
.map(
|
||
(item) => `
|
||
<a class="pill ${group === item ? "active" : ""}" href="/fpp?group=${encodeURIComponent(item)}">${escapeHtml(item)}</a>
|
||
`,
|
||
)
|
||
.join("")}
|
||
</div>
|
||
</section>
|
||
<section class="grid grid-2">
|
||
${items
|
||
.map(
|
||
(item) => `
|
||
<article class="panel">
|
||
<div class="panel-header">
|
||
<div>
|
||
<h3>${escapeHtml(item.group)}</h3>
|
||
<p>${escapeHtml(item.desc)}</p>
|
||
</div>
|
||
<span class="pill">${escapeHtml(item.count)} items</span>
|
||
</div>
|
||
<div class="grid grid-2">
|
||
${["Filter", "Inspect", "Select", "Export"]
|
||
.map(
|
||
(action) => `
|
||
<div class="card">
|
||
<strong>${escapeHtml(action)}</strong>
|
||
<p class="muted">Workflow action for ${escapeHtml(item.group)}.</p>
|
||
</div>
|
||
`,
|
||
)
|
||
.join("")}
|
||
</div>
|
||
</article>
|
||
`,
|
||
)
|
||
.join("")}
|
||
</section>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function fragmentOrderStep(step) {
|
||
return renderOrderForm({}, step);
|
||
}
|
||
|
||
function fragmentPesanKhusus(orderId) {
|
||
const order = mockOrders.find((item) => item.id === orderId) || mockOrders[0];
|
||
return `
|
||
<div class="modal-shell" role="dialog" aria-modal="true" aria-labelledby="pesan-khusus-title">
|
||
<div class="modal-backdrop" data-modal-close></div>
|
||
<section class="modal-card panel">
|
||
${panelHeader("Pesan khusus", "Desktop opens this as a modal, mobile can still use it as a full sheet.", `<button class="btn btn-secondary" type="button" data-modal-close>Close</button>`)}
|
||
<div class="detail-grid">
|
||
<div class="card">
|
||
<strong>${escapeHtml(order.patient)}</strong>
|
||
<p class="muted">${escapeHtml(order.id)}</p>
|
||
<div style="margin-top:12px">${statusBadge(order.status)}</div>
|
||
<p style="margin-top:14px" class="muted">${escapeHtml(order.message)}</p>
|
||
</div>
|
||
<form class="form card" action="/orders/${order.id}/pesan-khusus" method="post">
|
||
<label class="field">
|
||
<span>Special message</span>
|
||
<textarea name="pesan_khusus" placeholder="Type a special note for this order..." required>${escapeHtml(order.message)}</textarea>
|
||
</label>
|
||
<div class="topbar-actions" style="justify-content:flex-start">
|
||
<button class="btn btn-primary" type="submit">Save message</button>
|
||
<button class="btn btn-secondary" type="button" data-modal-close>Cancel</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function fragmentResultDetail(session, resultId) {
|
||
const result = await loadResultDetail(session, resultId);
|
||
return `
|
||
<div id="result-detail-fragment">
|
||
${renderResultDetail(result)}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function renderRoute(req, res, url) {
|
||
const path = url.pathname;
|
||
const query = Object.fromEntries(url.searchParams.entries());
|
||
const session = readSession(req);
|
||
const authed = Boolean(session);
|
||
const isGet = req.method === "GET" || req.method === "HEAD";
|
||
|
||
if (path === "/styles.css") {
|
||
const css = await readFile(new URL("./styles.css", import.meta.url), "utf8");
|
||
res.writeHead(200, { "Content-Type": "text/css; charset=utf-8" });
|
||
res.end(css);
|
||
return;
|
||
}
|
||
|
||
if (path === "/login" && isGet) {
|
||
html(res, 200, loginPage(), { "Cache-Control": "no-store" });
|
||
return;
|
||
}
|
||
|
||
if (path === "/splash" && isGet) {
|
||
html(res, 200, splashPage(), { "Cache-Control": "no-store" });
|
||
return;
|
||
}
|
||
|
||
if (path === "/problem-login" && isGet) {
|
||
html(res, 200, problemLoginPage(), { "Cache-Control": "no-store" });
|
||
return;
|
||
}
|
||
|
||
if (path === "/login" && req.method === "POST") {
|
||
const body = await readBody(req);
|
||
try {
|
||
const payload = await apiPost("/auth/login", body);
|
||
const normalized = normalizeSession(payload);
|
||
const upstreamFailed = String(payload?.status || payload?.result_status || "").toUpperCase() === "ERR" || !normalized.token;
|
||
if (upstreamFailed) {
|
||
throw new Error(payload?.message || "Invalid login response");
|
||
}
|
||
const sessionValue = JSON.stringify({
|
||
token: normalized.token,
|
||
username: normalized.username || body.username || "",
|
||
doctorId: normalized.doctorId || body.doctor_id || "",
|
||
raw: payload,
|
||
});
|
||
redirect(res, "/", {
|
||
"Set-Cookie": setCookie(sessionKey, sessionValue, { maxAge: 60 * 60 * 12 }),
|
||
});
|
||
} catch (error) {
|
||
if (process.env.DOCLINK_DEMO_MODE !== "0") {
|
||
const sessionValue = JSON.stringify({
|
||
token: "demo-token",
|
||
username: body.username || mockUser.name,
|
||
doctorId: body.doctor_id || mockUser.doctorId,
|
||
raw: { demo: true },
|
||
});
|
||
redirect(res, "/", {
|
||
"Set-Cookie": setCookie(sessionKey, sessionValue, { maxAge: 60 * 60 * 12 }),
|
||
});
|
||
} else {
|
||
html(res, 200, loginPage({ error: "Login failed or upstream API unavailable. Check credentials and network." }));
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (path === "/logout" && req.method === "POST") {
|
||
const sessionData = readSession(req);
|
||
if (sessionData?.token) {
|
||
try {
|
||
await apiPost(
|
||
"/auth/logout",
|
||
{
|
||
M_UserID: sessionData.doctorId || mockUser.doctorId,
|
||
M_UserUsername: sessionData.username || mockUser.name,
|
||
},
|
||
sessionData.token,
|
||
);
|
||
} catch {
|
||
// Ignore upstream logout failures and still clear local session.
|
||
}
|
||
}
|
||
redirect(res, "/login", { "Set-Cookie": deleteCookie(sessionKey) });
|
||
return;
|
||
}
|
||
|
||
if (path === "/settings/change-password" && req.method === "POST") {
|
||
const sessionData = requireAuth(req, res);
|
||
if (!sessionData) return;
|
||
const body = await readBody(req);
|
||
try {
|
||
await apiPost(
|
||
"/auth/change_password",
|
||
{
|
||
token: sessionData.token,
|
||
M_UserID: sessionData.doctorId || mockUser.doctorId,
|
||
username: sessionData.username || mockUser.name,
|
||
doctor_id: sessionData.doctorId || mockUser.doctorId,
|
||
new_password: body.new_password,
|
||
confirm_password: body.confirm_password,
|
||
},
|
||
sessionData.token,
|
||
);
|
||
redirect(res, "/settings");
|
||
} catch (error) {
|
||
html(
|
||
res,
|
||
200,
|
||
layout(
|
||
"Change Password",
|
||
`<section class="panel">${panelHeader("Change password", "Inline validation and a straightforward submit path.")}<div class="note-box">Upstream change password call failed. Check API availability.</div></section>`,
|
||
{ authenticated: true, activePath: "/settings/change-password" },
|
||
),
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (path === "/orders/new" && isGet) {
|
||
if (!requireAuth(req, res)) return;
|
||
html(res, 200, orderNewPage(path));
|
||
return;
|
||
}
|
||
|
||
if (path.startsWith("/orders/new/") && isGet) {
|
||
if (!requireAuth(req, res)) return;
|
||
html(res, 200, orderNewPage(path));
|
||
return;
|
||
}
|
||
|
||
if (path === "/orders" && isGet) {
|
||
if (!requireAuth(req, res)) return;
|
||
const orders = await loadOrders(session, query.search || "", query.status || "All");
|
||
html(res, 200, ordersPage({ query, orders, selectedOrderId: orders[0]?.id || mockOrders[0].id }));
|
||
return;
|
||
}
|
||
|
||
if (path === "/results" && isGet) {
|
||
if (!requireAuth(req, res)) return;
|
||
const results = await loadResults(session, query.search || "");
|
||
html(res, 200, resultsPage({ query, results, selectedResultId: results[0]?.id || mockResults[0].id }));
|
||
return;
|
||
}
|
||
|
||
if (path === "/results/historical" && isGet) {
|
||
if (!requireAuth(req, res)) return;
|
||
html(
|
||
res,
|
||
200,
|
||
layout(
|
||
"Historical results",
|
||
`<section class="panel">${panelHeader("Historical results", "A compact historic view that still fits the responsive shell.")}<div class="mini-list">${mockResults.map((item) => `<a class="mini-item" href="/results/${item.id}"><div><strong>${escapeHtml(item.patient)}</strong><p>${escapeHtml(item.test)} · ${escapeHtml(item.date)}</p></div>${statusBadge(item.status)}</a>`).join("")}</div></section>`,
|
||
{ authenticated: true, activePath: "/results" },
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (path === "/results/pending" && isGet) {
|
||
if (!requireAuth(req, res)) return;
|
||
html(
|
||
res,
|
||
200,
|
||
layout(
|
||
"Pending results",
|
||
`<section class="panel">${panelHeader("Pending results", "Items that need attention before release.")}<div class="grid grid-2">${mockResults.filter((item) => item.status !== "Released").map((item) => `<article class="card"><div class="topbar-actions" style="justify-content:space-between"><strong>${escapeHtml(item.patient)}</strong>${statusBadge(item.status)}</div><p class="muted">${escapeHtml(item.test)} · ${escapeHtml(item.summary)}</p><a class="btn btn-secondary" href="/results/${item.id}">Open detail</a></article>`).join("")}</div></section>`,
|
||
{ authenticated: true, activePath: "/results" },
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (path === "/fpp" && isGet) {
|
||
if (!requireAuth(req, res)) return;
|
||
const groups = await loadFpp(session, query.group || "All");
|
||
html(res, 200, fppPage({ group: groups.filter, groups: groups.items }));
|
||
return;
|
||
}
|
||
|
||
if (path === "/patients" && isGet) {
|
||
if (!requireAuth(req, res)) return;
|
||
html(res, 200, patientsPage());
|
||
return;
|
||
}
|
||
|
||
if ((path === "/settings" || path === "/settings/account") && isGet) {
|
||
if (!requireAuth(req, res)) return;
|
||
html(res, 200, settingsPage());
|
||
return;
|
||
}
|
||
|
||
if (path === "/settings/change-password" && isGet) {
|
||
if (!requireAuth(req, res)) return;
|
||
html(res, 200, changePasswordPage());
|
||
return;
|
||
}
|
||
|
||
if (path === "/" && isGet) {
|
||
if (!requireAuth(req, res)) return;
|
||
html(res, 200, layout("Dashboard", dashboardPage(), { authenticated: true, activePath: "/" }));
|
||
return;
|
||
}
|
||
|
||
if (path.startsWith("/orders/") && path.endsWith("/pesan-khusus") && isGet) {
|
||
if (!requireAuth(req, res)) return;
|
||
const orderId = path.split("/")[2];
|
||
const order = await loadOrderDetail(session, orderId);
|
||
html(res, 200, specialMessagePage(order));
|
||
return;
|
||
}
|
||
|
||
if (path.startsWith("/orders/") && isGet) {
|
||
if (!requireAuth(req, res)) return;
|
||
const orderId = path.split("/")[2];
|
||
const order = await loadOrderDetail(session, orderId);
|
||
html(res, 200, orderDetailPage(order));
|
||
return;
|
||
}
|
||
|
||
if (path.startsWith("/results/") && isGet) {
|
||
if (!requireAuth(req, res)) return;
|
||
const resultId = path.split("/")[2];
|
||
const result = await loadResultDetail(session, resultId);
|
||
html(res, 200, resultDetailPage(result));
|
||
return;
|
||
}
|
||
|
||
if (path === "/fragments/orders/table" && isGet) {
|
||
if (!requireAuth(req, res)) return;
|
||
const orders = await loadOrders(session, query.search || "", query.status || "All");
|
||
html(res, 200, fragmentOrdersTable(query.search || "", query.status || "All", orders), { "HX-Trigger": JSON.stringify({ "doclink:orders-updated": true }) });
|
||
return;
|
||
}
|
||
|
||
if (path === "/fragments/results/table" && isGet) {
|
||
if (!requireAuth(req, res)) return;
|
||
const results = await loadResults(session, query.search || "");
|
||
html(res, 200, fragmentResultsTable(query.search || "", results));
|
||
return;
|
||
}
|
||
|
||
if (path === "/fragments/fpp/list" && isGet) {
|
||
if (!requireAuth(req, res)) return;
|
||
const groups = await loadFpp(session, query.group || "All");
|
||
html(res, 200, fragmentFpp(groups.filter || "All", groups.items));
|
||
return;
|
||
}
|
||
|
||
if (path.startsWith("/fragments/forms/order-step/") && isGet) {
|
||
if (!requireAuth(req, res)) return;
|
||
const step = path.split("/")[4] || "demografi";
|
||
html(res, 200, fragmentOrderStep(step));
|
||
return;
|
||
}
|
||
|
||
if (path === "/fragments/modals/pesan-khusus" && isGet) {
|
||
if (!requireAuth(req, res)) return;
|
||
html(res, 200, fragmentPesanKhusus(query.order_id || mockOrders[0].id));
|
||
return;
|
||
}
|
||
|
||
if (path.startsWith("/fragments/results/detail/") && isGet) {
|
||
if (!requireAuth(req, res)) return;
|
||
const resultId = path.split("/")[4] || mockResults[0].id;
|
||
html(res, 200, await fragmentResultDetail(session, resultId));
|
||
return;
|
||
}
|
||
|
||
if (path.startsWith("/api/") && isGet) {
|
||
json(res, 200, { ok: true });
|
||
return;
|
||
}
|
||
|
||
if (path === "/orders" && req.method === "POST") {
|
||
const sessionData = requireAuth(req, res);
|
||
if (!sessionData) return;
|
||
const body = await readBody(req);
|
||
try {
|
||
await apiPost(
|
||
"/order/order_patient",
|
||
{
|
||
token: sessionData.token,
|
||
M_MouID: body.M_MouID || mockUser.doctorId,
|
||
patient_name: body.patient_name || "",
|
||
patient_diagnosa: body.patient_diagnosa || "",
|
||
patient_address: body.patient_address || "",
|
||
patient_nik: body.patient_nik || "",
|
||
patient_hp: body.patient_hp || "",
|
||
patient_dob: body.patient_dob || "",
|
||
patient_note: body.patient_note || "",
|
||
details: body.details || [],
|
||
},
|
||
sessionData.token,
|
||
);
|
||
redirect(res, "/orders");
|
||
} catch {
|
||
html(
|
||
res,
|
||
200,
|
||
layout(
|
||
"Create Order",
|
||
`<section class="panel">${panelHeader("Create new order", "The new project collapses the old step-heavy flow into a cleaner shell while keeping the same workflow.")}<div class="note-box">Upstream order create call failed. Check API availability.</div></section>`,
|
||
{ authenticated: true, activePath: "/orders" },
|
||
),
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (path.startsWith("/orders/") && path.endsWith("/pesan-khusus") && req.method === "POST") {
|
||
const sessionData = requireAuth(req, res);
|
||
if (!sessionData) return;
|
||
const orderId = path.split("/")[2];
|
||
const body = await readBody(req);
|
||
try {
|
||
await apiPost(
|
||
"/Pesankhusus/add_pesan_khusus",
|
||
{
|
||
token: sessionData.token,
|
||
order_id: orderId,
|
||
pesan_khusus: body.pesan_khusus,
|
||
},
|
||
sessionData.token,
|
||
);
|
||
redirect(res, `/orders/${orderId}`);
|
||
} catch {
|
||
html(
|
||
res,
|
||
200,
|
||
layout(
|
||
"Pesan Khusus",
|
||
`<section class="panel">${panelHeader("Pesan khusus", "Desktop can treat this as a modal-style panel; mobile reads it as a dedicated page.")}<div class="note-box">Upstream save failed. Check API availability.</div></section>`,
|
||
{ authenticated: true, activePath: "/orders" },
|
||
),
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
html(res, 404, emptyRoute(path));
|
||
}
|
||
|
||
const server = http.createServer(async (req, res) => {
|
||
try {
|
||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||
await renderRoute(req, res, url);
|
||
} catch (error) {
|
||
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
||
res.end(`Internal error: ${error.message}`);
|
||
}
|
||
});
|
||
|
||
server.listen(PORT, () => {
|
||
console.log(`DocLink Web running on http://localhost:${PORT}`);
|
||
});
|