2624 lines
110 KiB
JavaScript
2624 lines
110 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 BASE_PATH = normalizeBasePath(process.env.DOCLINK_BASE_PATH || "");
|
|
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",
|
|
mouId: "2773",
|
|
password: "123456",
|
|
};
|
|
|
|
function escapeHtml(value) {
|
|
return String(value)
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """);
|
|
}
|
|
|
|
function normalizeBasePath(value) {
|
|
const raw = String(value || "").trim();
|
|
if (!raw || raw === "/") return "";
|
|
const withLeadingSlash = raw.startsWith("/") ? raw : `/${raw}`;
|
|
return withLeadingSlash.replace(/\/+$/, "");
|
|
}
|
|
|
|
function appPath(pathname = "/") {
|
|
const text = String(pathname || "/");
|
|
if (/^https?:\/\//i.test(text) || text.startsWith("//")) return text;
|
|
if (!BASE_PATH) return text.startsWith("/") ? text : `/${text}`;
|
|
if (text === BASE_PATH || text.startsWith(`${BASE_PATH}/`)) return text;
|
|
if (text === "/" || text === "") return BASE_PATH;
|
|
if (text.startsWith("/")) return `${BASE_PATH}${text}`;
|
|
return `${BASE_PATH}/${text}`;
|
|
}
|
|
|
|
function stripBasePath(pathname) {
|
|
const path = String(pathname || "/");
|
|
if (!BASE_PATH) return path || "/";
|
|
if (path === BASE_PATH) return "/";
|
|
if (path.startsWith(`${BASE_PATH}/`)) return path.slice(BASE_PATH.length) || "/";
|
|
return path;
|
|
}
|
|
|
|
function rewriteHtmlPaths(html) {
|
|
if (!BASE_PATH || typeof html !== "string") return html;
|
|
return html.replace(/\b(href|src|action|hx-get|hx-post)="\/(?!\/)/g, `$1="${BASE_PATH}/`);
|
|
}
|
|
|
|
function statusClass(status) {
|
|
const mapping = {
|
|
Processing: "warning",
|
|
Ready: "success",
|
|
"Needs review": "danger",
|
|
Released: "success",
|
|
Pending: "warning",
|
|
Review: "danger",
|
|
Confirmed: "success",
|
|
Unconfirmed: "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, accountName = "", accountMeta = "" } = {}) {
|
|
const nav = authenticated ? desktopNav(activePath) : "";
|
|
const mobile = authenticated ? mobileNav(activePath) : "";
|
|
const header = authenticated ? topbar(activePath, subtitle, accountName, accountMeta) : "";
|
|
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 () {
|
|
var ORDER_DRAFT_KEY = 'doclink.orderDraft';
|
|
|
|
function readOrderDraft() {
|
|
try {
|
|
var raw = sessionStorage.getItem(ORDER_DRAFT_KEY);
|
|
var draft = raw ? JSON.parse(raw) : {};
|
|
if (!draft || typeof draft !== 'object') return { fields: {}, details: [] };
|
|
if (!draft.fields || typeof draft.fields !== 'object') draft.fields = {};
|
|
if (!Array.isArray(draft.details)) draft.details = [];
|
|
return draft;
|
|
} catch (error) {
|
|
return { fields: {}, details: [] };
|
|
}
|
|
}
|
|
|
|
function writeOrderDraft(draft) {
|
|
sessionStorage.setItem(ORDER_DRAFT_KEY, JSON.stringify(draft));
|
|
}
|
|
|
|
function isOrderField(element) {
|
|
return element && element.closest && element.closest('[data-order-form]');
|
|
}
|
|
|
|
function updateSelectedCount(form) {
|
|
if (!form) return;
|
|
var target = form.querySelector('#order-selected-count');
|
|
if (!target) return;
|
|
var draft = readOrderDraft();
|
|
target.textContent = (draft.details || []).length + ' selected';
|
|
}
|
|
|
|
function syncOrderDraft(form) {
|
|
if (!form) return;
|
|
var draft = readOrderDraft();
|
|
form.querySelectorAll('input[name], textarea[name], select[name]').forEach(function (field) {
|
|
if (!field.name) return;
|
|
if (field.classList && field.classList.contains('fpp-select-toggle')) return;
|
|
if (field.type === 'button' || field.type === 'submit' || field.type === 'reset') return;
|
|
if (field.type === 'radio' && !field.checked) return;
|
|
if (field.type === 'checkbox') {
|
|
draft.fields[field.name] = field.checked ? '1' : '0';
|
|
return;
|
|
}
|
|
draft.fields[field.name] = field.value;
|
|
});
|
|
|
|
var details = [];
|
|
form.querySelectorAll('.fpp-select-toggle:checked').forEach(function (checkbox) {
|
|
details.push({
|
|
testId: checkbox.getAttribute('data-test-id') || '',
|
|
testName: checkbox.getAttribute('data-test-name') || '',
|
|
price: checkbox.getAttribute('data-test-price') || '0',
|
|
heading: checkbox.getAttribute('data-test-heading') || '',
|
|
subcategory: checkbox.getAttribute('data-test-subcategory') || '',
|
|
});
|
|
});
|
|
draft.details = details;
|
|
writeOrderDraft(draft);
|
|
updateSelectedCount(form);
|
|
}
|
|
|
|
function restoreOrderForm(form) {
|
|
if (!form) return;
|
|
var draft = readOrderDraft();
|
|
Object.keys(draft.fields || {}).forEach(function (name) {
|
|
var fields = form.querySelectorAll('[name="' + name.replace(/"/g, '\\"') + '"]');
|
|
if (!fields.length) return;
|
|
fields.forEach(function (field) {
|
|
if (field.classList && field.classList.contains('fpp-select-toggle')) return;
|
|
if (field.type === 'checkbox') {
|
|
field.checked = draft.fields[name] === '1' || draft.fields[name] === 'true';
|
|
return;
|
|
}
|
|
if (field.type === 'radio') {
|
|
field.checked = field.value === draft.fields[name];
|
|
return;
|
|
}
|
|
field.value = draft.fields[name];
|
|
});
|
|
});
|
|
|
|
var selectedIds = new Set((draft.details || []).map(function (item) { return item.testId; }));
|
|
form.querySelectorAll('.fpp-select-toggle').forEach(function (checkbox) {
|
|
checkbox.checked = selectedIds.has(checkbox.getAttribute('data-test-id') || '');
|
|
});
|
|
updateSelectedCount(form);
|
|
}
|
|
|
|
function injectOrderDraftPayload(form) {
|
|
var draft = readOrderDraft();
|
|
var payload = form.querySelector('[data-order-draft-payload]');
|
|
if (payload) payload.remove();
|
|
payload = document.createElement('div');
|
|
payload.hidden = true;
|
|
payload.setAttribute('data-order-draft-payload', 'true');
|
|
|
|
var currentNames = new Set();
|
|
form.querySelectorAll('input[name], textarea[name], select[name]').forEach(function (field) {
|
|
if (field.name) currentNames.add(field.name);
|
|
});
|
|
|
|
var mouId = form.getAttribute('data-mou-id') || '';
|
|
if (mouId && !currentNames.has('M_MouID')) {
|
|
var mouInput = document.createElement('input');
|
|
mouInput.type = 'hidden';
|
|
mouInput.name = 'M_MouID';
|
|
mouInput.value = mouId;
|
|
payload.appendChild(mouInput);
|
|
}
|
|
|
|
Object.keys(draft.fields || {}).forEach(function (name) {
|
|
if (currentNames.has(name)) return;
|
|
var value = draft.fields[name];
|
|
var input = document.createElement('input');
|
|
input.type = 'hidden';
|
|
input.name = name;
|
|
input.value = value == null ? '' : String(value);
|
|
payload.appendChild(input);
|
|
});
|
|
|
|
(draft.details || []).forEach(function (detail, index) {
|
|
var fields = {
|
|
test_id: detail.testId || '',
|
|
test_name: detail.testName || '',
|
|
price: detail.price || '0',
|
|
};
|
|
Object.keys(fields).forEach(function (key) {
|
|
var input = document.createElement('input');
|
|
input.type = 'hidden';
|
|
input.name = 'details[' + index + '][' + key + ']';
|
|
input.value = String(fields[key] || '');
|
|
payload.appendChild(input);
|
|
});
|
|
});
|
|
|
|
form.appendChild(payload);
|
|
}
|
|
|
|
function closeModal() {
|
|
var modalRoot = document.getElementById('modal-root');
|
|
if (modalRoot) modalRoot.innerHTML = '';
|
|
}
|
|
|
|
function normalizeDateForInput(value) {
|
|
var text = String(value || '').trim();
|
|
if (!text || text === 'null' || text === 'undefined') return '';
|
|
var iso = text.match(/^(\\d{4})-(\\d{2})-(\\d{2})$/);
|
|
if (iso) return iso[0];
|
|
var slash = text.match(/^(\\d{2})\\/(\\d{2})\\/(\\d{4})$/);
|
|
if (slash) return slash[3] + '-' + slash[2] + '-' + slash[1];
|
|
var dash = text.match(/^(\\d{2})-(\\d{2})-(\\d{4})$/);
|
|
if (dash) return dash[3] + '-' + dash[2] + '-' + dash[1];
|
|
return '';
|
|
}
|
|
|
|
function fillPatientFromPick(button) {
|
|
var form = document.querySelector('[data-order-form]');
|
|
if (!form || !button) return;
|
|
var fields = {
|
|
patient_name: button.getAttribute('data-patient-name') || '',
|
|
patient_dob: normalizeDateForInput(button.getAttribute('data-patient-dob') || ''),
|
|
patient_nik: button.getAttribute('data-patient-nik') || '',
|
|
patient_hp: button.getAttribute('data-patient-hp') || '',
|
|
patient_address: button.getAttribute('data-patient-address') || '',
|
|
};
|
|
Object.keys(fields).forEach(function (name) {
|
|
var field = form.querySelector('[name="' + name.replace(/"/g, '\\"') + '"]');
|
|
if (!field) return;
|
|
field.value = fields[name];
|
|
field.dispatchEvent(new Event('input', { bubbles: true }));
|
|
field.dispatchEvent(new Event('change', { bubbles: true }));
|
|
});
|
|
syncOrderDraft(form);
|
|
closeModal();
|
|
}
|
|
|
|
document.addEventListener('input', function (event) {
|
|
var form = isOrderField(event.target);
|
|
if (!form) return;
|
|
syncOrderDraft(form);
|
|
});
|
|
document.addEventListener('change', function (event) {
|
|
var form = isOrderField(event.target);
|
|
if (!form) return;
|
|
syncOrderDraft(form);
|
|
});
|
|
document.addEventListener('submit', function (event) {
|
|
var form = event.target;
|
|
if (!(form instanceof HTMLFormElement) || !form.matches('[data-order-form]')) return;
|
|
var step = form.getAttribute('data-order-step') || '';
|
|
if (step !== 'pemeriksaan') {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
var draft = readOrderDraft();
|
|
if (!draft.details || !draft.details.length) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
injectOrderDraftPayload(form);
|
|
});
|
|
document.addEventListener('click', function (event) {
|
|
var patientPick = event.target.closest && event.target.closest('[data-patient-pick]');
|
|
if (patientPick) {
|
|
event.preventDefault();
|
|
fillPatientFromPick(patientPick);
|
|
return;
|
|
}
|
|
var printTrigger = event.target.closest && event.target.closest('[data-action="print-order"]');
|
|
if (printTrigger) {
|
|
event.preventDefault();
|
|
document.body.classList.add('print-order');
|
|
window.setTimeout(function () {
|
|
window.print();
|
|
}, 50);
|
|
return;
|
|
}
|
|
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();
|
|
});
|
|
window.addEventListener('afterprint', function () {
|
|
document.body.classList.remove('print-order');
|
|
});
|
|
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.id === 'order-step-fragment') {
|
|
var form = document.querySelector('[data-order-form]');
|
|
if (form) restoreOrderForm(form);
|
|
}
|
|
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);
|
|
}
|
|
});
|
|
var orderForm = document.querySelector('[data-order-form]');
|
|
if (orderForm) restoreOrderForm(orderForm);
|
|
if (document.querySelector('[data-order-saved="true"]')) {
|
|
sessionStorage.removeItem(ORDER_DRAFT_KEY);
|
|
}
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function topbar(activePath, subtitle, accountName = "") {
|
|
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 displayName = accountName || sampleLogin.username;
|
|
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">
|
|
<a class="btn btn-primary" href="/orders/new">${icon("plus")} New order</a>
|
|
<a class="btn btn-secondary account-chip" href="/settings">
|
|
<span class="account-avatar" aria-hidden="true">DL</span>
|
|
<span class="account-copy">
|
|
<strong>${escapeHtml(displayName)}</strong>
|
|
</span>
|
|
</a>
|
|
</div>
|
|
</header>
|
|
`;
|
|
}
|
|
|
|
function desktopNav(activePath) {
|
|
const items = [
|
|
["/", "Home", "Dashboard"],
|
|
["/orders", "Order", "Create & list"],
|
|
["/results", "Result", "History"],
|
|
];
|
|
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>
|
|
</div>
|
|
<div class="sidebar-footer sidebar-footer-logo">
|
|
<img src="/logo.png" alt="Pramita Lab" class="sidebar-logo-image" />
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
`;
|
|
}
|
|
|
|
function mobileNav(activePath) {
|
|
const items = [
|
|
["/", "Home", "Dashboard"],
|
|
["/orders", "Order", "Create"],
|
|
["/results", "Result", "History"],
|
|
];
|
|
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 resolveFppRouteIds(session) {
|
|
return {
|
|
doctorId: String(session?.doctorId || sampleLogin.doctorId || "1"),
|
|
mouId: String(session?.mouId || sampleLogin.mouId || "1"),
|
|
};
|
|
}
|
|
|
|
function accountLayoutOptions(session = {}) {
|
|
return {
|
|
accountName: session?.displayName || session?.username || sampleLogin.username,
|
|
};
|
|
}
|
|
|
|
async function dashboardPage(session) {
|
|
const orders = await loadDesktopOrders(session, { currentPage: 1, month: new Date().getMonth() + 1, year: new Date().getFullYear() });
|
|
const stats = [
|
|
{ label: "Orders this month", value: String(orders.items.length), trend: `${orders.month}/${orders.year}`, hint: "" },
|
|
{ label: "Confirmed", value: String(orders.items.filter((item) => item.status === "Confirmed").length), trend: "Home", hint: "" },
|
|
{ label: "Unconfirmed", value: String(orders.items.filter((item) => item.status === "Unconfirmed").length), trend: "Home", hint: "" },
|
|
];
|
|
return `
|
|
<div class="stack">
|
|
<section class="grid grid-3">
|
|
${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>
|
|
${item.hint ? `<span class="muted">${escapeHtml(item.hint)}</span>` : ""}
|
|
</article>
|
|
`,
|
|
)
|
|
.join("")}
|
|
</section>
|
|
<section class="panel">
|
|
${panelHeader("Recent orders", "", '<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.items || []).length
|
|
? orders.items
|
|
.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 || "Unconfirmed")}</td>
|
|
<td>${escapeHtml(order.updated || "-")}</td>
|
|
</tr>
|
|
`,
|
|
)
|
|
.join("")
|
|
: `<tr><td colspan="4">${emptyState("No orders returned", "The desktop order endpoint did not return any rows for this page.")}</td></tr>`}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderOrdersTable({
|
|
items = [],
|
|
search = "",
|
|
month = "",
|
|
year = "",
|
|
currentPage = 1,
|
|
hasNext = false,
|
|
hasPrev = false,
|
|
selectedOrderId = "",
|
|
} = {}) {
|
|
const now = new Date();
|
|
const resolvedMonth = String(month || now.getMonth() + 1).padStart(2, "0");
|
|
const resolvedYear = String(year || now.getFullYear());
|
|
const monthOptions = Array.from({ length: 12 }, (_, index) => {
|
|
const value = String(index + 1).padStart(2, "0");
|
|
return `<option value="${value}" ${value === resolvedMonth ? "selected" : ""}>${value}</option>`;
|
|
}).join("");
|
|
const yearStart = Number(resolvedYear) - 1;
|
|
const yearOptions = Array.from({ length: 3 }, (_, index) => {
|
|
const value = String(yearStart + index);
|
|
return `<option value="${value}" ${value === resolvedYear ? "selected" : ""}>${value}</option>`;
|
|
}).join("");
|
|
const prevPage = Math.max(1, Number(currentPage) - 1);
|
|
const nextPage = Math.max(1, Number(currentPage) + 1);
|
|
const selected = items.find((item) => item.id === selectedOrderId) || items[0] || null;
|
|
const selectedId = selected?.id || "";
|
|
return `
|
|
<div class="detail-grid">
|
|
<section class="panel">
|
|
${panelHeader("Orders", "Monthly order list from the desktop endpoint with name, month, and year filters.", '<a class="btn btn-primary" href="/orders/new">Create order</a>')}
|
|
<form class="card" hx-get="/fragments/orders/table" hx-target="#orders-fragment" hx-push-url="true">
|
|
<div class="grid grid-3">
|
|
<label class="field"><span>Search patient</span><input name="search" type="search" value="${escapeHtml(search)}" placeholder="ANDI SETADI" /></label>
|
|
<label class="field"><span>Month</span><select name="month">${monthOptions}</select></label>
|
|
<label class="field"><span>Year</span><select name="year">${yearOptions}</select></label>
|
|
</div>
|
|
<input type="hidden" name="current_page" value="1" />
|
|
<input type="hidden" name="selected" value="${escapeHtml(selectedId)}" />
|
|
<div class="topbar-actions" style="justify-content:flex-end; margin-top:14px">
|
|
<button class="btn btn-primary" type="submit">Apply filter</button>
|
|
</div>
|
|
</form>
|
|
${
|
|
items.length
|
|
? `
|
|
<div class="table-wrap" style="margin-top:16px">
|
|
<table>
|
|
<thead>
|
|
<tr><th>Patient</th><th>Order ID</th><th>Order QR</th><th>Date</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
${items
|
|
.map(
|
|
(order) => `
|
|
<tr
|
|
class="order-row ${selectedId === order.id ? "active" : ""}"
|
|
hx-get="/fragments/orders/table?search=${encodeURIComponent(search)}&month=${encodeURIComponent(resolvedMonth)}&year=${encodeURIComponent(resolvedYear)}¤t_page=${encodeURIComponent(String(currentPage || 1))}&selected=${encodeURIComponent(order.id)}"
|
|
hx-target="#orders-fragment"
|
|
hx-push-url="true"
|
|
hx-trigger="click"
|
|
>
|
|
<td><strong>${escapeHtml(order.patient || "Unknown patient")}</strong><br /><span class="muted">${escapeHtml(order.diagnosis || "-")}</span></td>
|
|
<td>${escapeHtml(order.id || "-")}</td>
|
|
<td>${escapeHtml(order.qrcode || "-")}</td>
|
|
<td>${escapeHtml(order.updated || "-")}</td>
|
|
</tr>
|
|
`,
|
|
)
|
|
.join("")}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`
|
|
: emptyState("No orders returned", "The desktop order endpoint did not return any rows for this filter.")
|
|
}
|
|
<div class="topbar-actions" style="justify-content:space-between; margin-top:16px">
|
|
<button
|
|
class="btn btn-secondary"
|
|
type="button"
|
|
hx-get="/fragments/orders/table?search=${encodeURIComponent(search)}&month=${encodeURIComponent(resolvedMonth)}&year=${encodeURIComponent(resolvedYear)}¤t_page=${encodeURIComponent(String(prevPage))}&selected=${encodeURIComponent(selectedId)}"
|
|
hx-target="#orders-fragment"
|
|
hx-push-url="true"
|
|
${hasPrev ? "" : "disabled"}
|
|
>Previous</button>
|
|
<span class="pill">Page ${escapeHtml(String(currentPage || 1))}</span>
|
|
<button
|
|
class="btn btn-secondary"
|
|
type="button"
|
|
hx-get="/fragments/orders/table?search=${encodeURIComponent(search)}&month=${encodeURIComponent(resolvedMonth)}&year=${encodeURIComponent(resolvedYear)}¤t_page=${encodeURIComponent(String(nextPage))}&selected=${encodeURIComponent(selectedId)}"
|
|
hx-target="#orders-fragment"
|
|
hx-push-url="true"
|
|
${hasNext ? "" : "disabled"}
|
|
>Next</button>
|
|
</div>
|
|
</section>
|
|
<aside class="panel">
|
|
${panelHeader("Selected order", "Click a row to update the detail pane. The row you pick is highlighted.", selected ? `<span class="pill">Selected</span>` : "")}
|
|
${
|
|
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">
|
|
${selected.status ? statusBadge(selected.status) : ""}
|
|
<span class="pill">${escapeHtml(selected.qrcode || "-")}</span>
|
|
</div>
|
|
</div>
|
|
<div class="mini-list">
|
|
<div class="mini-item"><div><strong>NIK</strong><p>${escapeHtml(selected.orderNik || "-")}</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 || "-")}</p></div></div>
|
|
<div class="mini-item"><div><strong>Diagnosis</strong><p>${escapeHtml(selected.diagnosis || "-")}</p></div></div>
|
|
<div class="mini-item"><div><strong>Note</strong><p>${escapeHtml(selected.message || "-")}</p></div></div>
|
|
</div>
|
|
</div>
|
|
`
|
|
: emptyState("No selected order", "Click one row on the left to see its detail here.")
|
|
}
|
|
</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 qrImageUrl(value, size = 240) {
|
|
const data = encodeURIComponent(String(value || ""));
|
|
return `https://api.qrserver.com/v1/create-qr-code/?size=${size}x${size}&data=${data}`;
|
|
}
|
|
|
|
function formatOrderDisplayDate(value) {
|
|
const text = String(value || "").trim();
|
|
const match = text.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}:\d{2}:\d{2}))?$/);
|
|
if (!match) return text;
|
|
return `${match[3]}-${match[2]}-${match[1]}${match[4] ? ` ${match[4]}` : ""}`;
|
|
}
|
|
|
|
function renderOrderSaved(order) {
|
|
const details = Array.isArray(order.details) ? order.details : [];
|
|
const qrText = order.orderCode || order.id || "";
|
|
const printableDate = formatOrderDisplayDate(order.orderDate || "");
|
|
const printableDoctor = order.doctorName || order.doctor || sampleLogin.username;
|
|
return `
|
|
<div class="stack order-created-shell" data-order-saved="true">
|
|
<section class="panel">
|
|
${panelHeader(
|
|
"QR Step",
|
|
"Order is saved. Share it to WhatsApp or print the slip below.",
|
|
`<div class="pill-row">
|
|
<a class="btn btn-secondary" href="/orders/${escapeHtml(order.id || "")}">Open detail</a>
|
|
<a class="btn btn-primary" href="/orders/new">Create new order</a>
|
|
</div>`,
|
|
)}
|
|
<div class="detail-grid">
|
|
<div class="card order-created-qr">
|
|
<div class="qr-slip">
|
|
<div class="qr-slip-header">
|
|
<div class="qr-slip-label">Tanggal Order</div>
|
|
<div class="qr-slip-value">${escapeHtml(printableDate || "-")}</div>
|
|
<div class="qr-slip-label">Nama</div>
|
|
<div class="qr-slip-value">${escapeHtml(order.patient || "-")}</div>
|
|
<div class="qr-slip-label">Dokter</div>
|
|
<div class="qr-slip-value">${escapeHtml(printableDoctor)}</div>
|
|
</div>
|
|
<div class="qr-slip-section-title">Pemeriksaan</div>
|
|
<div class="qr-slip-tests">
|
|
${
|
|
details.length
|
|
? details
|
|
.map(
|
|
(item) => `<div class="qr-slip-test">- ${escapeHtml(item.test_name || item.testName || "-")}</div>`,
|
|
)
|
|
.join("")
|
|
: `<div class="qr-slip-test muted">-</div>`
|
|
}
|
|
</div>
|
|
<div class="qr-preview">
|
|
${
|
|
qrText
|
|
? `<img class="image_qrcode" src="${qrImageUrl(qrText, 320)}" alt="QR code for order ${escapeHtml(qrText)}" loading="lazy" />`
|
|
: `<div class="note-box">QR code not available.</div>`
|
|
}
|
|
</div>
|
|
<div class="qr-code-text">${escapeHtml(qrText || "-")}</div>
|
|
<div class="pill-row qr-actions">
|
|
${
|
|
qrText
|
|
? `<a class="btn btn-secondary" href="${escapeHtml(
|
|
`https://wa.me/?text=${encodeURIComponent(
|
|
`Order ${order.id || ""} | ${order.patient || ""} | QR ${qrText}`,
|
|
)}`,
|
|
)}" target="_blank" rel="noreferrer">Share WhatsApp</a>`
|
|
: ""
|
|
}
|
|
<button class="btn btn-primary" type="button" data-action="print-order">Print</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="card">
|
|
<strong>Review</strong>
|
|
<p class="muted">Saved payload summary from the backend response.</p>
|
|
<div class="mini-list" style="margin-top:12px">
|
|
<div class="mini-item"><div><strong>Patient</strong><p>${escapeHtml(order.patient || "-")}</p></div></div>
|
|
<div class="mini-item"><div><strong>Diagnosis</strong><p>${escapeHtml(order.diagnosis || "-")}</p></div></div>
|
|
<div class="mini-item"><div><strong>Notes</strong><p>${escapeHtml(order.message || "-")}</p></div></div>
|
|
<div class="mini-item"><div><strong>Saved at</strong><p>${escapeHtml(order.orderDate || "-")}</p></div></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<section class="panel">
|
|
${panelHeader("Selected tests", "These items are the `details[]` sent to the backend on submit.")}
|
|
${
|
|
details.length
|
|
? `<div class="mini-list">${details
|
|
.map(
|
|
(item) => `
|
|
<div class="mini-item">
|
|
<div>
|
|
<strong>${escapeHtml(item.test_name || item.testName || "-")}</strong>
|
|
<p>Test ID ${escapeHtml(item.test_id || item.testId || "-")} · Rp ${escapeHtml(item.price || "0")}</p>
|
|
</div>
|
|
</div>
|
|
`,
|
|
)
|
|
.join("")}</div>`
|
|
: emptyState("No tests saved", "The saved order did not return any detail rows.")
|
|
}
|
|
</section>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderOrderForm(step, stepKey = "demografi", fppTests = [], mouId = "") {
|
|
const fppGroups = groupFppTestsForSelection(fppTests);
|
|
const packedGroups = packFppGroupsIntoColumns(fppGroups, 5);
|
|
const steps = [
|
|
["demografi", "Demografi", "Patient identity and contact details."],
|
|
["diagnosa", "Diagnosis", "Clinical indication and diagnosis."],
|
|
["pemeriksaan", "Test", "Choose examination groups."],
|
|
];
|
|
const normalizedStep = steps.some((item) => item[0] === stepKey) ? stepKey : "demografi";
|
|
const activeIndex = Math.max(0, steps.findIndex((item) => item[0] === normalizedStep));
|
|
const stepBody = {
|
|
demografi: `
|
|
<div class="stack">
|
|
<div class="panel" style="padding:0; box-shadow:none; background:transparent; border:0">
|
|
${panelHeader("Mandatory", "These fields are required before save.", '<button class="btn btn-primary" type="button" hx-get="/fragments/modals/patient-search" hx-target="#modal-root" hx-swap="innerHTML">Search patient</button>')}
|
|
<div class="grid grid-2">
|
|
<label class="field"><span>Patient name</span><input name="patient_name" required /></label>
|
|
</div>
|
|
</div>
|
|
<div class="panel" style="padding:0; box-shadow:none; background:transparent; border:0">
|
|
${panelHeader("Optional", "These fields can be filled when available.")}
|
|
<div class="grid grid-2">
|
|
<label class="field"><span>Date of birth</span><input name="patient_dob" type="date" /></label>
|
|
<label class="field"><span>NIK</span><input name="patient_nik" placeholder="16 digits if available" /></label>
|
|
<label class="field"><span>Phone</span><input name="patient_hp" placeholder="08xxxxxxxxxx" /></label>
|
|
<label class="field"><span>Address</span><input name="patient_address" /></label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`,
|
|
diagnosa: `
|
|
<div class="stack">
|
|
<div class="panel" style="padding:0; box-shadow:none; background:transparent; border:0">
|
|
${panelHeader("Diagnosis", "")}
|
|
<div class="grid grid-2">
|
|
<label class="field" style="grid-column:1/-1"><span>Diagnosis</span><input name="patient_diagnosa" required /></label>
|
|
</div>
|
|
</div>
|
|
<div class="panel" style="padding:0; box-shadow:none; background:transparent; border:0">
|
|
${panelHeader("Optional", "Notes are useful for the lab or front office.")}
|
|
<div class="grid grid-2">
|
|
<label class="field" style="grid-column:1/-1"><span>Patient note</span><textarea name="patient_note"></textarea></label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`,
|
|
pemeriksaan: `
|
|
<div class="stack">
|
|
<div class="panel" style="padding:0; box-shadow:none; background:transparent; border:0">
|
|
${panelHeader("Tests", "", '<button class="btn btn-primary" type="submit">Submit order</button>')}
|
|
<div class="note-box fpp-selection-summary">
|
|
<strong>Selected tests</strong>
|
|
<p id="order-selected-count">0 selected</p>
|
|
</div>
|
|
${
|
|
packedGroups.length
|
|
? `<div class="fpp-select-board">${packedGroups
|
|
.map(
|
|
(column) => `
|
|
<div class="fpp-select-stack">
|
|
${column
|
|
.map(
|
|
(group) => `
|
|
<article class="fpp-select-column">
|
|
<div class="fpp-select-head">
|
|
<div class="fpp-select-title">${escapeHtml(group.heading)}</div>
|
|
<div class="fpp-select-count">${group.sections.reduce((sum, section) => sum + section.tests.length, 0)} tests</div>
|
|
</div>
|
|
<div class="fpp-select-body">
|
|
${group.sections
|
|
.map(
|
|
(section) => `
|
|
<section class="fpp-select-section">
|
|
${section.title ? `<div class="fpp-select-section-head">${escapeHtml(section.title)}</div>` : ""}
|
|
<ul class="fpp-select-list">
|
|
${section.tests
|
|
.map(
|
|
(test) => `
|
|
<li class="fpp-select-row ${test.doctortest ? "" : "is-disabled"}">
|
|
<label class="fpp-select-label">
|
|
<input
|
|
class="fpp-select-toggle"
|
|
type="checkbox"
|
|
${test.doctortest ? "" : "disabled"}
|
|
data-test-id="${escapeHtml(test.testId)}"
|
|
data-test-name="${escapeHtml(test.name)}"
|
|
data-test-price="${escapeHtml(test.price)}"
|
|
data-test-heading="${escapeHtml(group.heading)}"
|
|
data-test-subcategory="${escapeHtml(section.title || "")}"
|
|
/>
|
|
<span class="fpp-select-name">${escapeHtml(test.name)}</span>
|
|
</label>
|
|
</li>
|
|
`,
|
|
)
|
|
.join("")}
|
|
</ul>
|
|
</section>
|
|
`,
|
|
)
|
|
.join("")}
|
|
</div>
|
|
</article>
|
|
`,
|
|
)
|
|
.join("")}
|
|
</div>
|
|
`,
|
|
)
|
|
.join("")}</div>`
|
|
: `<div class="note-box">FPP catalog not available yet.</div>`
|
|
}
|
|
<div class="topbar-actions" style="justify-content:flex-start; margin-top:14px">
|
|
<button class="btn btn-primary" type="submit">Submit order</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`,
|
|
}[normalizedStep];
|
|
const prev = steps[activeIndex - 1];
|
|
const next = steps[activeIndex + 1];
|
|
return `
|
|
<div class="stack order-create-stack">
|
|
<section class="panel order-create-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>
|
|
<form class="stack" action="/orders" method="post" data-order-form data-order-step="${escapeHtml(normalizedStep)}" data-mou-id="${escapeHtml(mouId || sampleLogin.mouId)}">
|
|
<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>
|
|
${next ? `<a class="btn btn-primary" href="/orders/new/${next[0]}" hx-get="/fragments/forms/order-step/${next[0]}" hx-target="#order-step-fragment" hx-push-url="true">Continue</a>` : ""}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</form>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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>Diagnosis</strong><p>${escapeHtml(result.summary || "-")}</p></div></div>
|
|
<div class="mini-item"><div><strong>Note</strong><p>${escapeHtml(result.value || "-")}</p></div></div>
|
|
</div>
|
|
${result.details?.length ? `<div style="height:14px"></div><div class="mini-list">${result.details.map((item) => `<div class="mini-item"><div><strong>Detail</strong><p>${escapeHtml(item)}</p></div></div>`).join("")}</div>` : ""}
|
|
</aside>
|
|
</div>
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
function groupFppTestsForSelection(tests = []) {
|
|
const groups = new Map();
|
|
for (const test of tests) {
|
|
const heading = test.heading || "FPP";
|
|
const sectionTitle = test.subcategory || "";
|
|
if (!groups.has(heading)) {
|
|
groups.set(heading, { heading, sections: new Map() });
|
|
}
|
|
const group = groups.get(heading);
|
|
const sectionKey = sectionTitle || "__ungrouped__";
|
|
if (!group.sections.has(sectionKey)) {
|
|
group.sections.set(sectionKey, { title: sectionTitle, tests: [] });
|
|
}
|
|
group.sections.get(sectionKey).tests.push(test);
|
|
}
|
|
return Array.from(groups.values()).map((group) => ({
|
|
heading: group.heading,
|
|
sections: Array.from(group.sections.values()),
|
|
}));
|
|
}
|
|
|
|
function packFppGroupsIntoColumns(groups = [], maxColumns = 5) {
|
|
const columnCount = Math.min(Math.max(groups.length, 1), maxColumns);
|
|
const columns = Array.from({ length: columnCount }, () => ({ items: [], score: 0 }));
|
|
const estimateScore = (group) =>
|
|
1 +
|
|
group.sections.length +
|
|
group.sections.reduce((sum, section) => sum + section.tests.length * 0.12, 0);
|
|
|
|
for (const group of groups) {
|
|
let target = columns[0];
|
|
for (const column of columns) {
|
|
if (column.score < target.score) target = column;
|
|
}
|
|
target.items.push(group);
|
|
target.score += estimateScore(group);
|
|
}
|
|
|
|
return columns.map((column) => column.items);
|
|
}
|
|
|
|
function renderFpp(groups, activeGroup = "All") {
|
|
const availableGroups = groups.map((item) => item.heading);
|
|
const visibleGroups = activeGroup === "All" ? groups : groups.filter((item) => item.heading === activeGroup);
|
|
const packedGroups = packFppGroupsIntoColumns(visibleGroups, 5);
|
|
return `
|
|
<div class="stack swap-zone fpp-shell">
|
|
<section class="panel fpp-toolbar">
|
|
${panelHeader("FPP", `Paper-style catalog with ${availableGroups.length} grouped panels, aligned to the manual reference sheet.`)}
|
|
</section>
|
|
${
|
|
visibleGroups.length
|
|
? `
|
|
<section class="fpp-sheet">
|
|
<div class="fpp-sheet-head">
|
|
<div class="fpp-sheet-title">Pemeriksaan</div>
|
|
<div class="fpp-sheet-meta">Hitam putih, padat, dan mengikuti pembagian grup seperti form manual.</div>
|
|
</div>
|
|
<div class="fpp-board">
|
|
${packedGroups
|
|
.map(
|
|
(column) => `
|
|
<div class="fpp-select-stack">
|
|
${column
|
|
.map(
|
|
(group) => `
|
|
<article class="fpp-column">
|
|
<div class="fpp-column-head">
|
|
<div class="fpp-column-title">${escapeHtml(group.heading)}</div>
|
|
<div class="fpp-column-count">${group.sections.reduce((sum, section) => sum + section.tests.length, 0)} tests</div>
|
|
</div>
|
|
<div class="fpp-column-body">
|
|
${group.sections
|
|
.map(
|
|
(section) => `
|
|
<section class="fpp-section">
|
|
${section.title ? `<div class="fpp-section-head">${escapeHtml(section.title)}</div>` : ""}
|
|
<ul class="fpp-test-list">
|
|
${section.tests
|
|
.map(
|
|
(test) => `
|
|
<li class="fpp-test ${test.doctortest ? "" : "is-disabled"}" title="${test.doctortest ? "" : "Tidak dapat dicentang"}">
|
|
<span class="fpp-dot" aria-hidden="true"></span>
|
|
<span class="fpp-test-name">${escapeHtml(test.name)}</span>
|
|
</li>
|
|
`,
|
|
)
|
|
.join("")}
|
|
</ul>
|
|
</section>
|
|
`,
|
|
)
|
|
.join("")}
|
|
</div>
|
|
</article>
|
|
`,
|
|
)
|
|
.join("")}
|
|
</div>
|
|
`,
|
|
)
|
|
.join("")}
|
|
</div>
|
|
</section>
|
|
`
|
|
: `<section class="panel">${emptyState("No FPP items", "The API returned no catalog rows for this filter.")}</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>
|
|
</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?.displayName || session?.username || "-")}</strong></div>
|
|
<div class="card"><span class="muted">Username</span><strong style="display:block; margin-top:8px">${escapeHtml(session?.loginUsername || "-")}</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>
|
|
</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, error = "") {
|
|
return `
|
|
<section class="panel">
|
|
${panelHeader("Change password", "Inline validation and a straightforward submit path.")}
|
|
${error ? `<div class="note-box" style="margin-bottom:16px">${escapeHtml(error)}</div>` : ""}
|
|
<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>
|
|
<input type="hidden" name="login_username" value="${escapeHtml(session?.loginUsername || "")}" />
|
|
<label class="field"><span>Username</span><input value="${escapeHtml(session?.loginUsername || "-")}" readonly /></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">
|
|
<div class="note-box" style="margin-right:auto">Password minimal 8 digit, 1 huruf kecil, 1 huruf besar, dan 1 angka.</div>
|
|
<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" 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" },
|
|
);
|
|
}
|
|
|
|
async function orderNewPage(session, path) {
|
|
const allowedSteps = new Set(["demografi", "diagnosa", "pemeriksaan"]);
|
|
const step = path.split("/").filter(Boolean)[2] || "demografi";
|
|
const normalizedStep = allowedSteps.has(step) ? step : "demografi";
|
|
const fppTests = await loadFppCatalog(session);
|
|
return layout("Create Order", `<div id="order-step-fragment">${renderOrderForm({}, normalizedStep, fppTests, session?.mouId || "")}</div>`, {
|
|
authenticated: true,
|
|
activePath: "/orders",
|
|
subtitle: "The new project collapses the old step-heavy flow into a cleaner shell while keeping the same workflow.",
|
|
...accountLayoutOptions(session),
|
|
});
|
|
}
|
|
|
|
function ordersPage(data = {}, session = {}) {
|
|
return layout("Orders", `<div id="orders-fragment">${renderOrdersTable(data)}</div>`, {
|
|
authenticated: true,
|
|
activePath: "/orders",
|
|
...accountLayoutOptions(session),
|
|
});
|
|
}
|
|
|
|
function resultsPage({ query = {}, results = [], selectedResultId = "" } = {}, session = {}) {
|
|
return layout("Results", `<div id="results-fragment">${renderResultsTable(results, selectedResultId)}</div>`, {
|
|
authenticated: true,
|
|
activePath: "/results",
|
|
...accountLayoutOptions(session),
|
|
});
|
|
}
|
|
|
|
function fppPage({ group = "All", groups = [] } = {}, session = {}) {
|
|
return layout("FPP", `<div id="fpp-fragment">${renderFpp(groups, group)}</div>`, {
|
|
authenticated: true,
|
|
activePath: "/fpp",
|
|
...accountLayoutOptions(session),
|
|
});
|
|
}
|
|
|
|
async function patientsPage(session) {
|
|
return layout("Patients", await renderPatients(session), { authenticated: true, activePath: "/patients", ...accountLayoutOptions(session) });
|
|
}
|
|
|
|
function settingsPage(session) {
|
|
return layout("Settings", renderSettings(session), { authenticated: true, activePath: "/settings", ...accountLayoutOptions(session) });
|
|
}
|
|
|
|
function changePasswordPage(session) {
|
|
return layout("Change Password", renderChangePassword(session), {
|
|
authenticated: true,
|
|
activePath: "/settings/change-password",
|
|
...accountLayoutOptions(session),
|
|
});
|
|
}
|
|
|
|
function orderDetailPage(order, session = {}) {
|
|
return layout("Order Detail", renderOrderDetail(order), {
|
|
authenticated: true,
|
|
activePath: "/orders",
|
|
...accountLayoutOptions(session),
|
|
});
|
|
}
|
|
|
|
function resultDetailPage(result, session = {}) {
|
|
return layout("Result Detail", renderResultDetail(result), {
|
|
authenticated: true,
|
|
activePath: "/results",
|
|
...accountLayoutOptions(session),
|
|
});
|
|
}
|
|
|
|
function specialMessagePage(order, session = {}) {
|
|
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", ...accountLayoutOptions(session) },
|
|
);
|
|
}
|
|
|
|
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 displayName = user?.M_UserUsername || data?.M_UserUsername || data?.name || data?.display_name || "";
|
|
const loginUsername = data?.username || user?.username || data?.login_username || data?.user_name || "";
|
|
const doctorId = user?.M_UserM_DoctorID || data?.doctor_id || data?.doctorId || "";
|
|
const doctorCode = user?.M_UserM_DoctorCode || data?.doctor_code || "";
|
|
const mouId = user?.M_UserM_MouID || data?.M_UserM_MouID || data?.mou_id || "";
|
|
const userId = user?.M_UserID || data?.M_UserID || data?.user_id || "";
|
|
return {
|
|
token,
|
|
displayName,
|
|
loginUsername,
|
|
username: displayName || loginUsername,
|
|
doctorId,
|
|
doctorCode,
|
|
mouId,
|
|
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 normalizeHomeOrder(raw, index = 0) {
|
|
const status = String(raw?.is_confirm || raw?.status || "").toUpperCase() === "Y" ? "Confirmed" : "Unconfirmed";
|
|
return {
|
|
id: raw?.order_id || raw?.id || raw?.order_patient_id || "",
|
|
patient: raw?.order_name || raw?.patient_name || raw?.name || "",
|
|
doctor: raw?.doctor_id || raw?.doctor_name || raw?.doctor || "",
|
|
updated: raw?.order_date || raw?.updated_at || raw?.updated || "",
|
|
status,
|
|
tone: statusClass(status),
|
|
mode: raw?.LabNumber && raw?.LabNumber !== "-" ? raw.LabNumber : raw?.order_qrcode || "",
|
|
age: String(raw?.age || raw?.patient_age || ""),
|
|
gender: raw?.gender || raw?.patient_gender || "",
|
|
tests: Array.isArray(raw?.details) ? raw.details.map((item) => item?.test_name || item?.name || "").filter(Boolean) : [],
|
|
diagnosis: raw?.order_diagnosa || raw?.diagnosis || "",
|
|
message: raw?.order_note || raw?.note || "",
|
|
orderDate: raw?.order_date || "",
|
|
orderNik: raw?.order_nik || "",
|
|
orderHp: raw?.order_hp || "",
|
|
orderAddress: raw?.order_address || "",
|
|
orderDob: raw?.order_dob || "",
|
|
};
|
|
}
|
|
|
|
function normalizeDesktopOrder(raw, index = 0) {
|
|
return {
|
|
id: raw?.order_id || raw?.id || raw?.order_patient_id || "",
|
|
patient: raw?.order_name || raw?.patient_name || raw?.name || "",
|
|
doctor: raw?.doctor_id || raw?.doctor_name || raw?.doctor || "",
|
|
updated: raw?.order_date || raw?.updated_at || raw?.updated || "",
|
|
qrcode: raw?.order_qrcode || raw?.qrcode || "",
|
|
orderNik: raw?.order_nik || "",
|
|
orderHp: raw?.order_hp || "",
|
|
orderAddress: raw?.order_address || "",
|
|
orderDob: raw?.order_dob || "",
|
|
diagnosis: raw?.order_diagnosa || raw?.diagnosis || "",
|
|
message: raw?.order_note || raw?.note || "",
|
|
status: String(raw?.is_confirm || raw?.status || "").toUpperCase() === "Y" ? "Confirmed" : "Unconfirmed",
|
|
};
|
|
}
|
|
|
|
function normalizePatientOrder(raw, index = 0) {
|
|
return {
|
|
id: raw?.order_id || raw?.id || raw?.order_patient_id || "",
|
|
patient: raw?.order_name || raw?.patient_name || raw?.name || "",
|
|
orderDob: raw?.order_dob || "",
|
|
orderNik: raw?.order_nik || "",
|
|
orderHp: raw?.order_hp || "",
|
|
orderAddress: raw?.order_address || "",
|
|
updated: raw?.order_date || raw?.updated_at || raw?.updated || "",
|
|
};
|
|
}
|
|
|
|
function normalizeResult(raw, index = 0) {
|
|
const status = raw?.status || raw?.result_status || raw?.order_status || "Pending";
|
|
const detailsSource = raw?.details || raw?.items || raw?.order_details || [];
|
|
const details = Array.isArray(detailsSource)
|
|
? detailsSource
|
|
.map((item) => {
|
|
if (typeof item === "string") return item;
|
|
return item?.test_name || item?.name || item?.detail_name || item?.exam || item?.label || "";
|
|
})
|
|
.filter(Boolean)
|
|
: [];
|
|
return {
|
|
id: raw?.result_id || raw?.order_id || raw?.id || raw?.hasil_id || "",
|
|
patient: raw?.order_name || raw?.patient_name || raw?.name || raw?.patient || "",
|
|
test: details.join(", ") || raw?.test_name || raw?.item_name || raw?.test || "",
|
|
status,
|
|
tone: statusClass(status),
|
|
date: raw?.order_date || raw?.date || raw?.created_at || raw?.updated_at || "",
|
|
summary: raw?.order_diagnosa || raw?.summary || raw?.note || raw?.result_summary || "",
|
|
value: raw?.order_note || raw?.value || raw?.result_value || raw?.result || "",
|
|
details,
|
|
orderCode: raw?.order_qrcode || "",
|
|
orderDob: raw?.order_dob || "",
|
|
orderAddress: raw?.order_address || "",
|
|
orderNik: raw?.order_nik || "",
|
|
orderHp: raw?.order_hp || "",
|
|
};
|
|
}
|
|
|
|
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 loadHomeOrders(session) {
|
|
try {
|
|
const now = new Date();
|
|
const payload = await apiPost(
|
|
"/order/home",
|
|
{
|
|
token: session.token,
|
|
month: String(now.getMonth() + 1),
|
|
year: String(now.getFullYear()),
|
|
},
|
|
session.token,
|
|
);
|
|
const rows = extractArray(payload) || payload?.data || [];
|
|
const items = Array.isArray(rows) ? rows.map((row, index) => normalizeHomeOrder(row, index)).filter((item) => item.id) : [];
|
|
const totals = {
|
|
total: Number(payload?.total_order || payload?.total || items.length || 0),
|
|
confirmed: Number(payload?.total_confirmed || items.filter((item) => item.status === "Confirmed").length || 0),
|
|
unconfirmed: Number(payload?.total_unconfirmed || items.filter((item) => item.status === "Unconfirmed").length || 0),
|
|
};
|
|
return { items, totals, month: String(now.getMonth() + 1), year: String(now.getFullYear()) };
|
|
} catch {
|
|
return { items: [], totals: { total: 0, confirmed: 0, unconfirmed: 0 }, month: "", year: "" };
|
|
}
|
|
}
|
|
|
|
async function loadDesktopOrders(session, { search = "", month = "", year = "", currentPage = 1 } = {}) {
|
|
try {
|
|
const now = new Date();
|
|
const resolvedMonth = String(month || now.getMonth() + 1);
|
|
const resolvedYear = String(year || now.getFullYear());
|
|
const resolvedPage = String(Math.max(1, Number(currentPage) || 1));
|
|
const payload = await apiPost(
|
|
"/order/search_order_pasien_by_doktorid_desktop",
|
|
{
|
|
token: session.token,
|
|
month: resolvedMonth,
|
|
year: resolvedYear,
|
|
search: String(search || ""),
|
|
current_page: resolvedPage,
|
|
OrderPatientM_DoctorID: session.doctorId || sampleLogin.doctorId,
|
|
},
|
|
session.token,
|
|
);
|
|
const rows = extractArray(payload) || payload?.data || [];
|
|
const items = Array.isArray(rows) ? rows.map((row, index) => normalizeDesktopOrder(row, index)).filter((item) => item.id) : [];
|
|
return {
|
|
items,
|
|
month: resolvedMonth,
|
|
year: resolvedYear,
|
|
currentPage: Number(resolvedPage),
|
|
search: String(search || ""),
|
|
hasNext: items.length > 0,
|
|
hasPrev: Number(resolvedPage) > 1,
|
|
};
|
|
} catch {
|
|
const now = new Date();
|
|
return {
|
|
items: [],
|
|
month: String(month || now.getMonth() + 1),
|
|
year: String(year || now.getFullYear()),
|
|
currentPage: Number(currentPage) || 1,
|
|
search: String(search || ""),
|
|
hasNext: false,
|
|
hasPrev: false,
|
|
};
|
|
}
|
|
}
|
|
|
|
async function loadPatientSearch(session, search = "") {
|
|
try {
|
|
const payload = await apiPost(
|
|
"/order/search_order_pasien_by_doktorid",
|
|
{
|
|
token: session.token,
|
|
OrderPatientM_DoctorID: session.doctorId || sampleLogin.doctorId,
|
|
search: String(search || ""),
|
|
},
|
|
session.token,
|
|
);
|
|
const rows = extractArray(payload) || [];
|
|
return Array.isArray(rows) ? rows.map((row, index) => normalizePatientOrder(row, index)).filter((item) => item.id || item.patient) : [];
|
|
} catch {
|
|
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 rawRows = extractArray(payload) || (payload?.data ? [payload.data] : []);
|
|
return rawRows.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?.data && typeof payload.data === "object") return normalizeResult(payload.data, 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 {
|
|
const related = await loadResults(session, resultId);
|
|
return related.find((item) => item.id === resultId) || related[0] || 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 loadFppCatalog(session) {
|
|
try {
|
|
const { doctorId, mouId } = resolveFppRouteIds(session);
|
|
const payload = await apiPost(`/Fpp/loadFPP/${doctorId}/${mouId}`, { token: session.token }, session.token);
|
|
const rows = extractArray(payload) || payload?.rows || [];
|
|
const headings = Array.isArray(rows) ? rows : [];
|
|
const tests = headings.flatMap((heading) =>
|
|
(heading?.details || []).flatMap((detail) =>
|
|
(detail?.tests || []).map((test) => ({
|
|
heading: heading?.heading || "FPP",
|
|
subcategory: detail?.subcategories || "",
|
|
testId: String(test?.testid || ""),
|
|
code: String(test?.code || ""),
|
|
name: String(test?.name || ""),
|
|
price: String(test?.price || "0"),
|
|
checked: Boolean(test?.checked),
|
|
doctortest: test?.doctortest !== false && test?.doctortest !== "false",
|
|
})),
|
|
),
|
|
);
|
|
return tests.filter((item) => item.name);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function loadFppPosterGroups(session) {
|
|
try {
|
|
const { doctorId, mouId } = resolveFppRouteIds(session);
|
|
const payload = await apiPost(`/Fpp/loadFPP/${doctorId}/${mouId}`, { token: session.token }, session.token);
|
|
const rows = extractArray(payload) || payload?.rows || [];
|
|
const headings = Array.isArray(rows) ? rows : [];
|
|
return headings
|
|
.map((heading) => ({
|
|
heading: heading?.heading || "FPP",
|
|
sections: (heading?.details || [])
|
|
.map((detail) => ({
|
|
title: detail?.subcategories || "",
|
|
tests: (detail?.tests || [])
|
|
.map((test) => ({
|
|
testId: String(test?.testid || ""),
|
|
code: String(test?.code || ""),
|
|
name: String(test?.name || ""),
|
|
price: String(test?.price || "0"),
|
|
checked: Boolean(test?.checked),
|
|
doctortest: test?.doctortest !== false && test?.doctortest !== "false",
|
|
}))
|
|
.filter((test) => test.name),
|
|
}))
|
|
.filter((section) => section.tests.length),
|
|
}))
|
|
.filter((group) => group.sections.length);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
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 = {}) {
|
|
const resolvedLocation = typeof location === "string" && location.startsWith("/") ? appPath(location) : location;
|
|
res.writeHead(302, { Location: resolvedLocation, ...headers });
|
|
res.end();
|
|
}
|
|
|
|
function html(res, status, body, headers = {}) {
|
|
const output = rewriteHtmlPaths(body);
|
|
res.writeHead(status, { "Content-Type": "text/html; charset=utf-8", ...headers });
|
|
res.end(output);
|
|
}
|
|
|
|
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) : {};
|
|
const flat = Object.fromEntries(new URLSearchParams(raw));
|
|
const details = [];
|
|
for (const [key, value] of Object.entries(flat)) {
|
|
const match = key.match(/^details\[(\d+)\]\[(\w+)\]$/);
|
|
if (!match) continue;
|
|
const index = Number(match[1]);
|
|
const field = match[2];
|
|
details[index] ||= {};
|
|
details[index][field] = value;
|
|
delete flat[key];
|
|
}
|
|
if (details.length) flat.details = details.filter(Boolean);
|
|
return flat;
|
|
}
|
|
|
|
function fragmentOrdersTable(search = "", month = "", year = "", currentPage = 1, selected = "", data = null) {
|
|
return renderOrdersTable({
|
|
...(data || {}),
|
|
search,
|
|
month,
|
|
year,
|
|
currentPage,
|
|
selectedOrderId: selected,
|
|
});
|
|
}
|
|
|
|
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(groups = [], group = "All") {
|
|
return renderFpp(groups, group);
|
|
}
|
|
|
|
async function fragmentOrderStep(session, step) {
|
|
const fppTests = await loadFppCatalog(session);
|
|
return renderOrderForm({}, step, fppTests, session?.mouId || "");
|
|
}
|
|
|
|
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 fragmentPatientSearch(session, search = "") {
|
|
const patients = await loadPatientSearch(session, search);
|
|
return `
|
|
<div class="modal-shell" role="dialog" aria-modal="true" aria-labelledby="patient-search-title">
|
|
<div class="modal-backdrop" data-modal-close></div>
|
|
<section class="modal-card panel">
|
|
${panelHeader("Search patient", "Pick an existing patient and the demographic fields will fill automatically.", `<button class="btn btn-secondary" type="button" data-modal-close>Close</button>`)}
|
|
<form class="card" hx-get="/fragments/modals/patient-search" hx-target="#modal-root" hx-swap="innerHTML">
|
|
<label class="field">
|
|
<span>Search name</span>
|
|
<input name="search" value="${escapeHtml(search)}" placeholder="Type patient name" />
|
|
</label>
|
|
<div class="topbar-actions" style="justify-content:flex-start; margin-top:12px">
|
|
<button class="btn btn-primary" type="submit">Search</button>
|
|
</div>
|
|
</form>
|
|
<div style="height:14px"></div>
|
|
${
|
|
patients.length
|
|
? `<div class="mini-list">${patients
|
|
.map(
|
|
(patient) => `
|
|
<button
|
|
class="mini-item"
|
|
type="button"
|
|
data-patient-pick="true"
|
|
data-patient-name="${escapeHtml(patient.patient || "")}"
|
|
data-patient-dob="${escapeHtml(patient.orderDob || "")}"
|
|
data-patient-nik="${escapeHtml(patient.orderNik || "")}"
|
|
data-patient-hp="${escapeHtml(patient.orderHp || "")}"
|
|
data-patient-address="${escapeHtml(patient.orderAddress || "")}"
|
|
>
|
|
<div>
|
|
<strong>${escapeHtml(patient.patient || "Unknown patient")}</strong>
|
|
<p>${patient.orderNik ? escapeHtml(patient.orderNik) : "NIK belum ada"}</p>
|
|
</div>
|
|
</button>
|
|
`,
|
|
)
|
|
.join("")}</div>`
|
|
: emptyState("No patients found", "Search by patient name to load matching records.")
|
|
}
|
|
</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 = stripBasePath(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 === "/logo.png") {
|
|
const logo = await readFile(new URL("./logo.png", import.meta.url));
|
|
res.writeHead(200, { "Content-Type": "image/png" });
|
|
res.end(logo);
|
|
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,
|
|
displayName: normalized.displayName || normalized.username || body.username || "",
|
|
loginUsername: normalized.loginUsername || body.username || "",
|
|
username: normalized.displayName || normalized.username || body.username || "",
|
|
doctorId: normalized.doctorId || body.doctor_id || "",
|
|
doctorCode: normalized.doctorCode || body.doctor_id || "",
|
|
mouId: normalized.mouId || body.M_MouID || "",
|
|
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.displayName || 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 {
|
|
const payload = await apiPost(
|
|
"/auth/change_password",
|
|
{
|
|
token: sessionData.token,
|
|
M_UserID: sessionData.userId || "",
|
|
username: body.login_username || sessionData.loginUsername || "",
|
|
doctor_id: sessionData.doctorCode || sessionData.doctorId || sampleLogin.doctorId,
|
|
new_password: body.new_password,
|
|
confirm_password: body.confirm_password,
|
|
},
|
|
sessionData.token,
|
|
);
|
|
if (payload?.status && payload.status !== "OK") {
|
|
throw new Error(payload?.message || "Change password failed");
|
|
}
|
|
redirect(res, "/login", { "Set-Cookie": deleteCookie(sessionKey) });
|
|
} catch (error) {
|
|
html(
|
|
res,
|
|
200,
|
|
layout(
|
|
"Change Password",
|
|
renderChangePassword(sessionData, error?.message || "Upstream change password call failed. Check API availability."),
|
|
{ authenticated: true, activePath: "/settings/change-password", ...accountLayoutOptions(sessionData) },
|
|
),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (path === "/orders/new" && isGet) {
|
|
if (!requireAuth(req, res)) return;
|
|
html(res, 200, await orderNewPage(session, path));
|
|
return;
|
|
}
|
|
|
|
if (path.startsWith("/orders/new/") && isGet) {
|
|
if (!requireAuth(req, res)) return;
|
|
html(res, 200, await orderNewPage(session, path));
|
|
return;
|
|
}
|
|
|
|
if (path === "/orders" && isGet) {
|
|
if (!requireAuth(req, res)) return;
|
|
const orders = await loadDesktopOrders(session, query);
|
|
html(res, 200, ordersPage({ ...orders, selectedOrderId: query.selected || orders.items[0]?.id || "" }, session));
|
|
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 || "" }, session));
|
|
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", ...accountLayoutOptions(session) }));
|
|
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", ...accountLayoutOptions(session) }));
|
|
return;
|
|
}
|
|
|
|
if (path === "/fpp" && isGet) {
|
|
if (!requireAuth(req, res)) return;
|
|
const groups = await loadFppPosterGroups(session);
|
|
html(res, 200, fppPage({ group: query.group || "All", groups }, session));
|
|
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: "/", ...accountLayoutOptions(session) }));
|
|
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", ...accountLayoutOptions(session) }));
|
|
return;
|
|
}
|
|
html(res, 200, specialMessagePage(order, session));
|
|
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", ...accountLayoutOptions(session) }));
|
|
return;
|
|
}
|
|
html(res, 200, orderDetailPage(order, session));
|
|
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", ...accountLayoutOptions(session) }));
|
|
return;
|
|
}
|
|
html(res, 200, resultDetailPage(result, session));
|
|
return;
|
|
}
|
|
|
|
if (path === "/fragments/orders/table" && isGet) {
|
|
if (!requireAuth(req, res)) return;
|
|
const orders = await loadDesktopOrders(session, query);
|
|
html(
|
|
res,
|
|
200,
|
|
fragmentOrdersTable(query.search || "", query.month || "", query.year || "", query.current_page || 1, query.selected || orders.items[0]?.id || "", 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 loadFppPosterGroups(session);
|
|
html(res, 200, fragmentFpp(groups, query.group || "All"));
|
|
return;
|
|
}
|
|
|
|
if (path.startsWith("/fragments/forms/order-step/") && isGet) {
|
|
if (!requireAuth(req, res)) return;
|
|
const step = path.split("/")[4] || "demografi";
|
|
html(res, 200, await fragmentOrderStep(session, step));
|
|
return;
|
|
}
|
|
|
|
if (path === "/fragments/modals/pesan-khusus" && isGet) {
|
|
if (!requireAuth(req, res)) return;
|
|
html(res, 200, fragmentPesanKhusus(query.order_id || ""));
|
|
return;
|
|
}
|
|
|
|
if (path === "/fragments/modals/patient-search" && isGet) {
|
|
if (!requireAuth(req, res)) return;
|
|
html(res, 200, await fragmentPatientSearch(session, query.search || ""));
|
|
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 {
|
|
const payload = await apiPost(
|
|
"/order/order_patient",
|
|
{
|
|
token: sessionData.token,
|
|
M_MouID: body.M_MouID || sessionData.mouId || sampleLogin.mouId || "",
|
|
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,
|
|
);
|
|
const saved = payload?.data || payload?.result || payload || {};
|
|
const normalizedSaved = {
|
|
id: saved.order_id || saved.id || saved.order_patient_id || "",
|
|
patient: saved.order_name || body.patient_name || "",
|
|
diagnosis: saved.order_diagnosa || body.patient_diagnosa || "",
|
|
message: saved.order_note || body.patient_note || "",
|
|
orderDate: saved.order_date || "",
|
|
orderCode: saved.order_qrcode || "",
|
|
doctorName: sessionData.username || sampleLogin.username,
|
|
details: Array.isArray(saved.details) ? saved.details : Array.isArray(body.details) ? body.details : [],
|
|
};
|
|
html(
|
|
res,
|
|
200,
|
|
layout("Order saved", renderOrderSaved(normalizedSaved), {
|
|
authenticated: true,
|
|
activePath: "/orders",
|
|
subtitle: "QR Code and review are shown after the order has been submitted successfully.",
|
|
}),
|
|
);
|
|
} 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", ...accountLayoutOptions(sessionData) },
|
|
),
|
|
);
|
|
}
|
|
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", ...accountLayoutOptions(sessionData) },
|
|
),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
html(res, 404, emptyRoute(url.pathname));
|
|
}
|
|
|
|
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}`);
|
|
});
|