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", Released: "success",
Pending: "warning", Pending: "warning",
Review: "danger", Review: "danger",
Confirmed: "success",
Unconfirmed: "danger",
}; };
return mapping[status] || "neutral"; return mapping[status] || "neutral";
} }
@@ -448,17 +450,20 @@ function accountLayoutOptions(session = {}) {
} }
async function dashboardPage(session) { async function dashboardPage(session) {
const [orders, results] = await Promise.all([ const [homeOrders, results] = await Promise.all([
loadOrders(session, "", "All"), loadHomeOrders(session),
loadResults(session, ""), loadResults(session, ""),
]); ]);
const orders = homeOrders.items;
const stats = [ 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: "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 ` return `
<div class="stack"> <div class="stack">
<section class="grid grid-2"> <section class="grid grid-4">
${stats ${stats
.map( .map(
(item) => ` (item) => `
@@ -476,7 +481,7 @@ async function dashboardPage(session) {
</section> </section>
<section class="detail-grid"> <section class="detail-grid">
<div class="panel"> <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"> <div class="table-wrap">
<table> <table>
<thead> <thead>
@@ -497,7 +502,7 @@ async function dashboardPage(session) {
`, `,
) )
.join("") .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> </tbody>
</table> </table>
</div> </div>
@@ -507,39 +512,70 @@ async function dashboardPage(session) {
`; `;
} }
function renderOrdersTable(orders, selectedOrderId, filter = "All") { function renderOrdersTable({
const selected = orders.find((item) => item.id === selectedOrderId) || orders[0] || null; 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 ` return `
<div class="detail-grid"> <div class="detail-grid">
<section class="panel"> <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>')} ${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="pill-row" hx-get="/fragments/orders/table" hx-target="#orders-fragment" hx-push-url="true"> <form class="card" hx-get="/fragments/orders/table" hx-target="#orders-fragment" hx-push-url="true">
<input type="hidden" name="status" value="${escapeHtml(filter)}" /> <div class="grid grid-3">
${["All", "Processing", "Ready", "Needs review"] <label class="field"><span>Search patient</span><input name="search" type="search" value="${escapeHtml(search)}" placeholder="ANDI SETADI" /></label>
.map( <label class="field"><span>Month</span><select name="month">${monthOptions}</select></label>
(item) => ` <label class="field"><span>Year</span><select name="year">${yearOptions}</select></label>
<button class="pill ${filter === item ? "active" : ""}" name="status" value="${escapeHtml(item)}" type="submit">${escapeHtml(item)}</button> </div>
`, <input type="hidden" name="current_page" value="1" />
) <input type="hidden" name="selected" value="${escapeHtml(selectedId)}" />
.join("")} <div class="topbar-actions" style="justify-content:flex-end; margin-top:14px">
<button class="btn btn-primary" type="submit">Apply filter</button>
</div>
</form> </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> <table>
<thead> <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> </thead>
<tbody> <tbody>
${orders ${items
.map( .map(
(order) => ` (order) => `
<tr> <tr
<td><strong>${escapeHtml(order.patient || "Unknown patient")}</strong><br /><span class="muted">${escapeHtml(order.mode || "-")}</span></td> class="order-row ${selectedId === order.id ? "active" : ""}"
<td><a href="/orders/${order.id}">${escapeHtml(order.id)}</a></td> hx-get="/fragments/orders/table?search=${encodeURIComponent(search)}&month=${encodeURIComponent(resolvedMonth)}&year=${encodeURIComponent(resolvedYear)}&current_page=${encodeURIComponent(String(currentPage || 1))}&selected=${encodeURIComponent(order.id)}"
<td>${escapeHtml(order.doctor || "-")}</td> hx-target="#orders-fragment"
<td>${statusBadge(order.status)}</td> 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> <td>${escapeHtml(order.updated || "-")}</td>
</tr> </tr>
`, `,
@@ -548,33 +584,31 @@ function renderOrdersTable(orders, selectedOrderId, filter = "All") {
</tbody> </tbody>
</table> </table>
</div> </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> </section>
<aside class="panel"> <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 selected
? ` ? `
@@ -583,19 +617,20 @@ function renderOrdersTable(orders, selectedOrderId, filter = "All") {
<strong>${escapeHtml(selected.patient || "Unknown patient")}</strong> <strong>${escapeHtml(selected.patient || "Unknown patient")}</strong>
<p class="muted">${escapeHtml(selected.id || "-")} · ${escapeHtml(selected.updated || "-")}</p> <p class="muted">${escapeHtml(selected.id || "-")} · ${escapeHtml(selected.updated || "-")}</p>
<div class="pill-row" style="margin-top:12px"> <div class="pill-row" style="margin-top:12px">
${statusBadge(selected.status)} ${selected.status ? statusBadge(selected.status) : ""}
<span class="pill">Doctor ${escapeHtml(selected.doctor || "-")}</span> <span class="pill">${escapeHtml(selected.qrcode || "-")}</span>
<span class="pill">${escapeHtml(selected.orderDate || selected.updated || "-")}</span>
</div> </div>
</div> </div>
<div class="mini-list"> <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>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>
</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> </aside>
</div> </div>
@@ -1355,8 +1390,8 @@ async function orderNewPage(session, path) {
}); });
} }
function ordersPage({ query = {}, orders = [], selectedOrderId = "" } = {}, session = {}) { function ordersPage(data = {}, session = {}) {
return layout("Orders", `<div id="orders-fragment">${renderOrdersTable(orders, selectedOrderId, query.status || "All")}</div>`, { return layout("Orders", `<div id="orders-fragment">${renderOrdersTable(data)}</div>`, {
authenticated: true, authenticated: true,
activePath: "/orders", activePath: "/orders",
...accountLayoutOptions(session), ...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) { function normalizeResult(raw, index = 0) {
const status = raw?.status || raw?.result_status || raw?.order_status || "Pending"; const status = raw?.status || raw?.result_status || raw?.order_status || "Pending";
const detailsSource = raw?.details || raw?.items || raw?.order_details || []; const detailsSource = raw?.details || raw?.items || raw?.order_details || [];
@@ -1640,6 +1715,74 @@ async function loadOrders(session, search = "", status = "All") {
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 loadResults(session, search = "") { async function loadResults(session, search = "") {
const term = String(search || "").trim(); const term = String(search || "").trim();
try { try {
@@ -1827,75 +1970,15 @@ async function readBody(req) {
return flat; return flat;
} }
function fragmentOrdersTable(search = "", status = "All", ordersData = null) { function fragmentOrdersTable(search = "", month = "", year = "", currentPage = 1, selected = "", data = null) {
const orders = ordersData || []; return renderOrdersTable({
const selected = orders[0] || null; ...(data || {}),
return ` search,
<div class="detail-grid"> month,
<section class="panel"> year,
${panelHeader("Search orders", "Filter the list without full page refresh.")} currentPage,
<form class="pill-row" hx-get="/fragments/orders/table" hx-target="#orders-fragment" hx-push-url="true"> selectedOrderId: selected,
<input type="hidden" name="search" value="${escapeHtml(search)}" /> });
${["All", "Processing", "Ready", "Needs review"]
.map(
(item) => `
<button class="pill ${status === item ? "active" : ""}" name="status" value="${escapeHtml(item)}" type="submit">${escapeHtml(item)}</button>
`,
)
.join("")}
</form>
${
orders.length
? `
<div id="orders-table" class="table-wrap" style="margin-top:16px">
<table>
<thead>
<tr><th>Patient</th><th>Order ID</th><th>Doctor</th><th>Status</th><th>Updated</th></tr>
</thead>
<tbody>
${orders
.map(
(order) => `
<tr>
<td><strong>${escapeHtml(order.patient || "Unknown patient")}</strong><br /><span class="muted">${escapeHtml(order.mode || "-")}</span></td>
<td><a href="/orders/${order.id}">${escapeHtml(order.id)}</a></td>
<td>${escapeHtml(order.doctor || "-")}</td>
<td>${statusBadge(order.status)}</td>
<td>${escapeHtml(order.updated || "-")}</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
`
: emptyState("No orders returned", "The order endpoint did not return any rows for this filter.")
}
</section>
<aside class="panel">
${panelHeader("Selected order", "Context stays visible while you review the table.", selected ? `<a class="btn btn-secondary" href="/orders/${selected.id}">Open detail</a>` : "")}
${
selected
? `
<div class="stack">
<div class="card">
<strong>${escapeHtml(selected.patient || "Unknown patient")}</strong>
<p class="muted">${escapeHtml(selected.id || "-")} · ${escapeHtml(selected.mode || "-")}</p>
<div class="pill-row" style="margin-top:12px">${statusBadge(selected.status)}<span class="pill">${escapeHtml(selected.age || "-")} years</span><span class="pill">${escapeHtml(selected.gender || "-")}</span></div>
</div>
<div class="mini-list">
<div class="mini-item"><div><strong>Diagnosis</strong><p>${escapeHtml(selected.diagnosis || "-")}</p></div></div>
<div class="mini-item"><div><strong>Requested tests</strong><p>${escapeHtml((selected.tests || []).join(", ") || "-")}</p></div></div>
<div class="mini-item"><div><strong>Special note</strong><p>${escapeHtml(selected.message || "-")}</p></div></div>
</div>
</div>
`
: emptyState("No selected order", "The API returned no rows for this search.")
}
</aside>
</div>
`;
} }
function fragmentResultsTable(search = "", resultsData = null) { function fragmentResultsTable(search = "", resultsData = null) {
@@ -2140,8 +2223,8 @@ async function renderRoute(req, res, url) {
if (path === "/orders" && isGet) { if (path === "/orders" && isGet) {
if (!requireAuth(req, res)) return; if (!requireAuth(req, res)) return;
const orders = await loadOrders(session, query.search || "", query.status || "All"); const orders = await loadDesktopOrders(session, query);
html(res, 200, ordersPage({ query, orders, selectedOrderId: orders[0]?.id || "" }, session)); html(res, 200, ordersPage({ ...orders, selectedOrderId: query.selected || orders.items[0]?.id || "" }, session));
return; return;
} }
@@ -2235,8 +2318,13 @@ async function renderRoute(req, res, url) {
if (path === "/fragments/orders/table" && isGet) { if (path === "/fragments/orders/table" && isGet) {
if (!requireAuth(req, res)) return; if (!requireAuth(req, res)) return;
const orders = await loadOrders(session, query.search || "", query.status || "All"); const orders = await loadDesktopOrders(session, query);
html(res, 200, fragmentOrdersTable(query.search || "", query.status || "All", orders), { "HX-Trigger": JSON.stringify({ "doclink:orders-updated": true }) }); 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; return;
} }

View File

@@ -561,6 +561,18 @@ table {
background: rgba(255, 255, 255, 0.88); 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, th,
td { td {
padding: 14px 16px; padding: 14px 16px;