From 28c315c38f70c4c4d42ece0ebf89f0a4ae9df844 Mon Sep 17 00:00:00 2001 From: "sas.fajri" Date: Mon, 13 Apr 2026 16:53:30 +0700 Subject: [PATCH] Make FPP selectable in order flow --- server.js | 505 +++++++++++++++++++++++++++++++++++++---------------- styles.css | 324 ++++++++++++++++++++++++++++++++++ 2 files changed, 677 insertions(+), 152 deletions(-) diff --git a/server.js b/server.js index 9b511cc..5f57269 100644 --- a/server.js +++ b/server.js @@ -100,10 +100,164 @@ function layout(title, body, { authenticated = false, activePath = "/", subtitle @@ -267,6 +427,13 @@ function panelHeader(title, text, action = "") { `; } +function resolveFppRouteIds(session) { + return { + doctorId: String(session?.doctorId || sampleLogin.doctorId || "1"), + mouId: String(session?.mouId || sampleLogin.mouId || "1"), + }; +} + async function dashboardPage(session) { const [orders, results] = await Promise.all([ loadOrders(session, "", "All"), @@ -463,6 +630,7 @@ function renderOrderDetail(order) { } function renderOrderForm(step, stepKey = "demografi", fppTests = [], mouId = "") { + const fppGroups = groupFppTestsForSelection(fppTests); const steps = [ ["demografi", "Demografi", "Patient identity and contact details."], ["diagnosa", "Diagnosa", "Clinical indication and diagnosis."], @@ -515,39 +683,62 @@ function renderOrderForm(step, stepKey = "demografi", fppTests = [], mouId = "") pemeriksaan: `
- ${panelHeader("Mandatory", "The backend accepts a details array, so keep at least one item here when saving.")} + ${panelHeader("Mandatory", "Select examinations from the FPP catalog. Only doctortest=true rows are interactive.")} +
+ Selected tests +

0 selected

+
${ - fppTests.length - ? `
${fppTests.slice(0, 4).map((test, index) => ` - - `).join("")}
` + fppGroups.length + ? `
${fppGroups + .map( + (group) => ` +
+
+
${escapeHtml(group.heading)}
+
${group.sections.reduce((sum, section) => sum + section.tests.length, 0)} tests
+
+
+ ${group.sections + .map( + (section) => ` +
+ ${section.title ? `
${escapeHtml(section.title)}
` : ""} +
    + ${section.tests + .map( + (test) => ` +
  • + +
  • + `, + ) + .join("")} +
+
+ `, + ) + .join("")} +
+
+ `, + ) + .join("")}
` : `
FPP catalog not available yet.
` }
-
- ${panelHeader("Optional", "Add more test rows when needed.")} -
- ${ - fppTests.length > 4 - ? fppTests.slice(4, 8).map((test, index) => ` - - `).join("") - : `
No additional FPP rows available.
` - } -
-
`, qrcode: ` @@ -604,18 +795,24 @@ function renderOrderForm(step, stepKey = "demografi", fppTests = [], mouId = "") .join("")} -
-
- ${stepBody} -
- Back -
- Save draft - ${next ? "Continue" : "Submit order"} +
+
+
+ ${stepBody} +
+ Back +
+ Save draft + ${ + next + ? `Continue` + : `` + } +
-
-
+ + `; } @@ -736,64 +933,84 @@ function renderResultDetail(result) { `; } -function renderFpp(groups) { - const groupNames = ["All", ...Array.from(new Set(groups.items.map((item) => item.group)))]; +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 renderFpp(groups, activeGroup = "All") { + const availableGroups = groups.map((item) => item.heading); + const visibleGroups = activeGroup === "All" ? groups : groups.filter((item) => item.heading === activeGroup); return ` -
-
- ${panelHeader("FPP catalog", "Filter blocks and list cards on mobile, richer panel on desktop.")} -
- ${groupNames - .map( - (item) => ` - - `, - ) - .join("")} -
+
+
+ ${panelHeader("FPP", `Paper-style catalog with ${availableGroups.length} grouped panels, aligned to the manual reference sheet.`)}
-
- ${ - groups.items.length - ? groups.items - .map( - (item) => ` -
-
-
-

${escapeHtml(item.group)}

-

${escapeHtml(item.desc)}

+ ${ + visibleGroups.length + ? ` +
+
+
Pemeriksaan
+
Hitam putih, padat, dan mengikuti pembagian grup seperti form manual.
+
+
+ ${visibleGroups + .map( + (group) => ` +
+
+
${escapeHtml(group.heading)}
+
${group.sections.reduce((sum, section) => sum + section.tests.length, 0)} tests
- ${escapeHtml(item.count)} items -
-
- ${["Filter", "Inspect", "Select", "Export"] - .map( - (action) => ` -
- ${escapeHtml(action)} -

Workflow action for ${escapeHtml(item.group)}.

-
- `, - ) - .join("")} -
-
- `, - ) - .join("") - : `
${emptyState("No FPP items", "The API returned no catalog rows for this filter.")}
` - } -
+
+ ${group.sections + .map( + (section) => ` +
+ ${section.title ? `
${escapeHtml(section.title)}
` : ""} +
    + ${section.tests + .map( + (test) => ` +
  • + + ${escapeHtml(test.name)} +
  • + `, + ) + .join("")} +
+
+ `, + ) + .join("")} +
+ + `, + ) + .join("")} +
+
+ ` + : `
${emptyState("No FPP items", "The API returned no catalog rows for this filter.")}
` + }
`; } @@ -1031,7 +1248,7 @@ function resultsPage({ query = {}, results = [], selectedResultId = "" } = {}) { } function fppPage({ group = "All", groups = [] } = {}) { - return layout("FPP", `
${renderFpp({ items: groups, filter: group })}
`, { + return layout("FPP", `
${renderFpp(groups, group)}
`, { authenticated: true, activePath: "/fpp", }); @@ -1360,7 +1577,8 @@ async function loadFpp(session, group = "All") { async function loadFppCatalog(session) { try { - const payload = await apiPost("/Fpp/loadFPP/1/1", { token: session.token }, session.token); + 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) => @@ -1372,6 +1590,8 @@ async function loadFppCatalog(session) { code: String(test?.code || ""), name: String(test?.name || ""), price: String(test?.price || "0"), + checked: Boolean(test?.checked), + doctortest: test?.doctortest !== false && test?.doctortest !== "false", })), ), ); @@ -1381,6 +1601,37 @@ async function loadFppCatalog(session) { } } +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"), @@ -1582,58 +1833,8 @@ function fragmentResultsTable(search = "", resultsData = null) { `; } -function fragmentFpp(group = "All", itemsData = null) { - const items = itemsData || []; - const groups = ["All", ...Array.from(new Set(items.map((item) => item.group)))]; - return ` -
-
- ${panelHeader("FPP catalog", "Filter blocks and list cards on mobile, richer panel on desktop.")} -
- ${groups - .map( - (item) => ` - ${escapeHtml(item)} - `, - ) - .join("")} -
-
-
- ${ - items.length - ? items - .map( - (item) => ` -
-
-
-

${escapeHtml(item.group)}

-

${escapeHtml(item.desc)}

-
- ${escapeHtml(item.count)} items -
-
- ${["Filter", "Inspect", "Select", "Export"] - .map( - (action) => ` -
- ${escapeHtml(action)} -

Workflow action for ${escapeHtml(item.group)}.

-
- `, - ) - .join("")} -
-
- `, - ) - .join("") - : `
${emptyState("No FPP items", "The API returned no catalog rows for this filter.")}
` - } -
-
- `; +function fragmentFpp(groups = [], group = "All") { + return renderFpp(groups, group); } async function fragmentOrderStep(session, step) { @@ -1832,8 +2033,8 @@ async function renderRoute(req, res, url) { if (path === "/fpp" && isGet) { if (!requireAuth(req, res)) return; - const groups = await loadFpp(session, query.group || "All"); - html(res, 200, fppPage({ group: groups.filter, groups: groups.items })); + const groups = await loadFppPosterGroups(session); + html(res, 200, fppPage({ group: query.group || "All", groups })); return; } @@ -1913,8 +2114,8 @@ async function renderRoute(req, res, url) { if (path === "/fragments/fpp/list" && isGet) { if (!requireAuth(req, res)) return; - const groups = await loadFpp(session, query.group || "All"); - html(res, 200, fragmentFpp(groups.filter || "All", groups.items)); + const groups = await loadFppPosterGroups(session); + html(res, 200, fragmentFpp(groups, query.group || "All")); return; } diff --git a/styles.css b/styles.css index 47ad87d..f98ad47 100644 --- a/styles.css +++ b/styles.css @@ -561,6 +561,292 @@ tr:last-child td { gap: 20px; } +.fpp-shell { + gap: 16px; +} + +.fpp-sheet { + padding: 12px; + border-radius: 18px; + border: 1px solid rgba(120, 53, 15, 0.18); + background: linear-gradient(180deg, #fffaf2 0%, #fffdf9 100%); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72), 0 12px 32px rgba(15, 23, 42, 0.06); +} + +.fpp-sheet-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; + padding: 8px 10px; + border-radius: 12px; + background: linear-gradient(180deg, #c2410c 0%, #9a3412 100%); + color: white; +} + +.fpp-sheet-title { + font-size: 0.88rem; + font-weight: 900; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.fpp-sheet-meta { + font-size: 0.72rem; + font-weight: 600; + opacity: 0.86; +} + +.fpp-toolbar .panel-header h2 { + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.fpp-toolbar .panel-header p { + max-width: 760px; +} + +.fpp-toolbar .pill-row { + display: none; +} + +.fpp-board { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 4px; + align-items: start; +} + +.fpp-column { + overflow: hidden; + border-radius: 6px; + background: #fff; + border: 1px solid rgba(15, 23, 42, 0.08); + box-shadow: 0 4px 10px rgba(15, 23, 42, 0.03); +} + +.fpp-column-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 7px 8px 6px; + color: white; + background: linear-gradient(180deg, #b9381d 0%, #9a3412 100%); + border-bottom: 1px solid rgba(255, 255, 255, 0.18); +} + +.fpp-column-title { + font-size: 0.7rem; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.fpp-column-count { + padding: 4px 8px; + border-radius: 999px; + font-size: 0.62rem; + font-weight: 800; + color: #fff; + background: rgba(255, 255, 255, 0.14); + white-space: nowrap; +} + +.fpp-column-body { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 7px 9px; +} + +.fpp-section { + display: flex; + flex-direction: column; + gap: 4px; +} + +.fpp-section-head { + padding: 4px 6px; + border-radius: 5px; + font-size: 0.63rem; + font-weight: 900; + color: white; + text-transform: uppercase; + letter-spacing: 0.08em; + background: linear-gradient(180deg, #d46a1c 0%, #b45309 100%); +} + +.fpp-test-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0; +} + +.fpp-test { + display: flex; + align-items: flex-start; + gap: 4px; + padding: 0 2px 0 0; +} + +.fpp-dot { + width: 7px; + height: 7px; + margin-top: 5px; + border-radius: 999px; + flex: 0 0 auto; + border: 1px solid rgba(15, 23, 42, 0.35); + background: white; +} + +.fpp-test-name { + font-size: 0.73rem; + line-height: 1.18; + color: var(--text); + font-weight: 600; +} + +.fpp-test.is-disabled { + opacity: 0.32; + cursor: not-allowed; +} + +.fpp-test.is-disabled .fpp-dot { + background: #f3f4f6; + border-color: rgba(100, 116, 139, 0.55); +} + +.fpp-test.is-disabled .fpp-test-name { + color: #64748b; +} + +.fpp-selection-summary { + margin-bottom: 12px; +} + +.fpp-selection-summary strong { + display: block; + margin-bottom: 4px; +} + +.fpp-selection-summary p { + margin: 0; +} + +.fpp-select-board { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 6px; + align-items: start; +} + +.fpp-select-column { + overflow: hidden; + border-radius: 8px; + background: rgba(255, 255, 255, 0.98); + border: 1px solid rgba(15, 23, 42, 0.08); + box-shadow: 0 6px 14px rgba(15, 23, 42, 0.04); +} + +.fpp-select-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 7px 8px 6px; + color: white; + background: linear-gradient(180deg, #b9381d 0%, #9a3412 100%); + border-bottom: 1px solid rgba(255, 255, 255, 0.18); +} + +.fpp-select-title { + font-size: 0.7rem; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.fpp-select-count { + padding: 4px 8px; + border-radius: 999px; + font-size: 0.62rem; + font-weight: 800; + color: #fff; + background: rgba(255, 255, 255, 0.14); + white-space: nowrap; +} + +.fpp-select-body { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 7px 9px; +} + +.fpp-select-section { + display: flex; + flex-direction: column; + gap: 4px; +} + +.fpp-select-section-head { + padding: 4px 6px; + border-radius: 5px; + font-size: 0.63rem; + font-weight: 900; + color: white; + text-transform: uppercase; + letter-spacing: 0.08em; + background: linear-gradient(180deg, #d46a1c 0%, #b45309 100%); +} + +.fpp-select-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 1px; +} + +.fpp-select-row { + display: block; +} + +.fpp-select-label { + display: flex; + align-items: flex-start; + gap: 6px; + padding: 1px 2px 1px 0; + cursor: pointer; +} + +.fpp-select-toggle { + margin-top: 5px; + accent-color: #b91c1c; + flex: 0 0 auto; +} + +.fpp-select-name { + font-size: 0.75rem; + line-height: 1.22; + color: var(--text); + font-weight: 600; +} + +.fpp-select-row.is-disabled { + opacity: 0.34; +} + +.fpp-select-row.is-disabled .fpp-select-label { + cursor: not-allowed; +} + .mini-list { display: flex; flex-direction: column; @@ -928,6 +1214,18 @@ tr:last-child td { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .fpp-board { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .fpp-select-board { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .fpp-sheet { + padding: 10px; + } + .detail-grid, .auth-card { grid-template-columns: 1fr; @@ -971,6 +1269,19 @@ tr:last-child td { grid-template-columns: 1fr; } + .fpp-board { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .fpp-select-board { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .fpp-sheet-head { + flex-direction: column; + align-items: flex-start; + } + .desktop-only { display: none; } @@ -1047,6 +1358,19 @@ tr:last-child td { width: 100%; } + .fpp-board { + grid-template-columns: 1fr; + } + + .fpp-select-board { + grid-template-columns: 1fr; + } + + .fpp-sheet { + padding: 8px; + border-radius: 14px; + } + .table-wrap { border-radius: 18px; }