diff --git a/README.md b/README.md new file mode 100644 index 0000000..d4a4526 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# DocLink Web + +Server-rendered rebuild of the DocLink web app, following the structure in `project-specs/`. + +## Stack + +- Node.js HTTP server +- HTMX for partial updates +- Custom responsive CSS +- Upstream API adapter for auth, order, result, FPP, password change, and special message flows + +## Run + +```bash +npm start +``` + +Open: + +```text +http://localhost:5173 +``` + +## Notes + +- Login calls the upstream API from `project-specs/API_ENDPOINTS.md`. +- If the upstream login rejects the credentials or is unavailable, demo mode creates a local session so the UI can still be exercised. +- Order search, order detail helpers, FPP loading, password change, and special message save are wired through the API adapter with mock fallback for local preview. + +## Main Files + +- `server.js` +- `styles.css` +- `package.json` diff --git a/package.json b/package.json new file mode 100644 index 0000000..9ee4446 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "doclink-web", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.js", + "dev": "node server.js" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..83a446e --- /dev/null +++ b/server.js @@ -0,0 +1,1903 @@ +import http from "node:http"; +import { readFile } from "node:fs/promises"; +import { extname } from "node:path"; + +const PORT = Number(process.env.PORT || 5173); +const API_BASE = + process.env.DOCLINK_API_BASE || + "https://devbandungraya.aplikasi.web.id/one-api-doctor/doctor_mitra"; + +const sessionKey = "doclink_session"; + +const mockUser = { + name: "dr. Fajri", + role: "Dokter Mitra", + hospital: "Pramita Bandungraya", + doctorId: "DR-10024", +}; + +const mockOrders = [ + { + id: "ORD-24001", + patient: "Siti Amelia", + doctor: "dr. Fajri", + updated: "2m ago", + status: "Processing", + tone: "warning", + mode: "Inpatient", + age: "34", + gender: "F", + tests: ["Hematology", "Glucose", "Urine"], + diagnosis: "Check-up rutin", + message: "Prioritize fasting sample and note dizziness symptom.", + }, + { + id: "ORD-24002", + patient: "Budi Santoso", + doctor: "dr. Fajri", + updated: "14m ago", + status: "Ready", + tone: "success", + mode: "Outpatient", + age: "52", + gender: "M", + tests: ["Lipid Profile", "Liver Function"], + diagnosis: "Kontrol hipertensi", + message: "Call if LDL exceeds threshold.", + }, + { + id: "ORD-24003", + patient: "Nia Putri", + doctor: "dr. Fajri", + updated: "40m ago", + status: "Needs review", + tone: "danger", + mode: "Emergency", + age: "27", + gender: "F", + tests: ["Electrolytes", "CBC", "CRP"], + diagnosis: "Dehydration", + message: "Urgent release once CRP is complete.", + }, +]; + +const mockResults = [ + { + id: "RES-8821", + patient: "Siti Amelia", + test: "CBC", + status: "Released", + tone: "success", + date: "13 Apr 2026", + summary: "Hemoglobin slightly below baseline.", + value: "11.2 g/dL", + }, + { + id: "RES-8822", + patient: "Budi Santoso", + test: "Lipid Profile", + status: "Pending", + tone: "warning", + date: "13 Apr 2026", + summary: "Awaiting final approval.", + value: "LDL 145 mg/dL", + }, + { + id: "RES-8823", + patient: "Nia Putri", + test: "CRP", + status: "Review", + tone: "danger", + date: "12 Apr 2026", + summary: "Result flagged for clinical review.", + value: "18.4 mg/L", + }, +]; + +const mockFppGroups = [ + { group: "HEMATOLOGI", count: 6, desc: "Complete blood count and related panels." }, + { group: "KLINIK RUTIN", count: 5, desc: "Daily screening and basic chemistry." }, + { group: "IMUNOLOGI", count: 3, desc: "Inflammation and antibody markers." }, + { group: "URINALISA", count: 4, desc: "Urine screening and microscopic checks." }, +]; + +const mockPatients = [ + { name: "Siti Amelia", mrn: "MRN-10011", gender: "Female", lastVisit: "Today", note: "Active order" }, + { name: "Budi Santoso", mrn: "MRN-10012", gender: "Male", lastVisit: "Yesterday", note: "Repeat visit" }, + { name: "Nia Putri", mrn: "MRN-10013", gender: "Female", lastVisit: "12 Apr", note: "New registration" }, +]; + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function statusClass(status) { + const mapping = { + Processing: "warning", + Ready: "success", + "Needs review": "danger", + Released: "success", + Pending: "warning", + Review: "danger", + }; + return mapping[status] || "neutral"; +} + +function statusBadge(text) { + return `${escapeHtml(text)}`; +} + +function icon(name) { + const map = { + search: + '', + plus: + '', + bell: + '', + arrow: + '', + login: + '', + }; + return map[name] || ""; +} + +function layout(title, body, { authenticated = false, activePath = "/", subtitle = "", shell = true } = {}) { + const nav = authenticated ? desktopNav(activePath) : ""; + const mobile = authenticated ? mobileNav(activePath) : ""; + const header = authenticated ? topbar(activePath, subtitle) : ""; + return ` + + + + + ${escapeHtml(title)} + + + + + +
+
+
+
+ ${shell && authenticated ? ` +
+ ${nav} +
+
+ ${header} + ${body} +
+
+ ${mobile} +
+ ` : body} +
+ +`; +} + +function topbar(activePath, subtitle) { + 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 searchForm = activePath === "/orders" + ? ` + + ` + : activePath === "/results" + ? ` + + ` + : ` + Search orders + `; + return ` +
+
+
${escapeHtml(activePath === "/" ? "Clinical dashboard" : "Workflow view")}
+

${escapeHtml(title)}

+

${escapeHtml(subtitle || fallbackSubtitle)}

+
+
+ ${searchForm} + + ${icon("plus")} New order +
+
+ `; +} + +function desktopNav(activePath) { + const items = [ + ["/", "Home", "Dashboard"], + ["/orders", "Order", "Create & list"], + ["/results", "Result", "History"], + ["/fpp", "FPP", "Catalog"], + ["/settings", "Akun", "Profile"], + ]; + const active = (href) => activePath === href || activePath.startsWith(`${href}/`); + return ` + + `; +} + +function mobileNav(activePath) { + const items = [ + ["/", "Home", "Dashboard"], + ["/orders", "Order", "Create"], + ["/results", "Result", "History"], + ["/fpp", "FPP", "Catalog"], + ["/settings", "Akun", "Profile"], + ]; + const active = (href) => activePath === href || activePath.startsWith(`${href}/`); + return ` + + `; +} + +function panelHeader(title, text, action = "") { + return ` +
+
+

${escapeHtml(title)}

+

${escapeHtml(text)}

+
+
${action}
+
+ `; +} + +function dashboardPage() { + const stats = [ + { label: "Orders today", value: "28", trend: "+12%", hint: "Vs yesterday" }, + { label: "Results pending", value: "7", trend: "-3%", hint: "Still waiting release" }, + { label: "FPP items", value: "14", trend: "+2", hint: "Active reference set" }, + { label: "Special messages", value: "5", trend: "+1", hint: "Need follow-up" }, + ]; + const shortcuts = [ + { title: "New order", desc: "Start registration flow", href: "/orders/new" }, + { title: "Pending results", desc: "Review unreleased items", href: "/results/pending" }, + { title: "FPP catalog", desc: "Inspect lab groups", href: "/fpp" }, + { title: "Change password", desc: "Update account security", href: "/settings/change-password" }, + ]; + return ` +
+
+ ${stats + .map( + (item) => ` +
+
+ ${escapeHtml(item.label)} + ${escapeHtml(item.trend)} +
+ ${escapeHtml(item.value)} + ${escapeHtml(item.hint)} +
+ `, + ) + .join("")} +
+
+ ${panelHeader("Quick actions", "Jump into the common flows without digging through nested screens.")} +
+ ${shortcuts + .map( + (item) => ` + + ${escapeHtml(item.title)} +

${escapeHtml(item.desc)}

+ ${icon("arrow")} Open +
+ `, + ) + .join("")} +
+
+
+
+ ${panelHeader("Recent orders", "A compact snapshot of the latest patient orders and their state.", 'View all')} +
+ + + + + + ${mockOrders + .map( + (order) => ` + + + + + + + `, + ) + .join("")} + +
PatientOrderStatusUpdated
${escapeHtml(order.patient)}
${escapeHtml(order.mode)}
${escapeHtml(order.id)}
${escapeHtml(order.diagnosis)}
${statusBadge(order.status)}${escapeHtml(order.updated)}
+
+
+
+ ${panelHeader("Today’s notes", "Items that need attention now, not later.")} +
+ ${[ + ["FPP ready for release", "Review Hematology group before rounding."], + ["Pending sample", "One fasting sample still waiting in the queue."], + ["Password rotation", "Prompt user to refresh credentials after 90 days."], + ] + .map( + ([title, text]) => ` +
+
+ ${escapeHtml(title)} +

${escapeHtml(text)}

+
+ ${icon("arrow")} +
+ `, + ) + .join("")} +
+
+
+
+ `; +} + +function renderOrdersTable(orders, selectedOrderId, filter = "All") { + const selected = orders.find((item) => item.id === selectedOrderId) || orders[0] || mockOrders[0]; + return ` +
+
+ ${panelHeader("Search orders", "Use the filter to match the old app flow without giving up desktop readability.", 'Create order')} +
+ + ${["All", "Processing", "Ready", "Needs review"] + .map( + (item) => ` + + `, + ) + .join("")} +
+
+ + + + + + ${orders + .map( + (order) => ` + + + + + + + + `, + ) + .join("")} + +
PatientOrder IDDoctorStatusUpdated
${escapeHtml(order.patient)}
${escapeHtml(order.mode)}
${escapeHtml(order.id)}${escapeHtml(order.doctor)}${statusBadge(order.status)}${escapeHtml(order.updated)}
+
+
+ ${orders + .map( + (order) => ` +
+
+
+ ${escapeHtml(order.patient)} +

${escapeHtml(order.id)} · ${escapeHtml(order.mode)}

+
+ ${statusBadge(order.status)} +
+
+ ${escapeHtml(order.doctor)} + ${escapeHtml(order.updated)} +
+
+ `, + ) + .join("")} +
+
+ +
+ `; +} + +function renderOrderDetail(order) { + return ` +
+
+ ${panelHeader(`${order.patient} · ${order.id}`, "Order detail with the same structure as the proposed master-detail workflow.", `Pesan khusus`)} +
+
Status
${statusBadge(order.status)}
+
Patient${escapeHtml(order.patient)}${escapeHtml(order.mode)} · ${escapeHtml(order.age)} years
+
Doctor${escapeHtml(order.doctor)}${escapeHtml(mockUser.hospital)}
+
+
+
+
+ ${panelHeader("Requested tests", "List/table treatment on desktop, cards on smaller screens.")} +
+ ${order.tests + .map( + (test) => ` +
${escapeHtml(test)}

Included in the current order bundle.

+ `, + ) + .join("")} +
+
+ +
+
+ `; +} + +function renderOrderForm(step, stepKey = "demografi") { + const steps = [ + ["demografi", "Demografi", "Patient identity and contact details."], + ["diagnosa", "Diagnosa", "Clinical indication and diagnosis."], + ["pemeriksaan", "Pemeriksaan", "Choose examination groups."], + ["qrcode", "QR Code", "Fast entry for existing records."], + ["review", "Review", "Check before submit."], + ]; + const activeIndex = Math.max(0, steps.findIndex((item) => item[0] === stepKey)); + const stepBody = { + demografi: ` +
+ + + + + +
+ `, + diagnosa: ` +
+ + + +
+ `, + pemeriksaan: ` +
+ ${["Hematology", "Clinical Chemistry", "Urinalysis", "Immunology", "Microbiology"].map((item) => `${escapeHtml(item)}`).join("")} +
+
+
+ ${["CBC", "Glucose", "Lipid Profile", "CRP"].map((item) => ` + + `).join("")} +
+ `, + qrcode: ` +
+
+ Scan existing patient QR +

Use a QR code to pull patient and visit context instantly.

+
QR preview area
+
+
+ Manual fallback +

Still allow manual entry if the scan is unavailable.

+ +
+
+ `, + review: ` +
+
Summary

All steps are merged into a final review before submit.

+
+
Patient

Siti Amelia

+
Diagnosis

Check-up rutin

+
Tests

CBC, Glucose, Urine

+
Message

Prioritize fasting sample

+
+
+ `, + }[stepKey]; + const prev = steps[activeIndex - 1]; + const next = steps[activeIndex + 1]; + return ` +
+ ${panelHeader("Create new order", "The new project collapses the old step-heavy flow into a cleaner shell while keeping the same workflow.", 'Cancel')} +
+ ${steps + .map( + (item, index) => ` + + ${escapeHtml(item[1])} + ${escapeHtml(item[2])} + + `, + ) + .join("")} +
+
+
+
+ ${stepBody} + +
+
+ `; +} + +function renderResultsTable(results, selectedResultId) { + const selected = results.find((item) => item.id === selectedResultId) || results[0] || mockResults[0]; + return ` +
+
+ ${panelHeader("Result history", "Desktop shows table detail. Mobile collapses into stacked cards.")} +
+
Released

24 today

+
Pending

7 need approval

+
Reviewed

12 flagged

+
+
+ + + + + + ${results + .map( + (result) => ` + + + + + + + + `, + ) + .join("")} + +
PatientResult IDTestStatusDate
${escapeHtml(result.patient)}
${escapeHtml(result.summary)}
${escapeHtml(result.id)}${escapeHtml(result.test)}${statusBadge(result.status)}${escapeHtml(result.date)}
+
+
+ ${results + .map( + (result) => ` +
+
+
+ ${escapeHtml(result.patient)} +

${escapeHtml(result.test)} · ${escapeHtml(result.date)}

+
+ ${statusBadge(result.status)} +
+

${escapeHtml(result.summary)}

+
+ `, + ) + .join("")} +
+
+ +
+ `; +} + +function renderResultDetail(result) { + return ` +
+ ${panelHeader(`${result.patient} · ${result.id}`, "Result detail with summary, status, and interpretation fields.", 'Back to results')} +
+
Status
${statusBadge(result.status)}
+
Test${escapeHtml(result.test)}
+
Value${escapeHtml(result.value)}
+
+
+
+
+
${escapeHtml(result.summary)}
+
+ +
+
+ `; +} + +function renderFpp(groups) { + return ` +
+
+ ${panelHeader("FPP catalog", "Filter blocks and list cards on mobile, richer panel on desktop.")} +
+ ${["All", ...mockFppGroups.map((item) => item.group)] + .map((item) => `${escapeHtml(item)}`) + .join("")} +
+
+
+ ${groups.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("")} +
+
+ `; +} + +function renderPatients() { + return ` +
+
+ ${panelHeader("Patient registration", "Landing page for registration, lookup, and entry flow.")} +
+
+ New patient +

Open the registration stepper and start a fresh case.

+ Start registration +
+
+ Lookup +

Search an existing patient and reuse their visit data.

+ +
+
+ QR entry +

Fast path for scan-based intake.

+ Open QR flow +
+
+
+
+ ${panelHeader("Recent patients", "A compact list that becomes cards on mobile.")} +
+ + + + + + ${mockPatients + .map( + (person) => ` + + + + + + + + `, + ) + .join("")} + +
NameMRNGenderLast visitNote
${escapeHtml(person.name)}${escapeHtml(person.mrn)}${escapeHtml(person.gender)}${escapeHtml(person.lastVisit)}${escapeHtml(person.note)}
+
+
+
+ `; +} + +function renderSettings() { + return ` +
+
+ ${panelHeader("Account", "Profile data and security entry points.")} +
+
Name${escapeHtml(mockUser.name)}
+
Doctor ID${escapeHtml(mockUser.doctorId)}
+
Role${escapeHtml(mockUser.role)}
+
Hospital${escapeHtml(mockUser.hospital)}
+
+
+ +
+ `; +} + +function renderChangePassword() { + return ` +
+ ${panelHeader("Change password", "Inline validation and a straightforward submit path.")} +
+
+ + +
+
+ + +
+
+ + Cancel +
+
+
+ `; +} + +function loginPage({ error = "" } = {}) { + return layout( + "DocLink Login", + ` +
+
+
+
DocLink rebuild · responsive shell
+

Clinical workflow that works on desktop and mobile.

+

The old app behavior is preserved in a cleaner layout with dashboard, orders, results, FPP, and settings routes.

+
+
Route structure
Login, dashboard, order flow, result flow, and account pages.
+
Responsive shell
Persistent sidebar on desktop, bottom nav on mobile.
+
Clean interaction model
HTMX fragments plus server-rendered templates.
+
+
+
+
+
Sign in
+

Login to DocLink

+

Use the upstream API or switch to demo mode if the backend is unavailable.

+
+ ${error ? `
${escapeHtml(error)}
` : ""} +
+ + + + +
+ +
+
+
+ `, + { authenticated: false, shell: false, activePath: "/login" }, + ); +} + +function splashPage() { + return layout( + "DocLink Splash", + ` +
+
+
+
Starting DocLink
+

Loading workspace...

+

Checking session and routing into the right entry point.

+
+
+
+ Redirecting +

We will move you to the correct route in a moment.

+
+
+
+
+ `, + { authenticated: false, shell: false, activePath: "/splash" }, + ); +} + +function problemLoginPage() { + return layout( + "Problem Login", + ` +
+
+
+
Login problem
+

Access needs attention.

+

Use this page when auth state is broken or a session expires.

+
+
+
+ Session expired +

The session was cleared. Go back to login and sign in again.

+
+ Back to login +
+
+
+
+ `, + { authenticated: false, shell: false, activePath: "/problem-login" }, + ); +} + +function orderNewPage(path) { + const step = path.split("/").filter(Boolean)[2] || "demografi"; + return layout("Create Order", renderOrderForm({}, step), { + authenticated: true, + activePath: "/orders", + subtitle: "The new project collapses the old step-heavy flow into a cleaner shell while keeping the same workflow.", + }); +} + +function ordersPage({ query = {}, orders = mockOrders, selectedOrderId = mockOrders[0].id } = {}) { + return layout("Orders", `
${renderOrdersTable(orders, selectedOrderId, query.status || "All")}
`, { + authenticated: true, + activePath: "/orders", + }); +} + +function resultsPage({ query = {}, results = mockResults, selectedResultId = mockResults[0].id } = {}) { + return layout("Results", `
${renderResultsTable(results, selectedResultId)}
`, { + authenticated: true, + activePath: "/results", + }); +} + +function fppPage({ group = "All", groups = mockFppGroups } = {}) { + return layout("FPP", `
${renderFpp({ items: groups, filter: group })}
`, { + authenticated: true, + activePath: "/fpp", + }); +} + +function patientsPage() { + return layout("Patients", renderPatients(), { authenticated: true, activePath: "/patients" }); +} + +function settingsPage() { + return layout("Settings", renderSettings(), { authenticated: true, activePath: "/settings" }); +} + +function changePasswordPage() { + return layout("Change Password", renderChangePassword(), { + authenticated: true, + activePath: "/settings/change-password", + }); +} + +function orderDetailPage(order) { + return layout("Order Detail", renderOrderDetail(order), { + authenticated: true, + activePath: "/orders", + }); +} + +function resultDetailPage(result) { + return layout("Result Detail", renderResultDetail(result), { + authenticated: true, + activePath: "/results", + }); +} + +function specialMessagePage(order) { + return layout( + "Pesan Khusus", + ` +
+ ${panelHeader("Pesan khusus", "Desktop can treat this as a modal-style panel; mobile reads it as a dedicated page.", `Back`)} +
+
+ ${escapeHtml(order.patient)} +

${escapeHtml(order.id)}

+
${statusBadge(order.status)}
+

${escapeHtml(order.message)}

+ ${order.apiSaran ? `
${escapeHtml(typeof order.apiSaran === "string" ? order.apiSaran : JSON.stringify(order.apiSaran))}
` : ""} +
+
+ +
+ + Cancel +
+
+
+
+ `, + { authenticated: true, activePath: "/orders" }, + ); +} + +function emptyRoute(path) { + return layout("Not Found", `
Route not found

${escapeHtml(path)} is not part of the current rebuild scope.

Go to dashboard
`, { + 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 body = contentType.includes("application/json") ? await response.json() : await response.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 token = data?.token || data?.access_token || data?.accessToken || payload?.token || ""; + const username = data?.username || data?.M_UserUsername || data?.name || ""; + const doctorId = data?.doctor_id || data?.M_UserID || data?.doctorId || ""; + return { + token, + username, + doctorId, + 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 testsSource = raw?.details || raw?.tests || raw?.items || raw?.order_details || []; + const tests = Array.isArray(testsSource) + ? testsSource + .map((item) => { + if (typeof item === "string") return item; + return item?.name || item?.test_name || item?.detail_name || item?.exam || item?.label || ""; + }) + .filter(Boolean) + : []; + 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 || + `ORD-${String(index + 1).padStart(5, "0")}`, + patient: raw?.patient_name || raw?.name || raw?.patient || raw?.patient_fullname || "Patient", + doctor: raw?.doctor_name || raw?.doctor || raw?.M_UserUsername || mockUser.name, + updated: raw?.updated_at || raw?.updated || raw?.created_at || "Today", + status, + tone: statusClass(status), + mode: raw?.mode || raw?.visit_type || raw?.patient_type || "Outpatient", + age: String(raw?.age || raw?.patient_age || ""), + gender: raw?.gender || raw?.patient_gender || "", + tests, + diagnosis: raw?.patient_diagnosa || raw?.diagnosis || raw?.note || "", + message: raw?.message || raw?.patient_note || "", + }; +} + +function normalizeResult(raw, index = 0) { + const status = raw?.status || raw?.result_status || "Released"; + return { + id: + raw?.result_id || + raw?.order_id || + raw?.id || + raw?.hasil_id || + `RES-${String(index + 1).padStart(4, "0")}`, + patient: raw?.patient_name || raw?.name || raw?.patient || "Patient", + test: raw?.test_name || raw?.item_name || raw?.test || "Result", + status, + tone: statusClass(status), + date: raw?.date || raw?.created_at || raw?.updated_at || "Today", + summary: raw?.summary || raw?.note || raw?.result_summary || "", + value: raw?.value || raw?.result_value || raw?.result || "", + }; +} + +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 || mockUser.doctorId, + search: term, + }, + session.token, + ); + const rows = extractArray(payload) || []; + const orders = rows.map((row, index) => normalizeOrder(row, index)).filter((item) => { + 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; + }); + if (orders.length) return orders; + } catch { + // Fall through to mock data. + } + return filterOrders(term, status); +} + +async function loadResults(session, search = "") { + const term = String(search || "").trim(); + try { + const payload = await apiPost( + "/order/hasil_belum_keluar_by_id", + { token: session.token, order_id: session.orderId || "" }, + session.token, + ); + const rows = extractArray(payload) || []; + const results = rows.map((row, index) => normalizeResult(row, index)).filter((item) => { + if (!term) return true; + return [item.id, item.patient, item.test, item.status, item.summary].some((value) => + String(value).toLowerCase().includes(term.toLowerCase()), + ); + }); + if (results.length) return results; + } catch { + // Fall through to mock data. + } + return filterResults(term); +} + +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.", + })); + if (items.length) { + const filtered = group === "All" ? items : items.filter((item) => item.group === group); + return { items: filtered, filter: group }; + } + } catch { + // Fall through to mock data. + } + const items = group === "All" ? mockFppGroups : mockFppGroups.filter((item) => item.group === group); + return { items, filter: group }; +} + +async function loadOrderDetail(session, orderId) { + const order = mockOrders.find((item) => item.id === orderId) || mockOrders[0]; + const detail = { ...order }; + try { + const saran = await apiPost( + "/order/get_order_saran_by_order_patient_id", + { token: session.token, order_patient_id: orderId }, + session.token, + ); + detail.apiSaran = extractArray(saran) || saran?.message || saran?.note || saran?.result || ""; + } catch { + detail.apiSaran = ""; + } + try { + const hasil = await apiPost( + "/order/hasil_belum_keluar_by_id", + { token: session.token, order_id: orderId }, + session.token, + ); + detail.apiHasil = extractArray(hasil) || hasil?.message || hasil?.note || hasil?.result || ""; + } catch { + 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 = {}) { + res.writeHead(302, { Location: location, ...headers }); + res.end(); +} + +function html(res, status, body, headers = {}) { + res.writeHead(status, { "Content-Type": "text/html; charset=utf-8", ...headers }); + res.end(body); +} + +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) : {}; + return Object.fromEntries(new URLSearchParams(raw)); +} + +function filterOrders(search, status) { + const term = String(search || "").trim().toLowerCase(); + return mockOrders.filter((order) => { + const matchesSearch = + !term || + [order.id, order.patient, order.status, order.diagnosis, order.message].some((value) => + String(value).toLowerCase().includes(term), + ); + const matchesStatus = !status || status === "All" || order.status === status; + return matchesSearch && matchesStatus; + }); +} + +function filterResults(search) { + const term = String(search || "").trim().toLowerCase(); + return mockResults.filter((result) => { + if (!term) return true; + return [result.id, result.patient, result.test, result.status, result.summary].some((value) => + String(value).toLowerCase().includes(term), + ); + }); +} + +function fragmentOrdersTable(search = "", status = "All", ordersData = null) { + const orders = ordersData || filterOrders(search, status); + const selected = orders[0] || mockOrders[0]; + return ` +
+
+ ${panelHeader("Search orders", "Filter the list without full page refresh.")} +
+ + ${["All", "Processing", "Ready", "Needs review"] + .map( + (item) => ` + + `, + ) + .join("")} +
+
+ + + + + + ${orders + .map( + (order) => ` + + + + + + + + `, + ) + .join("")} + +
PatientOrder IDDoctorStatusUpdated
${escapeHtml(order.patient)}
${escapeHtml(order.mode)}
${escapeHtml(order.id)}${escapeHtml(order.doctor)}${statusBadge(order.status)}${escapeHtml(order.updated)}
+
+
+ +
+ `; +} + +function fragmentResultsTable(search = "", resultsData = null) { + const results = resultsData || filterResults(search); + const selected = results[0] || mockResults[0]; + return ` +
+
+ ${panelHeader("Result history", "Desktop shows table detail. Mobile collapses into stacked cards.")} +
+
Released

24 today

+
Pending

7 need approval

+
Reviewed

12 flagged

+
+
+ + + + + + ${results + .map( + (result) => ` + + + + + + + + `, + ) + .join("")} + +
PatientResult IDTestStatusDate
${escapeHtml(result.patient)}
${escapeHtml(result.summary)}
${escapeHtml(result.id)}${escapeHtml(result.test)}${statusBadge(result.status)}${escapeHtml(result.date)}
+
+
+ +
+ `; +} + +function fragmentFpp(group = "All", itemsData = null) { + const items = itemsData || (group === "All" ? mockFppGroups : mockFppGroups.filter((item) => item.group === group)); + return ` +
+
+ ${panelHeader("FPP catalog", "Filter blocks and list cards on mobile, richer panel on desktop.")} +
+ ${["All", ...mockFppGroups.map((item) => item.group)] + .map( + (item) => ` + ${escapeHtml(item)} + `, + ) + .join("")} +
+
+
+ ${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("")} +
+
+ `; +} + +function fragmentOrderStep(step) { + return renderOrderForm({}, step); +} + +function fragmentPesanKhusus(orderId) { + const order = mockOrders.find((item) => item.id === orderId) || mockOrders[0]; + return ` +
+ ${panelHeader("Pesan khusus", "Desktop can treat this as a modal-style panel; mobile reads it as a dedicated page.", `Back`)} +
+
+ ${escapeHtml(order.patient)} +

${escapeHtml(order.id)}

+
${statusBadge(order.status)}
+

${escapeHtml(order.message)}

+
+
+ +
+ + Cancel +
+
+
+
+ `; +} + +async function renderRoute(req, res, url) { + const path = 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 === "/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, + username: normalized.username || body.username || "", + doctorId: normalized.doctorId || body.doctor_id || "", + raw: payload, + }); + redirect(res, "/", { + "Set-Cookie": setCookie(sessionKey, sessionValue, { maxAge: 60 * 60 * 12 }), + }); + } catch (error) { + if (process.env.DOCLINK_DEMO_MODE !== "0") { + const sessionValue = JSON.stringify({ + token: "demo-token", + username: body.username || mockUser.name, + doctorId: body.doctor_id || mockUser.doctorId, + raw: { demo: true }, + }); + redirect(res, "/", { + "Set-Cookie": setCookie(sessionKey, sessionValue, { maxAge: 60 * 60 * 12 }), + }); + } else { + html(res, 200, loginPage({ error: "Login failed or upstream API unavailable. 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.doctorId || mockUser.doctorId, + M_UserUsername: sessionData.username || mockUser.name, + }, + 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 { + await apiPost( + "/auth/change_password", + { + token: sessionData.token, + M_UserID: sessionData.doctorId || mockUser.doctorId, + username: sessionData.username || mockUser.name, + doctor_id: sessionData.doctorId || mockUser.doctorId, + new_password: body.new_password, + confirm_password: body.confirm_password, + }, + sessionData.token, + ); + redirect(res, "/settings"); + } catch (error) { + html( + res, + 200, + layout( + "Change Password", + `
${panelHeader("Change password", "Inline validation and a straightforward submit path.")}
Upstream change password call failed. Check API availability.
`, + { authenticated: true, activePath: "/settings/change-password" }, + ), + ); + } + return; + } + + if (path === "/orders/new" && isGet) { + if (!requireAuth(req, res)) return; + html(res, 200, orderNewPage(path)); + return; + } + + if (path.startsWith("/orders/new/") && isGet) { + if (!requireAuth(req, res)) return; + html(res, 200, orderNewPage(path)); + return; + } + + 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 || mockOrders[0].id })); + 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 || mockResults[0].id })); + return; + } + + if (path === "/results/historical" && isGet) { + if (!requireAuth(req, res)) return; + html( + res, + 200, + layout( + "Historical results", + `
${panelHeader("Historical results", "A compact historic view that still fits the responsive shell.")}
${mockResults.map((item) => `
${escapeHtml(item.patient)}

${escapeHtml(item.test)} · ${escapeHtml(item.date)}

${statusBadge(item.status)}
`).join("")}
`, + { authenticated: true, activePath: "/results" }, + ), + ); + return; + } + + if (path === "/results/pending" && isGet) { + if (!requireAuth(req, res)) return; + html( + res, + 200, + layout( + "Pending results", + `
${panelHeader("Pending results", "Items that need attention before release.")}
${mockResults.filter((item) => item.status !== "Released").map((item) => `
${escapeHtml(item.patient)}${statusBadge(item.status)}

${escapeHtml(item.test)} · ${escapeHtml(item.summary)}

Open detail
`).join("")}
`, + { authenticated: true, activePath: "/results" }, + ), + ); + return; + } + + 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 })); + return; + } + + if (path === "/patients" && isGet) { + if (!requireAuth(req, res)) return; + html(res, 200, patientsPage()); + return; + } + + if ((path === "/settings" || path === "/settings/account") && isGet) { + if (!requireAuth(req, res)) return; + html(res, 200, settingsPage()); + return; + } + + if (path === "/settings/change-password" && isGet) { + if (!requireAuth(req, res)) return; + html(res, 200, changePasswordPage()); + return; + } + + if (path === "/" && isGet) { + if (!requireAuth(req, res)) return; + html(res, 200, layout("Dashboard", dashboardPage(), { authenticated: true, activePath: "/" })); + 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); + html(res, 200, specialMessagePage(order)); + return; + } + + if (path.startsWith("/orders/") && isGet) { + if (!requireAuth(req, res)) return; + const orderId = path.split("/")[2]; + const order = await loadOrderDetail(session, orderId); + html(res, 200, orderDetailPage(order)); + return; + } + + if (path.startsWith("/results/") && isGet) { + if (!requireAuth(req, res)) return; + const resultId = path.split("/")[2]; + const results = await loadResults(session, query.search || ""); + const result = results.find((item) => item.id === resultId) || mockResults.find((item) => item.id === resultId) || mockResults[0]; + html(res, 200, resultDetailPage(result)); + return; + } + + 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 }) }); + 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 loadFpp(session, query.group || "All"); + html(res, 200, fragmentFpp(groups.filter || "All", groups.items)); + return; + } + + if (path.startsWith("/fragments/forms/order-step/") && isGet) { + if (!requireAuth(req, res)) return; + const step = path.split("/")[4] || "demografi"; + html(res, 200, fragmentOrderStep(step)); + return; + } + + if (path === "/fragments/modals/pesan-khusus" && isGet) { + if (!requireAuth(req, res)) return; + html(res, 200, fragmentPesanKhusus(query.order_id || mockOrders[0].id)); + 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 { + await apiPost( + "/order/order_patient", + { + token: sessionData.token, + M_MouID: body.M_MouID || mockUser.doctorId, + 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, + ); + redirect(res, "/orders"); + } catch { + html( + res, + 200, + layout( + "Create Order", + `
${panelHeader("Create new order", "The new project collapses the old step-heavy flow into a cleaner shell while keeping the same workflow.")}
Upstream order create call failed. Check API availability.
`, + { authenticated: true, activePath: "/orders" }, + ), + ); + } + 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", + `
${panelHeader("Pesan khusus", "Desktop can treat this as a modal-style panel; mobile reads it as a dedicated page.")}
Upstream save failed. Check API availability.
`, + { authenticated: true, activePath: "/orders" }, + ), + ); + } + return; + } + + html(res, 404, emptyRoute(path)); +} + +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}`); +}); diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..67031c1 --- /dev/null +++ b/styles.css @@ -0,0 +1,918 @@ +:root { + color-scheme: light; + --bg: #f4f5f7; + --bg-soft: #eef2f5; + --surface: rgba(255, 255, 255, 0.82); + --surface-strong: #ffffff; + --line: rgba(15, 23, 42, 0.1); + --text: #122033; + --muted: #5b6677; + --muted-2: #7c8797; + --brand: #0f766e; + --brand-strong: #115e59; + --accent: #d97706; + --accent-soft: #fff4e6; + --success: #15803d; + --warning: #b45309; + --danger: #b91c1c; + --shadow-lg: 0 24px 60px rgba(15, 23, 42, 0.14); + --shadow-md: 0 12px 28px rgba(15, 23, 42, 0.12); + --radius-xl: 28px; + --radius-lg: 22px; + --radius-md: 16px; + --radius-sm: 12px; + --nav-width: 290px; + --content-max: 1440px; + --gap: 22px; + --font: "Avenir Next", "Nunito Sans", "Segoe UI", system-ui, sans-serif; +} + +* { + box-sizing: border-box; +} + +html, +body { + min-height: 100%; +} + +body { + margin: 0; + font-family: var(--font); + color: var(--text); + background: + radial-gradient(circle at top left, rgba(15, 118, 110, 0.08), transparent 28%), + radial-gradient(circle at bottom right, rgba(217, 119, 6, 0.07), transparent 28%), + linear-gradient(180deg, #f8fafc 0%, var(--bg) 100%); +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button { + cursor: pointer; +} + +.bg-orb, +.bg-grid { + pointer-events: none; + position: fixed; + inset: auto; + z-index: 0; +} + +.bg-orb { + width: 28rem; + height: 28rem; + border-radius: 999px; + filter: blur(32px); + opacity: 0.42; +} + +.orb-one { + top: -9rem; + left: -8rem; + background: rgba(15, 118, 110, 0.18); +} + +.orb-two { + right: -10rem; + bottom: 3rem; + background: rgba(217, 119, 6, 0.14); +} + +.bg-grid { + inset: 0; + opacity: 0.26; + background-image: + linear-gradient(rgba(15, 23, 42, 0.04) 1px, transparent 1px), + linear-gradient(90deg, rgba(15, 23, 42, 0.04) 1px, transparent 1px); + background-size: 42px 42px; + mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.5), transparent 88%); +} + +#app { + position: relative; + z-index: 1; + min-height: 100vh; +} + +.page-shell { + min-height: 100vh; + display: grid; + grid-template-columns: var(--nav-width) minmax(0, 1fr); +} + +.sidebar { + position: sticky; + top: 0; + height: 100vh; + padding: 18px; + border-right: 1px solid var(--line); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(248, 250, 252, 0.72)); + backdrop-filter: blur(22px); +} + +.sidebar-card { + height: 100%; + display: flex; + flex-direction: column; + gap: 18px; + padding: 20px; + border-radius: var(--radius-xl); + background: var(--surface); + border: 1px solid rgba(255, 255, 255, 0.7); + box-shadow: var(--shadow-lg); +} + +.brand { + display: flex; + align-items: center; + gap: 12px; +} + +.brand-mark { + width: 46px; + height: 46px; + border-radius: 16px; + display: grid; + place-items: center; + font-weight: 800; + letter-spacing: 0.05em; + color: white; + background: + radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.3), transparent 38%), + linear-gradient(135deg, var(--brand), var(--brand-strong)); + box-shadow: 0 14px 24px rgba(15, 118, 110, 0.26); +} + +.brand-text strong { + display: block; + font-size: 1rem; + line-height: 1.1; +} + +.brand-text span { + display: block; + color: var(--muted); + font-size: 0.85rem; +} + +.nav-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.nav-label { + margin: 12px 10px 6px; + color: var(--muted-2); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.14em; +} + +.nav-link { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 12px 14px; + border-radius: 14px; + color: var(--muted); + transition: + transform 160ms ease, + background 160ms ease, + color 160ms ease; +} + +.nav-link:hover { + transform: translateX(2px); + background: rgba(15, 118, 110, 0.08); + color: var(--text); +} + +.nav-link.active { + color: white; + background: linear-gradient(135deg, var(--brand), #0f9488); + box-shadow: 0 12px 24px rgba(15, 118, 110, 0.24); +} + +.nav-link small { + color: inherit; + opacity: 0.72; +} + +.sidebar-footer { + margin-top: auto; + padding: 16px; + border-radius: 18px; + background: linear-gradient(135deg, rgba(255, 247, 237, 0.95), rgba(236, 253, 245, 0.9)); + border: 1px solid rgba(217, 119, 6, 0.12); +} + +.sidebar-footer strong { + display: block; + margin-bottom: 6px; +} + +.sidebar-footer p { + margin: 0; + color: var(--muted); + font-size: 0.92rem; + line-height: 1.5; +} + +.content { + min-width: 0; + padding: 24px; +} + +.content-inner { + max-width: var(--content-max); + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 20px; +} + +.topbar { + display: flex; + align-items: center; + gap: 16px; + padding: 18px 20px; + border-radius: var(--radius-xl); + background: var(--surface); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.7); + box-shadow: var(--shadow-md); +} + +.topbar-main { + min-width: 0; + flex: 1; +} + +.eyebrow { + color: var(--brand); + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 0.74rem; + font-weight: 700; + margin-bottom: 4px; +} + +.page-title { + margin: 0; + font-size: clamp(1.35rem, 2vw, 2rem); + line-height: 1.15; +} + +.page-subtitle { + margin: 6px 0 0; + color: var(--muted); + line-height: 1.5; +} + +.topbar-actions { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.search { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px; + min-width: 280px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.92); + border: 1px solid var(--line); +} + +.search input { + width: 100%; + border: 0; + outline: none; + background: transparent; + color: var(--text); +} + +.icon-btn, +.btn { + border: 0; + border-radius: 14px; + transition: + transform 160ms ease, + box-shadow 160ms ease, + background 160ms ease; +} + +.icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + background: rgba(255, 255, 255, 0.92); + border: 1px solid var(--line); +} + +.btn { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + font-weight: 700; +} + +.btn:hover, +.icon-btn:hover { + transform: translateY(-1px); +} + +.btn-primary { + color: white; + background: linear-gradient(135deg, var(--brand), #0f9488); + box-shadow: 0 14px 26px rgba(15, 118, 110, 0.22); +} + +.btn-secondary { + color: var(--text); + background: white; + border: 1px solid var(--line); +} + +.btn-ghost { + color: var(--muted); + background: rgba(255, 255, 255, 0.78); + border: 1px solid transparent; +} + +.panel { + padding: 20px; + border-radius: var(--radius-xl); + background: var(--surface); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.72); + box-shadow: var(--shadow-md); +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + margin-bottom: 16px; +} + +.panel-header h2, +.panel-header h3 { + margin: 0; +} + +.panel-header p { + margin: 6px 0 0; + color: var(--muted); +} + +.grid { + display: grid; + gap: 18px; +} + +.grid-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.grid-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.grid-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.card { + padding: 18px; + border-radius: 22px; + background: rgba(255, 255, 255, 0.86); + border: 1px solid rgba(15, 23, 42, 0.06); +} + +.metric { + display: flex; + flex-direction: column; + gap: 10px; + min-height: 150px; +} + +.metric .kicker { + display: flex; + align-items: center; + justify-content: space-between; + color: var(--muted); + font-size: 0.88rem; +} + +.metric strong { + font-size: clamp(1.8rem, 2.2vw, 2.65rem); + letter-spacing: -0.04em; +} + +.metric .trend { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--success); + font-size: 0.88rem; + font-weight: 700; +} + +.pill-row { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.pill { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 13px; + border-radius: 999px; + border: 1px solid rgba(15, 23, 42, 0.08); + background: rgba(255, 255, 255, 0.88); + color: var(--muted); + font-size: 0.88rem; +} + +.pill.active { + color: var(--brand-strong); + background: rgba(15, 118, 110, 0.08); + border-color: rgba(15, 118, 110, 0.18); +} + +.list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.table-wrap { + overflow: auto; + border-radius: 20px; + border: 1px solid rgba(15, 23, 42, 0.08); +} + +table { + width: 100%; + border-collapse: collapse; + min-width: 860px; + background: rgba(255, 255, 255, 0.88); +} + +th, +td { + padding: 14px 16px; + border-bottom: 1px solid rgba(15, 23, 42, 0.06); + text-align: left; + vertical-align: top; +} + +th { + color: var(--muted); + font-size: 0.82rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + background: rgba(248, 250, 252, 0.98); +} + +tr:last-child td { + border-bottom: 0; +} + +.status { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 11px; + border-radius: 999px; + font-size: 0.84rem; + font-weight: 700; +} + +.status::before { + content: ""; + width: 8px; + height: 8px; + border-radius: 999px; + background: currentColor; +} + +.status.success { + color: var(--success); + background: rgba(21, 128, 61, 0.08); +} + +.status.warning { + color: var(--warning); + background: rgba(180, 83, 9, 0.09); +} + +.status.danger { + color: var(--danger); + background: rgba(185, 28, 28, 0.09); +} + +.status.neutral { + color: var(--muted); + background: rgba(15, 23, 42, 0.06); +} + +.detail-grid { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(300px, 0.75fr); + gap: 20px; +} + +.mini-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.mini-item { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 14px; + padding: 14px 15px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.84); + border: 1px solid rgba(15, 23, 42, 0.06); +} + +.mini-item strong { + display: block; + margin-bottom: 4px; +} + +.mini-item p { + margin: 0; + color: var(--muted); + font-size: 0.9rem; +} + +.stack { + display: flex; + flex-direction: column; + gap: 18px; +} + +.form { + display: grid; + gap: 16px; +} + +.field { + display: grid; + gap: 8px; +} + +.field label { + font-weight: 700; + font-size: 0.92rem; +} + +.field input, +.field select, +.field textarea { + width: 100%; + padding: 13px 14px; + border-radius: 14px; + border: 1px solid rgba(15, 23, 42, 0.12); + background: rgba(255, 255, 255, 0.9); + color: var(--text); + outline: none; +} + +.field textarea { + min-height: 120px; + resize: vertical; +} + +.field small { + color: var(--muted); +} + +.field-inline { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.stepper { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.step { + flex: 1 1 160px; + padding: 14px; + border-radius: 16px; + border: 1px solid rgba(15, 23, 42, 0.08); + background: rgba(255, 255, 255, 0.82); +} + +.step.active { + background: rgba(15, 118, 110, 0.08); + border-color: rgba(15, 118, 110, 0.18); +} + +.step strong { + display: block; +} + +.step span { + display: block; + margin-top: 4px; + color: var(--muted); + font-size: 0.88rem; +} + +.note-box { + padding: 16px 18px; + border-radius: 18px; + background: linear-gradient(135deg, rgba(255, 247, 237, 0.85), rgba(240, 253, 250, 0.9)); + border: 1px solid rgba(217, 119, 6, 0.12); + color: var(--text); +} + +.auth-screen { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; +} + +.auth-card { + width: min(1080px, 100%); + display: grid; + grid-template-columns: 1.1fr 0.9fr; + border-radius: 34px; + overflow: hidden; + background: rgba(255, 255, 255, 0.84); + border: 1px solid rgba(255, 255, 255, 0.68); + box-shadow: var(--shadow-lg); +} + +.auth-visual { + padding: 32px; + color: white; + background: + radial-gradient(circle at top right, rgba(255, 255, 255, 0.16), transparent 30%), + linear-gradient(135deg, #0f766e, #0f9488 52%, #115e59); +} + +.auth-visual .badge { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.16); + border: 1px solid rgba(255, 255, 255, 0.16); + margin-bottom: 24px; + font-size: 0.9rem; +} + +.auth-visual h1 { + margin: 0; + font-size: clamp(2rem, 5vw, 4rem); + line-height: 1; +} + +.auth-visual p { + max-width: 42ch; + font-size: 1.02rem; + line-height: 1.7; + opacity: 0.92; +} + +.auth-points { + margin-top: 32px; + display: grid; + gap: 14px; +} + +.auth-point { + padding: 14px 16px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.12); +} + +.auth-form { + padding: 32px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 18px; +} + +.auth-form h2 { + margin: 0; + font-size: 1.9rem; +} + +.muted { + color: var(--muted); +} + +.empty-state { + padding: 30px; + text-align: center; + border-radius: 24px; + background: rgba(255, 255, 255, 0.86); + border: 1px dashed rgba(15, 23, 42, 0.14); +} + +.empty-state p { + color: var(--muted); + margin: 8px 0 0; +} + +.mobile-nav { + display: none; + position: sticky; + bottom: 0; + z-index: 10; + padding: 12px 14px 18px; + background: linear-gradient(180deg, rgba(244, 245, 247, 0.08), rgba(244, 245, 247, 0.96) 28%); +} + +.mobile-nav-inner { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; + padding: 10px; + border-radius: 22px; + background: rgba(255, 255, 255, 0.82); + border: 1px solid rgba(15, 23, 42, 0.08); + box-shadow: var(--shadow-md); + backdrop-filter: blur(22px); +} + +.mobile-nav .nav-link { + flex-direction: column; + justify-content: center; + gap: 4px; + padding: 11px 8px; + font-size: 0.78rem; + text-align: center; +} + +.mobile-nav .nav-link small { + display: none; +} + +.shell-footer { + display: none; +} + +.hidden { + display: none !important; +} + +.desktop-only { + display: block; +} + +.mobile-only { + display: none; +} + +@media (max-width: 1180px) { + .grid-4, + .grid-3 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .detail-grid, + .auth-card { + grid-template-columns: 1fr; + } +} + +@media (max-width: 860px) { + .page-shell { + grid-template-columns: 1fr; + } + + .sidebar { + display: none; + } + + .content { + padding: 16px 14px 0; + } + + .topbar { + flex-direction: column; + align-items: stretch; + } + + .topbar-actions { + justify-content: space-between; + } + + .search { + min-width: 0; + } + + .mobile-nav { + display: block; + } + + .grid-4, + .grid-3, + .grid-2, + .field-inline { + grid-template-columns: 1fr; + } + + .desktop-only { + display: none; + } + + .mobile-only { + display: grid; + gap: 12px; + } + + .panel, + .topbar { + border-radius: 22px; + } + + .auth-screen { + padding: 12px; + } +} + +@media (max-width: 620px) { + .content { + padding: 12px 10px 0; + } + + .content-inner { + gap: 14px; + } + + .panel, + .topbar, + .auth-form, + .auth-visual { + padding: 16px; + } + + .mobile-nav-inner { + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 4px; + padding: 8px; + } + + .btn { + width: 100%; + justify-content: center; + } + + .topbar-actions { + flex-direction: column; + align-items: stretch; + } + + .btn, + .icon-btn, + .search { + width: 100%; + } + + .table-wrap { + border-radius: 18px; + } +}