Use desktop order search with row selection

This commit is contained in:
sas.fajri
2026-04-13 20:15:29 +07:00
parent 3ef5d72d4d
commit c0568d00f9
2 changed files with 231 additions and 131 deletions

350
server.js
View File

@@ -31,6 +31,8 @@ function statusClass(status) {
Released: "success",
Pending: "warning",
Review: "danger",
Confirmed: "success",
Unconfirmed: "danger",
};
return mapping[status] || "neutral";
}
@@ -448,17 +450,20 @@ function accountLayoutOptions(session = {}) {
}
async function dashboardPage(session) {
const [orders, results] = await Promise.all([
loadOrders(session, "", "All"),
const [homeOrders, results] = await Promise.all([
loadHomeOrders(session),
loadResults(session, ""),
]);
const orders = homeOrders.items;
const stats = [
{ label: "Orders today", value: String(orders.length), trend: "Live", hint: "" },
{ label: "Orders this month", value: String(homeOrders.totals.total), trend: `${homeOrders.month}/${homeOrders.year}`, hint: "" },
{ label: "Confirmed", value: String(homeOrders.totals.confirmed), trend: "Home", hint: "" },
{ label: "Results pending", value: String(results.filter((item) => item.status !== "Released").length), trend: "Live", hint: "" },
{ label: "Unconfirmed", value: String(homeOrders.totals.unconfirmed), trend: "Home", hint: "" },
];
return `
<div class="stack">
<section class="grid grid-2">
<section class="grid grid-4">
${stats
.map(
(item) => `
@@ -476,7 +481,7 @@ async function dashboardPage(session) {
</section>
<section class="detail-grid">
<div class="panel">
${panelHeader("Recent orders", "A compact snapshot of the latest patient orders and their state.", '<a class="btn btn-secondary" href="/orders">View all</a>')}
${panelHeader("Recent orders", `Monthly snapshot from /order/home for ${homeOrders.month}/${homeOrders.year}.`, '<a class="btn btn-secondary" href="/orders">View all</a>')}
<div class="table-wrap">
<table>
<thead>
@@ -497,7 +502,7 @@ async function dashboardPage(session) {
`,
)
.join("")
: `<tr><td colspan="4">${emptyState("No orders returned", "The order endpoint did not return any rows for this session.")}</td></tr>`}
: `<tr><td colspan="4">${emptyState("No orders returned", "The home endpoint did not return any rows for this month.")}</td></tr>`}
</tbody>
</table>
</div>
@@ -507,39 +512,70 @@ async function dashboardPage(session) {
`;
}
function renderOrdersTable(orders, selectedOrderId, filter = "All") {
const selected = orders.find((item) => item.id === selectedOrderId) || orders[0] || null;
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("Search orders", "Use the filter to match the old app flow without giving up desktop readability.", '<a class="btn btn-primary" href="/orders/new">Create order</a>')}
<form class="pill-row" hx-get="/fragments/orders/table" hx-target="#orders-fragment" hx-push-url="true">
<input type="hidden" name="status" value="${escapeHtml(filter)}" />
${["All", "Processing", "Ready", "Needs review"]
.map(
(item) => `
<button class="pill ${filter === item ? "active" : ""}" name="status" value="${escapeHtml(item)}" type="submit">${escapeHtml(item)}</button>
`,
)
.join("")}
${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>
${
orders.length
items.length
? `
<div id="orders-table" class="desktop-only table-wrap" style="margin-top:16px">
<div class="table-wrap" style="margin-top:16px">
<table>
<thead>
<tr><th>Patient</th><th>Order ID</th><th>Doctor</th><th>Status</th><th>Updated</th></tr>
<tr><th>Patient</th><th>Order ID</th><th>Order QR</th><th>Date</th></tr>
</thead>
<tbody>
${orders
${items
.map(
(order) => `
<tr>
<td><strong>${escapeHtml(order.patient || "Unknown patient")}</strong><br /><span class="muted">${escapeHtml(order.mode || "-")}</span></td>
<td><a href="/orders/${order.id}">${escapeHtml(order.id)}</a></td>
<td>${escapeHtml(order.doctor || "-")}</td>
<td>${statusBadge(order.status)}</td>
<tr
class="order-row ${selectedId === order.id ? "active" : ""}"
hx-get="/fragments/orders/table?search=${encodeURIComponent(search)}&month=${encodeURIComponent(resolvedMonth)}&year=${encodeURIComponent(resolvedYear)}&current_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>
`,
@@ -548,33 +584,31 @@ function renderOrdersTable(orders, selectedOrderId, filter = "All") {
</tbody>
</table>
</div>
<div class="mobile-only list" style="margin-top:16px">
${orders
.map(
(order) => `
<article class="card">
<div class="topbar-actions" style="justify-content:space-between">
<div>
<strong>${escapeHtml(order.patient || "Unknown patient")}</strong>
<p class="muted">${escapeHtml(order.id || "-")} · ${escapeHtml(order.mode || "-")}</p>
</div>
${statusBadge(order.status)}
</div>
<div class="pill-row" style="margin-top:12px">
<span class="pill">${escapeHtml(order.doctor || "-")}</span>
<span class="pill">${escapeHtml(order.updated || "-")}</span>
</div>
</article>
`,
)
.join("")}
</div>
`
: emptyState("No orders returned", "The order endpoint did not return any rows for this filter.")
: 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)}&current_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)}&current_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", "Master-detail layout keeps context visible while reviewing the list.", selected ? `<a class="btn btn-secondary" href="/orders/${selected.id}">Open detail</a>` : "")}
${panelHeader("Selected order", "Click a row to update the detail pane. The row you pick is highlighted.", selected ? `<span class="pill">Selected</span>` : "")}
${
selected
? `
@@ -583,19 +617,20 @@ function renderOrdersTable(orders, selectedOrderId, filter = "All") {
<strong>${escapeHtml(selected.patient || "Unknown patient")}</strong>
<p class="muted">${escapeHtml(selected.id || "-")} · ${escapeHtml(selected.updated || "-")}</p>
<div class="pill-row" style="margin-top:12px">
${statusBadge(selected.status)}
<span class="pill">Doctor ${escapeHtml(selected.doctor || "-")}</span>
<span class="pill">${escapeHtml(selected.orderDate || selected.updated || "-")}</span>
${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 || selected.diagnosis || "-")}</p></div></div>
<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 || selected.message || "-")}</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", "The API returned no rows for this search.")
: emptyState("No selected order", "Click one row on the left to see its detail here.")
}
</aside>
</div>
@@ -1355,8 +1390,8 @@ async function orderNewPage(session, path) {
});
}
function ordersPage({ query = {}, orders = [], selectedOrderId = "" } = {}, session = {}) {
return layout("Orders", `<div id="orders-fragment">${renderOrdersTable(orders, selectedOrderId, query.status || "All")}</div>`, {
function ordersPage(data = {}, session = {}) {
return layout("Orders", `<div id="orders-fragment">${renderOrdersTable(data)}</div>`, {
authenticated: true,
activePath: "/orders",
...accountLayoutOptions(session),
@@ -1582,6 +1617,46 @@ function normalizeOrder(raw, index = 0) {
};
}
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" : "",
};
}
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 || [];
@@ -1640,6 +1715,74 @@ async function loadOrders(session, search = "", status = "All") {
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 loadResults(session, search = "") {
const term = String(search || "").trim();
try {
@@ -1827,75 +1970,15 @@ async function readBody(req) {
return flat;
}
function fragmentOrdersTable(search = "", status = "All", ordersData = null) {
const orders = ordersData || [];
const selected = orders[0] || null;
return `
<div class="detail-grid">
<section class="panel">
${panelHeader("Search orders", "Filter the list without full page refresh.")}
<form class="pill-row" hx-get="/fragments/orders/table" hx-target="#orders-fragment" hx-push-url="true">
<input type="hidden" name="search" value="${escapeHtml(search)}" />
${["All", "Processing", "Ready", "Needs review"]
.map(
(item) => `
<button class="pill ${status === item ? "active" : ""}" name="status" value="${escapeHtml(item)}" type="submit">${escapeHtml(item)}</button>
`,
)
.join("")}
</form>
${
orders.length
? `
<div id="orders-table" class="table-wrap" style="margin-top:16px">
<table>
<thead>
<tr><th>Patient</th><th>Order ID</th><th>Doctor</th><th>Status</th><th>Updated</th></tr>
</thead>
<tbody>
${orders
.map(
(order) => `
<tr>
<td><strong>${escapeHtml(order.patient || "Unknown patient")}</strong><br /><span class="muted">${escapeHtml(order.mode || "-")}</span></td>
<td><a href="/orders/${order.id}">${escapeHtml(order.id)}</a></td>
<td>${escapeHtml(order.doctor || "-")}</td>
<td>${statusBadge(order.status)}</td>
<td>${escapeHtml(order.updated || "-")}</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
`
: emptyState("No orders returned", "The order endpoint did not return any rows for this filter.")
}
</section>
<aside class="panel">
${panelHeader("Selected order", "Context stays visible while you review the table.", selected ? `<a class="btn btn-secondary" href="/orders/${selected.id}">Open detail</a>` : "")}
${
selected
? `
<div class="stack">
<div class="card">
<strong>${escapeHtml(selected.patient || "Unknown patient")}</strong>
<p class="muted">${escapeHtml(selected.id || "-")} · ${escapeHtml(selected.mode || "-")}</p>
<div class="pill-row" style="margin-top:12px">${statusBadge(selected.status)}<span class="pill">${escapeHtml(selected.age || "-")} years</span><span class="pill">${escapeHtml(selected.gender || "-")}</span></div>
</div>
<div class="mini-list">
<div class="mini-item"><div><strong>Diagnosis</strong><p>${escapeHtml(selected.diagnosis || "-")}</p></div></div>
<div class="mini-item"><div><strong>Requested tests</strong><p>${escapeHtml((selected.tests || []).join(", ") || "-")}</p></div></div>
<div class="mini-item"><div><strong>Special note</strong><p>${escapeHtml(selected.message || "-")}</p></div></div>
</div>
</div>
`
: emptyState("No selected order", "The API returned no rows for this search.")
}
</aside>
</div>
`;
function fragmentOrdersTable(search = "", month = "", year = "", currentPage = 1, selected = "", data = null) {
return renderOrdersTable({
...(data || {}),
search,
month,
year,
currentPage,
selectedOrderId: selected,
});
}
function fragmentResultsTable(search = "", resultsData = null) {
@@ -2140,8 +2223,8 @@ async function renderRoute(req, res, url) {
if (path === "/orders" && isGet) {
if (!requireAuth(req, res)) return;
const orders = await loadOrders(session, query.search || "", query.status || "All");
html(res, 200, ordersPage({ query, orders, selectedOrderId: orders[0]?.id || "" }, session));
const orders = await loadDesktopOrders(session, query);
html(res, 200, ordersPage({ ...orders, selectedOrderId: query.selected || orders.items[0]?.id || "" }, session));
return;
}
@@ -2235,8 +2318,13 @@ async function renderRoute(req, res, url) {
if (path === "/fragments/orders/table" && isGet) {
if (!requireAuth(req, res)) return;
const orders = await loadOrders(session, query.search || "", query.status || "All");
html(res, 200, fragmentOrdersTable(query.search || "", query.status || "All", orders), { "HX-Trigger": JSON.stringify({ "doclink:orders-updated": true }) });
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;
}

View File

@@ -561,6 +561,18 @@ table {
background: rgba(255, 255, 255, 0.88);
}
.order-row {
cursor: pointer;
}
.order-row.active td {
background: rgba(185, 28, 28, 0.08);
}
.order-row:hover td {
background: rgba(185, 28, 28, 0.05);
}
th,
td {
padding: 14px 16px;