Make FPP selectable in order flow
This commit is contained in:
505
server.js
505
server.js
@@ -100,10 +100,164 @@ function layout(title, body, { authenticated = false, activePath = "/", subtitle
|
||||
<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);
|
||||
});
|
||||
|
||||
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 = '';
|
||||
}
|
||||
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 !== 'review') {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
var draft = readOrderDraft();
|
||||
if (!draft.details || !draft.details.length) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
injectOrderDraftPayload(form);
|
||||
});
|
||||
document.addEventListener('click', function (event) {
|
||||
var close = event.target.closest && event.target.closest('[data-modal-close]');
|
||||
if (!close) return;
|
||||
@@ -118,6 +272,10 @@ function layout(title, body, { authenticated = false, activePath = "/", subtitle
|
||||
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;
|
||||
@@ -127,6 +285,8 @@ function layout(title, body, { authenticated = false, activePath = "/", subtitle
|
||||
}, 240);
|
||||
}
|
||||
});
|
||||
var orderForm = document.querySelector('[data-order-form]');
|
||||
if (orderForm) restoreOrderForm(orderForm);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
@@ -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: `
|
||||
<div class="stack">
|
||||
<div class="panel" style="padding:0; box-shadow:none; background:transparent; border:0">
|
||||
${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.")}
|
||||
<div class="note-box fpp-selection-summary">
|
||||
<strong>Selected tests</strong>
|
||||
<p id="order-selected-count">0 selected</p>
|
||||
</div>
|
||||
${
|
||||
fppTests.length
|
||||
? `<div class="grid grid-2">${fppTests.slice(0, 4).map((test, index) => `
|
||||
<label class="card">
|
||||
<span class="muted">${escapeHtml(test.heading || "FPP")}${test.subcategory ? ` · ${escapeHtml(test.subcategory)}` : ""}</span>
|
||||
<strong style="display:block; margin-top:8px">${escapeHtml(test.name)}</strong>
|
||||
<input type="hidden" name="details[${index}][test_id]" value="${escapeHtml(test.testId)}" />
|
||||
<input type="hidden" name="details[${index}][test_name]" value="${escapeHtml(test.name)}" />
|
||||
<input type="hidden" name="details[${index}][price]" value="${escapeHtml(test.price)}" />
|
||||
</label>
|
||||
`).join("")}</div>`
|
||||
fppGroups.length
|
||||
? `<div class="fpp-select-board">${fppGroups
|
||||
.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>`
|
||||
: `<div class="note-box">FPP catalog not available yet.</div>`
|
||||
}
|
||||
</div>
|
||||
<div class="panel" style="padding:0; box-shadow:none; background:transparent; border:0">
|
||||
${panelHeader("Optional", "Add more test rows when needed.")}
|
||||
<div class="grid grid-2">
|
||||
${
|
||||
fppTests.length > 4
|
||||
? fppTests.slice(4, 8).map((test, index) => `
|
||||
<label class="card">
|
||||
<span class="muted">${escapeHtml(test.heading || "FPP")}${test.subcategory ? ` · ${escapeHtml(test.subcategory)}` : ""}</span>
|
||||
<strong style="display:block; margin-top:8px">${escapeHtml(test.name)}</strong>
|
||||
<input type="hidden" name="details[${index + 4}][test_id]" value="${escapeHtml(test.testId)}" />
|
||||
<input type="hidden" name="details[${index + 4}][test_name]" value="${escapeHtml(test.name)}" />
|
||||
<input type="hidden" name="details[${index + 4}][price]" value="${escapeHtml(test.price)}" />
|
||||
</label>
|
||||
`).join("")
|
||||
: `<div class="note-box">No additional FPP rows available.</div>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
qrcode: `
|
||||
@@ -604,18 +795,24 @@ function renderOrderForm(step, stepKey = "demografi", fppTests = [], mouId = "")
|
||||
.join("")}
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<div class="stack">
|
||||
${stepBody}
|
||||
<div class="topbar-actions" style="justify-content: space-between; margin-top: 10px;">
|
||||
<a class="btn btn-secondary" href="/orders/new/${prev ? prev[0] : "demografi"}">Back</a>
|
||||
<div class="pill-row">
|
||||
<a class="btn btn-ghost" href="/orders">Save draft</a>
|
||||
<a class="btn btn-primary" href="/orders/new/${next ? next[0] : "review"}">${next ? "Continue" : "Submit order"}</a>
|
||||
<form class="stack" action="/orders" method="post" data-order-form data-order-step="${escapeHtml(stepKey)}">
|
||||
<section class="panel">
|
||||
<div class="stack">
|
||||
${stepBody}
|
||||
<div class="topbar-actions" style="justify-content: space-between; margin-top: 10px;">
|
||||
<a class="btn btn-secondary" href="/orders/new/${prev ? prev[0] : "demografi"}">Back</a>
|
||||
<div class="pill-row">
|
||||
<a class="btn btn-ghost" href="/orders">Save draft</a>
|
||||
${
|
||||
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>`
|
||||
: `<button class="btn btn-primary" type="submit">Submit order</button>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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 `
|
||||
<div class="stack swap-zone">
|
||||
<section class="panel">
|
||||
${panelHeader("FPP catalog", "Filter blocks and list cards on mobile, richer panel on desktop.")}
|
||||
<div class="pill-row">
|
||||
${groupNames
|
||||
.map(
|
||||
(item) => `
|
||||
<button
|
||||
class="pill ${groups.filter === item ? "active" : ""}"
|
||||
type="button"
|
||||
hx-get="/fragments/fpp/list?group=${encodeURIComponent(item)}"
|
||||
hx-target="#fpp-fragment"
|
||||
hx-swap="innerHTML"
|
||||
hx-push-url="true"
|
||||
>
|
||||
${escapeHtml(item)}
|
||||
</button>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
<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>
|
||||
<section class="grid grid-2">
|
||||
${
|
||||
groups.items.length
|
||||
? groups.items
|
||||
.map(
|
||||
(item) => `
|
||||
<article class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h3>${escapeHtml(item.group)}</h3>
|
||||
<p>${escapeHtml(item.desc)}</p>
|
||||
${
|
||||
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">
|
||||
${visibleGroups
|
||||
.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>
|
||||
<span class="pill">${escapeHtml(item.count)} items</span>
|
||||
</div>
|
||||
<div class="grid grid-2">
|
||||
${["Filter", "Inspect", "Select", "Export"]
|
||||
.map(
|
||||
(action) => `
|
||||
<div class="card">
|
||||
<strong>${escapeHtml(action)}</strong>
|
||||
<p class="muted">Workflow action for ${escapeHtml(item.group)}.</p>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</article>
|
||||
`,
|
||||
)
|
||||
.join("")
|
||||
: `<section class="panel">${emptyState("No FPP items", "The API returned no catalog rows for this filter.")}</section>`
|
||||
}
|
||||
</section>
|
||||
<div 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>
|
||||
</section>
|
||||
`
|
||||
: `<section class="panel">${emptyState("No FPP items", "The API returned no catalog rows for this filter.")}</section>`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1031,7 +1248,7 @@ function resultsPage({ query = {}, results = [], selectedResultId = "" } = {}) {
|
||||
}
|
||||
|
||||
function fppPage({ group = "All", groups = [] } = {}) {
|
||||
return layout("FPP", `<div id="fpp-fragment">${renderFpp({ items: groups, filter: group })}</div>`, {
|
||||
return layout("FPP", `<div id="fpp-fragment">${renderFpp(groups, group)}</div>`, {
|
||||
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 `
|
||||
<div class="stack">
|
||||
<section class="panel">
|
||||
${panelHeader("FPP catalog", "Filter blocks and list cards on mobile, richer panel on desktop.")}
|
||||
<div class="pill-row">
|
||||
${groups
|
||||
.map(
|
||||
(item) => `
|
||||
<a class="pill ${group === item ? "active" : ""}" href="/fpp?group=${encodeURIComponent(item)}">${escapeHtml(item)}</a>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</section>
|
||||
<section class="grid grid-2">
|
||||
${
|
||||
items.length
|
||||
? items
|
||||
.map(
|
||||
(item) => `
|
||||
<article class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h3>${escapeHtml(item.group)}</h3>
|
||||
<p>${escapeHtml(item.desc)}</p>
|
||||
</div>
|
||||
<span class="pill">${escapeHtml(item.count)} items</span>
|
||||
</div>
|
||||
<div class="grid grid-2">
|
||||
${["Filter", "Inspect", "Select", "Export"]
|
||||
.map(
|
||||
(action) => `
|
||||
<div class="card">
|
||||
<strong>${escapeHtml(action)}</strong>
|
||||
<p class="muted">Workflow action for ${escapeHtml(item.group)}.</p>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</article>
|
||||
`,
|
||||
)
|
||||
.join("")
|
||||
: `<section class="panel">${emptyState("No FPP items", "The API returned no catalog rows for this filter.")}</section>`
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
function 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;
|
||||
}
|
||||
|
||||
|
||||
324
styles.css
324
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user