1958 lines
79 KiB
JavaScript
1958 lines
79 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 sampleLogin = {
|
||
username: "yogayogi",
|
||
doctorId: "31010002",
|
||
password: "123456",
|
||
};
|
||
|
||
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 emptyState(title, text, action = "") {
|
||
return `
|
||
<div class="empty-state">
|
||
<strong>${escapeHtml(title)}</strong>
|
||
<p>${escapeHtml(text)}</p>
|
||
${action}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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;
|
||
}
|
||
if (event.detail && event.detail.target && event.detail.target.classList) {
|
||
event.detail.target.classList.remove('swap-in');
|
||
void event.detail.target.offsetWidth;
|
||
event.detail.target.classList.add('swap-in');
|
||
window.setTimeout(function () {
|
||
event.detail.target.classList.remove('swap-in');
|
||
}, 240);
|
||
}
|
||
});
|
||
})();
|
||
</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 Pramita</strong>
|
||
<span>Workflow shell</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>DocLink Pramita</strong>
|
||
<p>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>
|
||
`;
|
||
}
|
||
|
||
async function dashboardPage(session) {
|
||
const [orders, results, fpp] = await Promise.all([
|
||
loadOrders(session, "", "All"),
|
||
loadResults(session, ""),
|
||
loadFpp(session, "All"),
|
||
]);
|
||
const stats = [
|
||
{ label: "Orders today", value: String(orders.length), trend: "Live", hint: "From API response" },
|
||
{ label: "Results pending", value: String(results.filter((item) => item.status !== "Released").length), trend: "Live", hint: "From API response" },
|
||
{ label: "FPP items", value: String(fpp.items.length), trend: "Live", hint: "From API response" },
|
||
{ label: "Special messages", value: String(orders.filter((item) => Boolean(item.message)).length), trend: "Live", hint: "From API response" },
|
||
];
|
||
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>
|
||
${orders.length
|
||
? orders
|
||
.slice(0, 5)
|
||
.map(
|
||
(order) => `
|
||
<tr>
|
||
<td><strong>${escapeHtml(order.patient || "Unknown 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("")
|
||
: `<tr><td colspan="4">${emptyState("No orders returned", "The order endpoint did not return any rows for this session.")}</td></tr>`}
|
||
</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] || null;
|
||
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>
|
||
${
|
||
orders.length
|
||
? `
|
||
<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 || "Unknown 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 || "Unknown 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>
|
||
`
|
||
: emptyState("No orders returned", "The order endpoint did not return any rows for this filter.")
|
||
}
|
||
</section>
|
||
<aside class="panel">
|
||
${panelHeader("Selected order", "Master-detail layout keeps context visible while reviewing the list.", selected ? `<a class="btn btn-secondary" href="/orders/${selected.id}">Open detail</a>` : "")}
|
||
${
|
||
selected
|
||
? `
|
||
<div class="stack">
|
||
<div class="card">
|
||
<strong>${escapeHtml(selected.patient || "Unknown patient")}</strong>
|
||
<p class="muted">${escapeHtml(selected.id || "-")} · ${escapeHtml(selected.updated || "-")}</p>
|
||
<div class="pill-row" style="margin-top:12px">
|
||
${statusBadge(selected.status)}
|
||
<span class="pill">Doctor ${escapeHtml(selected.doctor || "-")}</span>
|
||
<span class="pill">${escapeHtml(selected.orderDate || selected.updated || "-")}</span>
|
||
</div>
|
||
</div>
|
||
<div class="mini-list">
|
||
<div class="mini-item"><div><strong>NIK</strong><p>${escapeHtml(selected.orderNik || selected.diagnosis || "-")}</p></div></div>
|
||
<div class="mini-item"><div><strong>Phone</strong><p>${escapeHtml(selected.orderHp || "-")}</p></div></div>
|
||
<div class="mini-item"><div><strong>Address</strong><p>${escapeHtml(selected.orderAddress || selected.message || "-")}</p></div></div>
|
||
</div>
|
||
</div>
|
||
`
|
||
: emptyState("No selected order", "The API returned no rows for this search.")
|
||
}
|
||
</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.updated || "-")}</span></div>
|
||
<div class="card"><span class="muted">Doctor ID</span><strong style="display:block; margin-top:8px">${escapeHtml(order.doctor || "-")}</strong><span class="muted">Pramita Bandungraya</span></div>
|
||
</div>
|
||
</section>
|
||
<section class="detail-grid">
|
||
<div class="panel">
|
||
${panelHeader("Order details", "The source API returns identity fields, so the card copies stay aligned with the payload.")}
|
||
<div class="grid grid-2">
|
||
<div class="card"><strong>Order date</strong><p class="muted">${escapeHtml(order.orderDate || order.updated || "-")}</p></div>
|
||
<div class="card"><strong>NIK</strong><p class="muted">${escapeHtml(order.orderNik || "-")}</p></div>
|
||
<div class="card"><strong>Phone</strong><p class="muted">${escapeHtml(order.orderHp || "-")}</p></div>
|
||
<div class="card"><strong>Address</strong><p class="muted">${escapeHtml(order.orderAddress || "-")}</p></div>
|
||
</div>
|
||
</div>
|
||
<aside class="panel">
|
||
${panelHeader("Clinical note", "The payload doesn't expose test bundle details, so this view stays identity-focused.")}
|
||
<div class="note-box">${escapeHtml(order.orderAddress || "-")}</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>Order ID</strong><p>${escapeHtml(order.id || "-")}</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] || null;
|
||
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">${results.filter((item) => item.status === "Released").length} items</p></div>
|
||
<div class="card"><strong>Pending</strong><p class="muted">${results.filter((item) => item.status === "Pending").length} items</p></div>
|
||
<div class="card"><strong>Reviewed</strong><p class="muted">${results.filter((item) => item.status === "Review").length} items</p></div>
|
||
</div>
|
||
${
|
||
results.length
|
||
? `
|
||
<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 || "Unknown patient")}</strong><br /><span class="muted">${escapeHtml(result.summary || "-")}</span></td>
|
||
<td><a href="/results/${result.id}" hx-get="/fragments/results/detail/${result.id}" hx-target="#result-detail-fragment" hx-swap="innerHTML">${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 || "Unknown patient")}</strong>
|
||
<p class="muted">${escapeHtml(result.test || "-")} · ${escapeHtml(result.date || "-")}</p>
|
||
</div>
|
||
${statusBadge(result.status)}
|
||
</div>
|
||
<div style="height:12px"></div>
|
||
<a class="btn btn-secondary" href="/results/${result.id}" hx-get="/fragments/results/detail/${result.id}" hx-target="#result-detail-fragment" hx-swap="innerHTML">Open detail</a>
|
||
<p class="muted" style="margin-top:10px">${escapeHtml(result.summary || "-")}</p>
|
||
</article>
|
||
`,
|
||
)
|
||
.join("")}
|
||
</div>
|
||
`
|
||
: emptyState("No results returned", "The result endpoint did not return any rows for this search.")
|
||
}
|
||
</section>
|
||
<aside class="panel swap-zone" id="result-detail-fragment">
|
||
${panelHeader("Selected result", "Use the right pane for quick context without losing list position.", selected ? `<a class="btn btn-secondary" href="/results/${selected.id}">Open detail</a>` : "")}
|
||
${
|
||
selected
|
||
? `
|
||
<div class="stack">
|
||
<div class="card">
|
||
<strong>${escapeHtml(selected.patient || "Unknown 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>
|
||
`
|
||
: emptyState("No selected result", "The API returned no rows for this search.")
|
||
}
|
||
</aside>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderResultDetail(result) {
|
||
if (!result) {
|
||
return `
|
||
<section class="panel swap-zone">
|
||
${panelHeader("Result detail", "The API returned no matching result.", '<a class="btn btn-secondary" href="/results">Back to results</a>')}
|
||
${emptyState("No result found", "No detail payload was returned for this result ID.")}
|
||
</section>
|
||
`;
|
||
}
|
||
return `
|
||
<section class="panel swap-zone">
|
||
${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) {
|
||
const groupNames = ["All", ...Array.from(new Set(groups.items.map((item) => item.group)))];
|
||
return `
|
||
<div class="stack swap-zone">
|
||
<section class="panel">
|
||
${panelHeader("FPP catalog", "Filter blocks and list cards on mobile, richer panel on desktop.")}
|
||
<div class="pill-row">
|
||
${groupNames
|
||
.map(
|
||
(item) => `
|
||
<button
|
||
class="pill ${groups.filter === item ? "active" : ""}"
|
||
type="button"
|
||
hx-get="/fragments/fpp/list?group=${encodeURIComponent(item)}"
|
||
hx-target="#fpp-fragment"
|
||
hx-swap="innerHTML"
|
||
hx-push-url="true"
|
||
>
|
||
${escapeHtml(item)}
|
||
</button>
|
||
`,
|
||
)
|
||
.join("")}
|
||
</div>
|
||
</section>
|
||
<section class="grid grid-2">
|
||
${
|
||
groups.items.length
|
||
? 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 class="panel">${emptyState("No FPP items", "The API returned no catalog rows for this filter.")}</section>`
|
||
}
|
||
</section>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function renderPatients(session) {
|
||
const orders = await loadOrders(session, "", "All");
|
||
const recentPatients = Array.from(
|
||
new Map(orders.filter((order) => order.patient).map((order) => [order.patient, order])).values(),
|
||
).slice(0, 4);
|
||
return `
|
||
<div class="stack">
|
||
<section class="panel">
|
||
${panelHeader("Patient registration", "A landing zone for registration, lookup, and intake shortcuts.", '<a class="btn btn-primary" href="/orders/new/demografi">Start registration</a>')}
|
||
<div class="grid grid-4">
|
||
${[
|
||
["Today's intake", String(orders.length), "From API response"],
|
||
["Active visits", String(orders.filter((item) => item.status === "Processing").length), "From API response"],
|
||
["QR scans", String(orders.filter((item) => String(item.mode).toLowerCase().includes("qr")).length), "From API response"],
|
||
["Needs review", String(orders.filter((item) => item.status === "Needs review").length), "From API response"],
|
||
]
|
||
.map(
|
||
([label, value, hint]) => `
|
||
<article class="card metric">
|
||
<div class="kicker">
|
||
<span>${escapeHtml(label)}</span>
|
||
<span class="trend">Live</span>
|
||
</div>
|
||
<strong>${escapeHtml(value)}</strong>
|
||
<span class="muted">${escapeHtml(hint)}</span>
|
||
</article>
|
||
`,
|
||
)
|
||
.join("")}
|
||
</div>
|
||
</section>
|
||
<section class="detail-grid">
|
||
<div class="panel">
|
||
${panelHeader("Quick intake", "Route into the proper entry flow without forcing the user through extra screens.")}
|
||
<div class="grid grid-2">
|
||
<div class="card">
|
||
<strong>Lookup patient</strong>
|
||
<p class="muted">Search existing records and attach them to the next order.</p>
|
||
<div class="field" style="margin-top:12px">
|
||
<label><span class="muted">MRN / name</span></label>
|
||
<input placeholder="Siti Amelia" />
|
||
</div>
|
||
<button class="btn btn-secondary" type="button" style="margin-top:12px">Search</button>
|
||
</div>
|
||
<div class="card">
|
||
<strong>New patient</strong>
|
||
<p class="muted">Jump straight into demographic capture.</p>
|
||
<div class="pill-row" style="margin-top:12px">
|
||
<a class="pill" href="/orders/new/demografi">Demografi</a>
|
||
<a class="pill" href="/orders/new/qrcode">QR entry</a>
|
||
</div>
|
||
<div class="note-box" style="margin-top:14px">Use the registration stepper when the patient is not yet in the system.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<aside class="panel">
|
||
${panelHeader("Recent patients", "A compact list that stays readable on mobile and still works as a table on desktop.")}
|
||
${
|
||
recentPatients.length
|
||
? `
|
||
<div class="mini-list">
|
||
${recentPatients
|
||
.map(
|
||
(person, index) => `
|
||
<div class="mini-item">
|
||
<div>
|
||
<strong>${escapeHtml(person.patient || "Unknown patient")}</strong>
|
||
<p>${escapeHtml(person.id || `Order ${index + 1}`)} · ${escapeHtml(person.gender || "-")}</p>
|
||
</div>
|
||
<span class="pill">${escapeHtml(person.updated || "Recent")}</span>
|
||
</div>
|
||
`,
|
||
)
|
||
.join("")}
|
||
</div>
|
||
`
|
||
: emptyState("No patients returned", "The order endpoint did not return any patient rows.")
|
||
}
|
||
</aside>
|
||
</section>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderSettings(session) {
|
||
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(session?.username || "-")}</strong></div>
|
||
<div class="card"><span class="muted">Doctor ID</span><strong style="display:block; margin-top:8px">${escapeHtml(session?.doctorCode || session?.doctorId || "-")}</strong></div>
|
||
<div class="card"><span class="muted">Role</span><strong style="display:block; margin-top:8px">Doctor</strong></div>
|
||
<div class="card"><span class="muted">Internal ID</span><strong style="display:block; margin-top:8px">${escapeHtml(session?.doctorId || "-")}</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(session) {
|
||
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(session?.doctorCode || session?.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">
|
||
<h1>DocLink Pramita</h1>
|
||
</div>
|
||
<div class="auth-form">
|
||
${error ? `<div class="note-box">${escapeHtml(error)}</div>` : ""}
|
||
<form class="form" action="/login" method="post">
|
||
<label class="field"><span>Username</span><input name="username" value="${escapeHtml(sampleLogin.username)}" required /></label>
|
||
<label class="field"><span>Doctor ID</span><input name="doctor_id" value="${escapeHtml(sampleLogin.doctorId)}" required /></label>
|
||
<label class="field"><span>Password</span><input type="password" name="password" placeholder="${escapeHtml(sampleLogin.password)}" required /></label>
|
||
<button class="btn btn-primary" type="submit">${icon("login")} Login</button>
|
||
</form>
|
||
</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">
|
||
<h1>DocLink Pramita</h1>
|
||
</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">
|
||
<h1>DocLink Pramita</h1>
|
||
</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 = [], selectedOrderId = "" } = {}) {
|
||
return layout("Orders", `<div id="orders-fragment">${renderOrdersTable(orders, selectedOrderId, query.status || "All")}</div>`, {
|
||
authenticated: true,
|
||
activePath: "/orders",
|
||
});
|
||
}
|
||
|
||
function resultsPage({ query = {}, results = [], selectedResultId = "" } = {}) {
|
||
return layout("Results", `<div id="results-fragment">${renderResultsTable(results, selectedResultId)}</div>`, {
|
||
authenticated: true,
|
||
activePath: "/results",
|
||
});
|
||
}
|
||
|
||
function fppPage({ group = "All", groups = [] } = {}) {
|
||
return layout("FPP", `<div id="fpp-fragment">${renderFpp({ items: groups, filter: group })}</div>`, {
|
||
authenticated: true,
|
||
activePath: "/fpp",
|
||
});
|
||
}
|
||
|
||
async function patientsPage(session) {
|
||
return layout("Patients", await renderPatients(session), { authenticated: true, activePath: "/patients" });
|
||
}
|
||
|
||
function settingsPage(session) {
|
||
return layout("Settings", renderSettings(session), { authenticated: true, activePath: "/settings" });
|
||
}
|
||
|
||
function changePasswordPage(session) {
|
||
return layout("Change Password", renderChangePassword(session), {
|
||
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 text = await response.text();
|
||
let body = text;
|
||
if (contentType.includes("application/json") || /^\s*[\[{]/.test(text)) {
|
||
try {
|
||
body = JSON.parse(text);
|
||
} catch {
|
||
body = 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 user = data?.user || data?.data || {};
|
||
const token = data?.token || data?.access_token || data?.accessToken || payload?.token || "";
|
||
const username = user?.M_UserUsername || data?.username || data?.M_UserUsername || data?.name || "";
|
||
const doctorId = user?.M_UserM_DoctorID || data?.doctor_id || data?.doctorId || "";
|
||
const doctorCode = user?.M_UserM_DoctorCode || data?.doctor_code || "";
|
||
const userId = user?.M_UserID || data?.M_UserID || data?.user_id || "";
|
||
return {
|
||
token,
|
||
username,
|
||
doctorId,
|
||
doctorCode,
|
||
userId,
|
||
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 status = raw?.status || raw?.order_status || raw?.state || "Processing";
|
||
return {
|
||
id: raw?.order_patient_id || raw?.order_id || raw?.id || raw?.OrderPatientID || raw?.OrderID || "",
|
||
patient: raw?.order_name || raw?.patient_name || raw?.name || raw?.patient || raw?.patient_fullname || "",
|
||
doctor: raw?.doctor_id || raw?.doctor_name || raw?.doctor || raw?.M_UserUsername || "",
|
||
updated: raw?.order_date || raw?.updated_at || raw?.updated || raw?.created_at || "",
|
||
status,
|
||
tone: statusClass(status),
|
||
mode: raw?.mode || raw?.visit_type || raw?.patient_type || "",
|
||
age: String(raw?.age || raw?.patient_age || ""),
|
||
gender: raw?.gender || raw?.patient_gender || "",
|
||
tests: [],
|
||
diagnosis: raw?.order_nik || raw?.patient_diagnosa || raw?.diagnosis || raw?.note || "",
|
||
message: raw?.order_address || raw?.message || raw?.patient_note || "",
|
||
orderDate: raw?.order_date || "",
|
||
orderNik: raw?.order_nik || "",
|
||
orderHp: raw?.order_hp || "",
|
||
orderAddress: raw?.order_address || "",
|
||
orderDob: raw?.order_dob || "",
|
||
};
|
||
}
|
||
|
||
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 || "",
|
||
patient: raw?.patient_name || raw?.name || raw?.patient || "",
|
||
test: raw?.test_name || raw?.item_name || raw?.test || "",
|
||
status,
|
||
tone: statusClass(status),
|
||
date: raw?.date || raw?.created_at || raw?.updated_at || "",
|
||
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 || sampleLogin.doctorId,
|
||
search: term,
|
||
},
|
||
session.token,
|
||
);
|
||
const rows = extractArray(payload) || [];
|
||
return rows.map((row, index) => normalizeOrder(row, index)).filter((item) => {
|
||
if (!item.id) return false;
|
||
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;
|
||
});
|
||
} catch {
|
||
return [];
|
||
}
|
||
return [];
|
||
}
|
||
|
||
async function loadResults(session, search = "") {
|
||
const term = String(search || "").trim();
|
||
try {
|
||
const orders = await loadOrders(session, "", "All");
|
||
const orderId = session.orderId || orders[0]?.id || "";
|
||
const payload = await apiPost(
|
||
"/order/hasil_belum_keluar_by_id",
|
||
{ token: session.token, order_id: orderId },
|
||
session.token,
|
||
);
|
||
const rows = extractArray(payload) || [];
|
||
return rows.map((row, index) => normalizeResult(row, index)).filter((item) => {
|
||
if (!item.id) return false;
|
||
if (!term) return true;
|
||
return [item.id, item.patient, item.test, item.status, item.summary].some((value) =>
|
||
String(value).toLowerCase().includes(term.toLowerCase()),
|
||
);
|
||
});
|
||
} catch {
|
||
return [];
|
||
}
|
||
return [];
|
||
}
|
||
|
||
async function loadResultDetail(session, resultId) {
|
||
try {
|
||
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 normalizeResult(payload, 0);
|
||
const related = await loadResults(session, resultId);
|
||
return related.find((item) => item.id === resultId) || null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
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.",
|
||
}));
|
||
const filtered = group === "All" ? items : items.filter((item) => item.group === group);
|
||
return { items: filtered, filter: group };
|
||
} catch {
|
||
return { items: [], filter: group };
|
||
}
|
||
}
|
||
|
||
async function loadOrderDetail(session, orderId) {
|
||
const [orders, saran, hasil] = await Promise.all([
|
||
loadOrders(session, "", "All"),
|
||
apiPost(
|
||
"/order/get_order_saran_by_order_patient_id",
|
||
{ token: session.token, order_patient_id: orderId },
|
||
session.token,
|
||
).catch(() => null),
|
||
apiPost(
|
||
"/order/hasil_belum_keluar_by_id",
|
||
{ token: session.token, order_id: orderId },
|
||
session.token,
|
||
).catch(() => null),
|
||
]);
|
||
const order = orders.find((item) => item.id === orderId) || orders[0] || null;
|
||
if (!order) return null;
|
||
const detail = { ...order };
|
||
try {
|
||
detail.apiSaran = saran ? extractArray(saran) || saran?.message || saran?.note || saran?.result || "" : "";
|
||
detail.apiHasil = hasil ? extractArray(hasil) || hasil?.message || hasil?.note || hasil?.result || "" : "";
|
||
} catch {
|
||
detail.apiSaran = "";
|
||
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 fragmentOrdersTable(search = "", status = "All", ordersData = null) {
|
||
const orders = ordersData || [];
|
||
const selected = orders[0] || null;
|
||
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>
|
||
${
|
||
orders.length
|
||
? `
|
||
<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 || "Unknown 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>
|
||
`
|
||
: emptyState("No orders returned", "The order endpoint did not return any rows for this filter.")
|
||
}
|
||
</section>
|
||
<aside class="panel">
|
||
${panelHeader("Selected order", "Context stays visible while you review the table.", selected ? `<a class="btn btn-secondary" href="/orders/${selected.id}">Open detail</a>` : "")}
|
||
${
|
||
selected
|
||
? `
|
||
<div class="stack">
|
||
<div class="card">
|
||
<strong>${escapeHtml(selected.patient || "Unknown 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>
|
||
`
|
||
: emptyState("No selected order", "The API returned no rows for this search.")
|
||
}
|
||
</aside>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function fragmentResultsTable(search = "", resultsData = null) {
|
||
const results = resultsData || [];
|
||
const selected = results[0] || null;
|
||
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">${results.filter((item) => item.status === "Released").length} items</p></div>
|
||
<div class="card"><strong>Pending</strong><p class="muted">${results.filter((item) => item.status === "Pending").length} items</p></div>
|
||
<div class="card"><strong>Reviewed</strong><p class="muted">${results.filter((item) => item.status === "Review").length} items</p></div>
|
||
</div>
|
||
${
|
||
results.length
|
||
? `
|
||
<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 || "Unknown 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>
|
||
`
|
||
: emptyState("No results returned", "The result endpoint did not return any rows for this search.")
|
||
}
|
||
</section>
|
||
<aside class="panel">
|
||
${panelHeader("Selected result", "Use the right pane for quick context without losing list position.", selected ? `<a class="btn btn-secondary" href="/results/${selected.id}">Open detail</a>` : "")}
|
||
${
|
||
selected
|
||
? `
|
||
<div class="stack">
|
||
<div class="card">
|
||
<strong>${escapeHtml(selected.patient || "Unknown 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>
|
||
`
|
||
: emptyState("No selected result", "The API returned no rows for this search.")
|
||
}
|
||
</aside>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function fragmentFpp(group = "All", itemsData = null) {
|
||
const items = itemsData || [];
|
||
const groups = ["All", ...Array.from(new Set(items.map((item) => item.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">
|
||
${groups
|
||
.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.length
|
||
? 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 class="panel">${emptyState("No FPP items", "The API returned no catalog rows for this filter.")}</section>`
|
||
}
|
||
</section>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function fragmentOrderStep(step) {
|
||
return renderOrderForm({}, step);
|
||
}
|
||
|
||
function fragmentPesanKhusus(orderId) {
|
||
const order = orderId
|
||
? { id: orderId, patient: "", status: "Processing", message: "", apiSaran: "" }
|
||
: { id: "", patient: "", status: "Processing", message: "", apiSaran: "" };
|
||
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 || "Order detail")}</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 || "",
|
||
doctorCode: normalized.doctorCode || body.doctor_id || "",
|
||
userId: normalized.userId || "",
|
||
raw: payload,
|
||
});
|
||
redirect(res, "/", {
|
||
"Set-Cookie": setCookie(sessionKey, sessionValue, { maxAge: 60 * 60 * 12 }),
|
||
});
|
||
} catch (error) {
|
||
html(res, 200, loginPage({ error: `Login failed: ${error.message}. 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.userId || "",
|
||
M_UserUsername: sessionData.username || sampleLogin.username,
|
||
},
|
||
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.userId || "",
|
||
username: sessionData.username || sampleLogin.username,
|
||
doctor_id: sessionData.doctorCode || sessionData.doctorId || sampleLogin.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 || "" }));
|
||
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 || "" }));
|
||
return;
|
||
}
|
||
|
||
if (path === "/results/historical" && isGet) {
|
||
if (!requireAuth(req, res)) return;
|
||
const results = await loadResults(session, "");
|
||
html(res, 200, layout("Historical results", `<section class="panel">${panelHeader("Historical results", "A compact historic view that still fits the responsive shell.")}${results.length ? `<div class="mini-list">${results.map((item) => `<a class="mini-item" href="/results/${item.id}"><div><strong>${escapeHtml(item.patient || "Unknown patient")}</strong><p>${escapeHtml(item.test || "-")} · ${escapeHtml(item.date || "-")}</p></div>${statusBadge(item.status)}</a>`).join("")}</div>` : emptyState("No results returned", "The API returned no historical result rows.")}</section>`, { authenticated: true, activePath: "/results" }));
|
||
return;
|
||
}
|
||
|
||
if (path === "/results/pending" && isGet) {
|
||
if (!requireAuth(req, res)) return;
|
||
const results = (await loadResults(session, "")).filter((item) => item.status !== "Released");
|
||
html(res, 200, layout("Pending results", `<section class="panel">${panelHeader("Pending results", "Items that need attention before release.")}${results.length ? `<div class="grid grid-2">${results.map((item) => `<article class="card"><div class="topbar-actions" style="justify-content:space-between"><strong>${escapeHtml(item.patient || "Unknown 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>` : emptyState("No pending results", "The API returned no unreleased rows.")}</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, await patientsPage(session));
|
||
return;
|
||
}
|
||
|
||
if ((path === "/settings" || path === "/settings/account") && isGet) {
|
||
if (!requireAuth(req, res)) return;
|
||
html(res, 200, settingsPage(session));
|
||
return;
|
||
}
|
||
|
||
if (path === "/settings/change-password" && isGet) {
|
||
if (!requireAuth(req, res)) return;
|
||
html(res, 200, changePasswordPage(session));
|
||
return;
|
||
}
|
||
|
||
if (path === "/" && isGet) {
|
||
if (!requireAuth(req, res)) return;
|
||
html(res, 200, layout("Dashboard", await dashboardPage(session), { 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);
|
||
if (!order) {
|
||
html(res, 200, layout("Pesan Khusus", `<section class="panel">${emptyState("No order found", "The API returned no matching order for this ID.")}</section>`, { authenticated: true, activePath: "/orders" }));
|
||
return;
|
||
}
|
||
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);
|
||
if (!order) {
|
||
html(res, 200, layout("Order Detail", `<section class="panel">${emptyState("No order found", "The API returned no matching order for this ID.")}</section>`, { authenticated: true, activePath: "/orders" }));
|
||
return;
|
||
}
|
||
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);
|
||
if (!result) {
|
||
html(res, 200, layout("Result Detail", `<section class="panel">${emptyState("No result found", "The API returned no matching result for this ID.")}</section>`, { authenticated: true, activePath: "/results" }));
|
||
return;
|
||
}
|
||
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 || ""));
|
||
return;
|
||
}
|
||
|
||
if (path.startsWith("/fragments/results/detail/") && isGet) {
|
||
if (!requireAuth(req, res)) return;
|
||
const resultId = path.split("/")[4] || "";
|
||
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 || sessionData.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}`);
|
||
});
|