From 6f225185bfe262b5eb5c2cf7aebc60c4fa46df06 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:06:22 +0800 Subject: [PATCH 1/9] feat(ui): mode-param redirects, cross-mode links, source-preview popover, medication badges - Redirect /?mode=favourites and /?mode=differentials to their standalone routes preserving q/focus/run query params; add matching Playwright tests - Add CrossModeLinksSection component and cross-mode-links/differentials libs; show in ClinicalDashboard after document search - Add SourcePreviewPopover component fixing missing-module build error (answer-content.tsx already imported it); fix click-outside anchorRef race - Add clinical-badge.tsx and medication-badges.ts with badge logic + unit tests - standalone /applications route now uses desktopSearchPlacement=hero - differentials route passes run param; DifferentialsHomePage respects it - Update global search shell, sidebar, answer surfaces, source-actions, medications API, and prescribing workspace for this feature set - Expand ui-smoke and ui-tools Playwright coverage for new routing --- docs/codebase-index.md | 10 + public/llms.txt | 2 +- scripts/capture-chrome-parity.ts | 6 +- src/app/api/medications/route.ts | 64 +- src/app/api/search/interaction/route.ts | 56 +- src/app/applications/layout.tsx | 2 +- src/app/differentials/layout.tsx | 6 +- src/app/differentials/page.tsx | 9 +- src/app/favourites/layout.tsx | 2 +- src/app/forms/page.tsx | 23 +- src/app/globals.css | 33 +- src/app/medications/layout.tsx | 6 +- src/app/page.tsx | 24 + src/components/ClinicalDashboard.tsx | 6 + src/components/applications-launcher-page.tsx | 86 +- .../clinical-dashboard/ClinicalSidebar.tsx | 6 +- .../clinical-dashboard/answer-content.tsx | 26 +- .../answer-result-surface.tsx | 10 + .../clinical-dashboard/clinical-badge.tsx | 94 ++ .../clinical-dashboard/cross-mode-links.tsx | 184 ++++ .../global-mockup-search-shell.tsx | 27 +- .../master-search-header.tsx | 17 +- .../medication-prescribing-workspace.tsx | 869 +----------------- .../medication-record-page.tsx | 160 ++-- .../clinical-dashboard/source-actions.tsx | 14 + .../source-preview-popover.tsx | 63 ++ .../use-medication-catalog.ts | 37 +- .../differentials/differentials-home-page.tsx | 64 +- src/components/ui-primitives.tsx | 2 +- src/lib/cross-mode-differentials.ts | 20 + src/lib/cross-mode-links.ts | 273 ++++++ src/lib/medication-badges.ts | 348 +++++++ src/lib/medication-seed.ts | 33 + src/lib/medications.ts | 10 +- src/lib/search-command-surface.ts | 2 +- tests/cross-mode-links.test.ts | 141 +++ tests/medication-badges.test.ts | 113 +++ tests/search-interaction-route.test.ts | 56 ++ tests/ui-smoke.spec.ts | 40 + tests/ui-tools.spec.ts | 32 + 40 files changed, 1854 insertions(+), 1122 deletions(-) create mode 100644 src/components/clinical-dashboard/clinical-badge.tsx create mode 100644 src/components/clinical-dashboard/cross-mode-links.tsx create mode 100644 src/components/clinical-dashboard/source-preview-popover.tsx create mode 100644 src/lib/cross-mode-differentials.ts create mode 100644 src/lib/cross-mode-links.ts create mode 100644 src/lib/medication-badges.ts create mode 100644 tests/cross-mode-links.test.ts create mode 100644 tests/medication-badges.test.ts diff --git a/docs/codebase-index.md b/docs/codebase-index.md index 31ab8aee5e..bc47dabb47 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -250,6 +250,16 @@ Golden retrieval fixture: `scripts/fixtures/rag-retrieval-golden.json` - Registry modes: services, forms, medications, differentials - Demo mode: synthetic data when Supabase unavailable (`demo-data.ts`, `isDemoMode()` in `env.ts`) +### Global search composer placement rules + +One shared composer (`master-search-header.tsx`) serves every mode. Placement: + +- **Mode homes** (`/services`, `/forms`, `/favourites`, `/differentials`, `/applications`, and dashboard homes): inline in the hero via the `mode-home-composer-slot` portal, on phone and tablet+ alike. +- **Result and detail views**: fixed bottom dock on phone (compact variant on submitted searches), sticky top from `sm` up. +- **Results routing**: standalone routes own their submitted searches via `?q=…&run=1` (`/services` → `ServicesNavigatorPage`, `/forms` → `FormsSearchResultsPage`, `/differentials` → `DifferentialsHome` results view, `/favourites` filters the command library in place). Answer, Documents, and Prescribing submitted searches render inside `ClinicalDashboard` — intentional, since they need retrieval/answer state. `/?mode=favourites` redirects to `/favourites`; `/?mode=differentials` redirects to `/differentials`. +- **Intentionally composer-free routes**: `/differentials/presentations/*` (comparison workflow owns its chrome), `/documents/[id]` viewer (has its own in-document ask composer), `/documents/source/*` (document flow owns mobile chrome). Do not re-flag these in search-consistency audits. +- **Local filter fields** (sidebar "Search chats", document drawer "Find a document"/"Find a source PDF") are scoped filters, not global search; they share the `fieldControlWithIcon`/`fieldIcon` primitives. + --- ## Key config files diff --git a/public/llms.txt b/public/llms.txt index c2ec0686b7..a908f6dc0f 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -5,7 +5,7 @@ Purpose: Clinical Guide is a local clinical knowledge-base interface for searchi Agent / codebase orientation: docs/codebase-index.md (module map, APIs, Supabase, worker). Route index: docs/site-map.md. Key routes: -- / opens the main dashboard. Use ?mode=answer, ?mode=documents, ?mode=tools, ?mode=favourites, ?mode=differentials, or ?mode=prescribing to choose the workspace. +- / opens the main dashboard. Use ?mode=answer, ?mode=documents, ?mode=tools, ?mode=differentials, or ?mode=prescribing to choose the workspace. ?mode=favourites redirects to /favourites. - /documents/search opens the documents search command centre after submitting a documents-mode query. - /documents/:id opens an indexed source document. - /services opens source-backed service records. diff --git a/scripts/capture-chrome-parity.ts b/scripts/capture-chrome-parity.ts index 98e0a00c30..bd12cc9fef 100644 --- a/scripts/capture-chrome-parity.ts +++ b/scripts/capture-chrome-parity.ts @@ -94,16 +94,16 @@ const selectorGroups: Array<{ key: string; selector: string; pseudo?: string }> type Snapshot = Record>; async function mockApis(page: Page) { - await page.route("**/api/setup-status**", async (route) => { + await page.route("**/api/setup-status**", async (route: Route) => { await route.fulfill({ json: { demoMode: true, checks: readySetupChecks } }); }); - await page.route(/\/api\/documents\/[0-9a-f-]+(?:\?.*)?$/, async (route) => { + await page.route(/\/api\/documents\/[0-9a-f-]+(?:\?.*)?$/, async (route: Route) => { const id = new URL(route.request().url()).pathname.split("/").pop() ?? ""; const payload = getDemoDocumentPayload(id); if (payload) await route.fulfill({ json: payload }); else await route.fulfill({ status: 404, json: { error: "not found" } }); }); - await page.route(/\/api\/documents(?:\?.*)?$/, async (route) => { + await page.route(/\/api\/documents(?:\?.*)?$/, async (route: Route) => { await route.fulfill({ json: { documents: demoDocuments, diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 88aff92a3f..5a83873654 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -8,15 +8,19 @@ import { } from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; -import { defaultMedicationRecords, ensureMedicationsSeeded } from "@/lib/medication-seed"; +import { defaultMedicationRecords, fetchOwnerMedicationRowsWithSeed } from "@/lib/medication-seed"; import { medicationSourceStatus, medicationValidationStatus, rowGovernance, rowToMedicationRecord, - type MedicationRecordRow, } from "@/lib/medication-records"; -import { medicationToSearchResult, rankMedicationRecords, type MedicationSearchMatch } from "@/lib/medications"; +import { + medicationToSearchResult, + rankMedicationRecords, + type MedicationRecord, + type MedicationSearchMatch, +} from "@/lib/medications"; import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; @@ -34,8 +38,25 @@ const medicationListQuerySchema = z.object({ .optional() .transform((value) => (value ? value : undefined)), limit: queryInteger({ fallback: 50, min: 1, max: 100 }), + fields: z.enum(["index"]).optional(), }); +function toIndexRecords(records: MedicationRecord[]): MedicationRecord[] { + return records.map((record) => ({ + slug: record.slug, + name: record.name, + class: record.class, + subclass: record.subclass, + category: record.category, + accent: record.accent, + tag: record.tag, + schedule: record.schedule, + stats: [], + sections: [], + quick: [], + })); +} + function medicationResponse(payload: Record) { return NextResponse.json(payload, { headers: { "Cache-Control": "private, no-store" } }); } @@ -49,8 +70,8 @@ function matchesPayload(matches: MedicationSearchMatch[]) { })); } -function publicMedicationPayload(q: string | undefined, limit: number) { - const records = defaultMedicationRecords(); +function publicMedicationPayload(q: string | undefined, limit: number, fields?: "index") { + const records = fields === "index" ? toIndexRecords(defaultMedicationRecords()) : defaultMedicationRecords(); const governance = Object.fromEntries( records.map((record) => [ record.slug, @@ -71,18 +92,18 @@ function publicMedicationPayload(q: string | undefined, limit: number) { export async function GET(request: Request) { try { - const { q, limit } = parseRequestQuery(request, medicationListQuerySchema, "Invalid medication query."); + const { q, limit, fields } = parseRequestQuery(request, medicationListQuerySchema, "Invalid medication query."); if (isDemoMode() || isLocalNoAuthMode()) { return medicationResponse({ - ...publicMedicationPayload(q, limit), + ...publicMedicationPayload(q, limit, fields), demoMode: true, }); } if (!shouldResolvePublicCatalogAccess(request)) { return medicationResponse({ - ...publicMedicationPayload(q, limit), + ...publicMedicationPayload(q, limit, fields), publicAccess: true, }); } @@ -102,33 +123,14 @@ export async function GET(request: Request) { if (!access.ownerId) { return medicationResponse({ - ...publicMedicationPayload(q, limit), + ...publicMedicationPayload(q, limit, fields), publicAccess: true, }); } - const fetchRecords = async () => { - const { data, error } = await supabase - .from("medication_records") - .select("*") - .eq("owner_id", access.ownerId) - .order("name") - .limit(MEDICATION_MAX_RECORDS); - if (error) throw new Error(error.message); - return (data ?? []) as MedicationRecordRow[]; - }; - - let rows = await fetchRecords(); - if (rows.length === 0) { - try { - await ensureMedicationsSeeded(supabase, access.ownerId); - } catch (seedError) { - console.error(`[medications] auto-seed failed for owner ${access.ownerId}`, seedError); - } - rows = await fetchRecords(); - } - - const records = rows.map(rowToMedicationRecord); + const rows = await fetchOwnerMedicationRowsWithSeed(supabase, access.ownerId, MEDICATION_MAX_RECORDS); + const fullRecords = rows.map(rowToMedicationRecord); + const records = fields === "index" ? toIndexRecords(fullRecords) : fullRecords; const governanceBySlug = Object.fromEntries(rows.map((row) => [row.slug, rowGovernance(row)])); return medicationResponse({ diff --git a/src/app/api/search/interaction/route.ts b/src/app/api/search/interaction/route.ts index b05736ffbf..dd8b019dff 100644 --- a/src/app/api/search/interaction/route.ts +++ b/src/app/api/search/interaction/route.ts @@ -15,15 +15,26 @@ import { parseJsonBody } from "@/lib/validation/body"; export const runtime = "nodejs"; -const interactionSchema = z.object({ - query: z.string().trim().min(1).max(2000), - documentId: z.string().uuid(), - chunkId: z.string().uuid().optional(), - fileName: z.string().trim().max(240).optional(), +const crossModeTargetSchema = z.object({ + mode: z.enum(["prescribing", "services", "forms", "differentials"]), + slug: z.string().trim().min(1).max(160), title: z.string().trim().max(240).optional(), - queryClass: z.string().trim().max(80).optional(), }); +const interactionSchema = z + .object({ + query: z.string().trim().min(1).max(2000), + documentId: z.string().uuid().optional(), + chunkId: z.string().uuid().optional(), + fileName: z.string().trim().max(240).optional(), + title: z.string().trim().max(240).optional(), + queryClass: z.string().trim().max(80).optional(), + crossMode: crossModeTargetSchema.optional(), + }) + .refine((body) => Boolean(body.documentId || body.crossMode), { + message: "Either documentId or a crossMode target is required.", + }); + function safeTelemetryText(value: string | undefined) { const cleaned = value ?.replace(/[\u0000-\u001f\u007f]+/g, " ") @@ -74,6 +85,39 @@ export async function POST(request: Request) { // Carry the authenticated owner through so the miss row is attributable and // owner-cleanable instead of being orphaned with owner_id: null (RET-H4). const user = await serverAuth.requireAuthenticatedUser(request, supabase); + + if (!body.documentId) { + const target = body.crossMode!; + const { error: insertError } = await supabase.from("rag_query_misses").insert({ + owner_id: user.id, + query: queryTextForStorage(body.query), + normalized_query: normalizedQueryTextForStorage(body.query), + query_class: body.queryClass ?? null, + clicked_document_id: null, + clicked_chunk_id: null, + top_files: [], + top_chunk_ids: [], + miss_reason: "clicked_result", + candidate_aliases: queryDerivedTokensForStorage(normalizedClinicalSearchTokens(body.query).slice(0, 10)), + candidate_labels: [ + { + label: safeTelemetryText(target.title) ?? target.slug, + label_type: "cross_mode_target", + document_id: null, + confidence: 1, + }, + ], + metadata: { + interaction: "cross_mode_link_open", + cross_mode_target: target.mode, + cross_mode_slug: target.slug, + ...queryPrivacyMetadata(body.query), + }, + }); + if (insertError) throw new Error(insertError.message); + return NextResponse.json({ ok: true }); + } + const hasOwnedDocument = await ownedDocumentExists({ supabase, ownerId: user.id, documentId: body.documentId }); const hasOwnedChunk = hasOwnedDocument ? await ownedChunkExists({ supabase, documentId: body.documentId, chunkId: body.chunkId }) diff --git a/src/app/applications/layout.tsx b/src/app/applications/layout.tsx index c9a0125402..641518a9fb 100644 --- a/src/app/applications/layout.tsx +++ b/src/app/applications/layout.tsx @@ -4,7 +4,7 @@ import { GlobalSearchShell } from "@/components/clinical-dashboard/global-search export default function ApplicationsLayout({ children }: { children: ReactNode }) { return ( - + {children} ); diff --git a/src/app/differentials/layout.tsx b/src/app/differentials/layout.tsx index 5a35655626..b37056fb20 100644 --- a/src/app/differentials/layout.tsx +++ b/src/app/differentials/layout.tsx @@ -3,5 +3,9 @@ import type { ReactNode } from "react"; import { GlobalSearchShell } from "@/components/clinical-dashboard/global-search-shell"; export default function DifferentialsLayout({ children }: { children: ReactNode }) { - return {children}; + return ( + + {children} + + ); } diff --git a/src/app/differentials/page.tsx b/src/app/differentials/page.tsx index e3d42c168c..7c3137a01b 100644 --- a/src/app/differentials/page.tsx +++ b/src/app/differentials/page.tsx @@ -1,7 +1,7 @@ import { DifferentialsHomePage } from "@/components/differentials/differentials-home-page"; type DifferentialsRouteProps = { - searchParams?: Promise<{ query?: string | string[]; q?: string | string[] }>; + searchParams?: Promise<{ query?: string | string[]; q?: string | string[]; run?: string | string[] }>; }; function firstSearchParam(value?: string | string[]) { @@ -10,11 +10,12 @@ function firstSearchParam(value?: string | string[]) { export default async function DifferentialsHomeRoute({ searchParams }: DifferentialsRouteProps) { const params = searchParams ? await searchParams : {}; - const query = firstSearchParam(params.query ?? params.q)?.trim(); + const query = (firstSearchParam(params.q) ?? firstSearchParam(params.query) ?? "").trim(); + const hasSubmittedSearch = firstSearchParam(params.run) === "1" && query.length > 0; - if (!query) { + if (!hasSubmittedSearch) { return ; } - return ; + return ; } diff --git a/src/app/favourites/layout.tsx b/src/app/favourites/layout.tsx index ab1d61dbda..abc0eb04d3 100644 --- a/src/app/favourites/layout.tsx +++ b/src/app/favourites/layout.tsx @@ -4,7 +4,7 @@ import { GlobalSearchShell } from "@/components/clinical-dashboard/global-search export default function FavouritesLayout({ children }: { children: ReactNode }) { return ( - + {children} ); diff --git a/src/app/forms/page.tsx b/src/app/forms/page.tsx index 4a29418523..bd750838fd 100644 --- a/src/app/forms/page.tsx +++ b/src/app/forms/page.tsx @@ -1,5 +1,24 @@ import { FormsHomePage } from "@/components/forms/forms-home-page"; +import { FormsSearchResultsPage } from "@/components/forms/forms-search-results-page"; -export default function FormsPage() { - return ; +type FormsSearchParams = Promise<{ [key: string]: string | string[] | undefined }>; + +function readFirstSearchParam(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} + +export default async function FormsPage({ searchParams }: { searchParams: FormsSearchParams }) { + const resolvedSearchParams = await searchParams; + const query = ( + readFirstSearchParam(resolvedSearchParams.q) ?? + readFirstSearchParam(resolvedSearchParams.query) ?? + "" + ).trim(); + const hasSubmittedSearch = readFirstSearchParam(resolvedSearchParams.run) === "1" && query.length > 0; + + if (!hasSubmittedSearch) { + return ; + } + + return ; } diff --git a/src/app/globals.css b/src/app/globals.css index 20eb977e70..1c70efc13c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1489,7 +1489,10 @@ summary::-webkit-details-marker { padding-bottom: max(0.45rem, var(--safe-area-bottom)); } - .answer-footer-search-dock[data-scroll-hidden="true"] { + /* Must beat the edge-to-edge dock rule above (transform: none) so scroll-hide + actually slides the bar off-screen once data-scroll-hidden is set. */ + .answer-footer-search-dock.document-mobile-search-edge.answer-footer-search-edge[data-scroll-hidden="true"], + .answer-footer-search-dock.dashboard-composer-edge.answer-footer-search-edge[data-scroll-hidden="true"] { transform: translateY(calc(100% + env(safe-area-inset-bottom))); pointer-events: none; } @@ -1788,14 +1791,29 @@ summary::-webkit-details-marker { box-shadow: 0 0 0 4px color-mix(in srgb, var(--focus) 25%, transparent) !important; } - /* Premium Hover Transitions for Source Capsules and Action row chips */ + /* Premium hover transitions for source capsules */ .source-capsule-hover { - transition: all 180ms cubic-bezier(0.34, 1.56, 0.64, 1) !important; + box-shadow: var(--glow-soft), var(--shadow-inset); + transition: + transform 180ms cubic-bezier(0.34, 1.56, 0.64, 1), + box-shadow 180ms cubic-bezier(0.22, 1, 0.36, 1), + border-color 150ms ease, + background-color 150ms ease !important; } .source-capsule-hover:hover { - transform: translateY(-1px) scale(1.015) !important; - box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 8%, transparent) !important; + transform: translateY(-1px) scale(1.01); + box-shadow: var(--shadow-tight), var(--shadow-inset); + } + + .source-capsule-hover[aria-expanded="true"] { + border-color: var(--clinical-accent); + box-shadow: var(--glow-soft), var(--shadow-inset); + } + + .source-capsule-hover[aria-expanded="true"]:hover { + transform: translateY(-1px) scale(1.01); + box-shadow: var(--shadow-tight), var(--shadow-inset); } .polished-scroll { @@ -1838,6 +1856,11 @@ summary::-webkit-details-marker { scroll-behavior: auto !important; transition-duration: 0.01ms !important; } + + .source-capsule-hover:hover, + .source-capsule-hover[aria-expanded="true"]:hover { + transform: none !important; + } } @media (forced-colors: active) { diff --git a/src/app/medications/layout.tsx b/src/app/medications/layout.tsx index 31812964b3..b891de2329 100644 --- a/src/app/medications/layout.tsx +++ b/src/app/medications/layout.tsx @@ -3,5 +3,9 @@ import type { ReactNode } from "react"; import { GlobalSearchShell } from "@/components/clinical-dashboard/global-search-shell"; export default function MedicationsLayout({ children }: { children: ReactNode }) { - return {children}; + return ( + + {children} + + ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index adcf4a4668..c083f5ee9e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,3 +1,5 @@ +import { redirect } from "next/navigation"; + import { HomePageClient } from "@/app/home-page-client"; import { isAppModeId, isAppModeVisible, type AppModeId } from "@/lib/app-modes"; @@ -20,5 +22,27 @@ export default async function Home({ searchParams }: HomeProps) { const initialSearchMode: AppModeId = isAppModeId(requestedMode) && isAppModeVisible(requestedMode) ? requestedMode : "answer"; + // /favourites is the canonical favourites surface; deep links via the + // dashboard mode param would otherwise open a divergent hub view. + if (initialSearchMode === "favourites") { + const favouriteParams = new URLSearchParams(); + const query = firstSearchParam(params.q)?.trim(); + if (query) favouriteParams.set("q", query); + if (firstSearchParam(params.focus) === "1") favouriteParams.set("focus", "1"); + if (firstSearchParam(params.run) === "1") favouriteParams.set("run", "1"); + const suffix = favouriteParams.toString(); + redirect(suffix ? `/favourites?${suffix}` : "/favourites"); + } + + if (initialSearchMode === "differentials") { + const differentialParams = new URLSearchParams(); + const query = firstSearchParam(params.q)?.trim(); + if (query) differentialParams.set("q", query); + if (firstSearchParam(params.focus) === "1") differentialParams.set("focus", "1"); + if (firstSearchParam(params.run) === "1") differentialParams.set("run", "1"); + const suffix = differentialParams.toString(); + redirect(suffix ? `/differentials?${suffix}` : "/differentials"); + } + return ; } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index de0809e93b..ce4252c6ac 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -63,6 +63,7 @@ import { useAuthSession } from "@/lib/supabase/client"; import { Sheet } from "@/components/ui/sheet"; import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; import { StagedAnswerResultSurface } from "@/components/clinical-dashboard/answer-result-surface"; +import { CrossModeLinksSection } from "@/components/clinical-dashboard/cross-mode-links"; import { RelatedDocumentsPanel } from "@/components/clinical-dashboard/document-results"; import { AuthPanel } from "@/components/clinical-dashboard/auth-panel"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; @@ -3979,6 +3980,9 @@ export function ClinicalDashboard({ ) : ( <> + {searchMode === "documents" && modeSearchSubmitted && ( + + )} turn.query), latestAnswerQuery]} + onCrossModeSearch={crossModeSearch} /> ) : null diff --git a/src/components/applications-launcher-page.tsx b/src/components/applications-launcher-page.tsx index 6c151a0972..2fadcdc5d4 100644 --- a/src/components/applications-launcher-page.tsx +++ b/src/components/applications-launcher-page.tsx @@ -12,7 +12,6 @@ import { FileText, Grid2X2, Pill, - Plus, Search, ShieldCheck, Sparkles, @@ -22,10 +21,12 @@ import { X, type LucideIcon, } from "lucide-react"; -import { type FormEvent, useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context"; import { ModeHomeVerificationFooter } from "@/components/mode-home-template"; import { cn } from "@/components/ui-primitives"; +import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; type LauncherStatus = "ready" | "recent" | "review_due"; type LauncherArea = "assessment" | "reference" | "care" | "coordination" | "saved"; type LauncherFilter = "all" | LauncherArea | "more"; @@ -304,9 +305,6 @@ const toolsLauncherCopy = { countNoun: "tools", emptyTitle: "No tools match", emptyBody: "Clear the search or try another clinical workflow, tool name, or category.", - searchAriaLabel: "Search tools", - searchPlaceholder: "Search tools...", - openSelectedAriaLabel: "Open selected tool", }; const quickActions = [ @@ -400,58 +398,6 @@ function StatusChip({ label, tone = "neutral" }: { label: string; tone?: "neutra ); } -function ToolSearch({ - value, - onChange, - onSubmit, - copy, - className, -}: { - value: string; - onChange: (query: string) => void; - onSubmit: () => void; - copy: typeof toolsLauncherCopy; - className?: string; -}) { - return ( -
) => { - event.preventDefault(); - onSubmit(); - }} - className={cn( - "grid min-h-13 grid-cols-[2.75rem_minmax(0,1fr)_2.75rem] items-center rounded-full border border-[color:var(--border)] bg-[color:var(--surface-lux)] text-left shadow-[var(--shadow-card)]", - className, - )} - > - - - - - -
- ); -} - function ToolChips({ app, includeStatus = false }: { app: LauncherApp; includeStatus?: boolean }) { return ( @@ -868,11 +814,13 @@ export function ApplicationsLauncherWorkspace({ desktopComposerSlotId, className, }: ApplicationsLauncherWorkspaceProps) { - const [localQuery, setLocalQuery] = useState(""); + // Standalone routes (e.g. /applications) render under the global search + // shell, which shares its live composer query through this context. + const searchCommand = useSearchCommand(); const [activeFilter, setActiveFilter] = useState("all"); const [detailOpen, setDetailOpen] = useState(false); const copy = toolsLauncherCopy; - const query = controlledQuery ?? localQuery; + const query = controlledQuery ?? searchCommand?.query ?? ""; const normalizedQuery = query.trim().toLowerCase(); const queryDerivedId = useMemo(() => initialToolId(query), [query]); const [selection, setSelection] = useState({ @@ -903,19 +851,11 @@ export function ApplicationsLauncherWorkspace({ : (filteredApps[0]?.id ?? selectedId); const selectedApp = appById(effectiveSelectedId); - function updateQuery(nextQuery: string) { - if (controlledQuery === undefined) setLocalQuery(nextQuery); - } - function openTool(id: string) { setSelection({ queryKey: normalizedQuery, id }); setDetailOpen(true); } - function submitSearch() { - if (filteredApps[0]) openTool(filteredApps[0].id); - } - return (
- ) : ( - - )} + ) : null}
@@ -1025,5 +957,5 @@ export function ApplicationsLauncherWorkspace({ } export function ApplicationsLauncherPage() { - return ; + return ; } diff --git a/src/components/clinical-dashboard/ClinicalSidebar.tsx b/src/components/clinical-dashboard/ClinicalSidebar.tsx index d9172af478..5862fb881d 100644 --- a/src/components/clinical-dashboard/ClinicalSidebar.tsx +++ b/src/components/clinical-dashboard/ClinicalSidebar.tsx @@ -22,7 +22,7 @@ import { } from "lucide-react"; import { appModeIcons } from "@/lib/app-mode-icons"; import { BrandMark } from "@/components/clinical-dashboard/brand"; -import { cn, sidebarItem, statusDotReady, textMuted } from "@/components/ui-primitives"; +import { cn, fieldControlWithIcon, fieldIcon, sidebarItem, statusDotReady, textMuted } from "@/components/ui-primitives"; function useClientMounted() { return useSyncExternalStore( @@ -161,14 +161,14 @@ export function ClinicalSidebarContent({ pinned. */}
diff --git a/src/components/clinical-dashboard/answer-content.tsx b/src/components/clinical-dashboard/answer-content.tsx index ff148551d8..df63a7c5af 100644 --- a/src/components/clinical-dashboard/answer-content.tsx +++ b/src/components/clinical-dashboard/answer-content.tsx @@ -28,6 +28,7 @@ import { statusDotReview, subtleStatusPill, textMuted, + toneWarningQuiet, } from "@/components/ui-primitives"; import { sourceResultHref } from "@/components/clinical-dashboard/source-actions"; import { @@ -573,11 +574,12 @@ export function NaturalLanguageAnswer({ setCopiedSourceQuote(false); } } + const cautionCapsule = weakEvidence || !grounded; const sourceCapsuleButton = ( ); @@ -611,7 +619,7 @@ export function NaturalLanguageAnswer({ > -
+

@@ -657,11 +665,11 @@ export function NaturalLanguageAnswer({ ) : null} ) : null} - {sourceCapsuleButton} +

{sourceCapsuleButton}
{sourcePreviewOpen && canOpenSourcePreview && !usePreviewSheet ? (
-
+
+
+
+
+ + ); + })} +
+ + ); +} diff --git a/src/components/clinical-dashboard/global-mockup-search-shell.tsx b/src/components/clinical-dashboard/global-mockup-search-shell.tsx index ced7037dac..78592feee2 100644 --- a/src/components/clinical-dashboard/global-mockup-search-shell.tsx +++ b/src/components/clinical-dashboard/global-mockup-search-shell.tsx @@ -16,7 +16,6 @@ import { GuideDialog } from "@/components/clinical-dashboard/dashboard-shell"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; import { useTheme } from "@/components/clinical-dashboard/use-theme"; -import { FormsSearchResultsPage } from "@/components/forms/forms-search-results-page"; import { ClientHydrationBoundary } from "@/components/client-hydration-boundary"; import { cn } from "@/components/ui-primitives"; import { @@ -141,18 +140,25 @@ function GlobalMockupSearchShellClient({ const isDocumentSearchMockupRoute = pathname.startsWith("/mockups/document-search") || isDocumentFlowRoute; const isDocumentCommandSearchView = pathname === "/documents/search" && requestedQuery.length > 0; const useCompactBottomSearch = hasSubmittedModeSearch || isDocumentCommandSearchView; + // Services, forms, and favourites own their submitted-search views on their + // standalone routes; the shell must not swap them to the dashboard. On the + // home route the dashboard always renders, so these exclusions only apply + // to the standalone pages. const shouldRenderDashboardSearch = - hasSubmittedModeSearch && resolvedSearchMode !== "services" && !isDocumentSearchMockupRoute; - const isFormsOnlyShell = availableModeIds?.length === 1 && availableModeIds[0] === "forms"; - const shouldRenderFormsSearchResults = - shouldRenderDashboardSearch && resolvedSearchMode === "forms" && isFormsOnlyShell; + hasSubmittedModeSearch && + resolvedSearchMode !== "services" && + resolvedSearchMode !== "forms" && + resolvedSearchMode !== "favourites" && + resolvedSearchMode !== "differentials" && + !isDocumentSearchMockupRoute; const isStandaloneModeHome = !hasSubmittedModeSearch && !shouldRenderDashboardSearch && ((searchMode === "services" && pathname === "/services") || (searchMode === "forms" && pathname === "/forms") || (searchMode === "favourites" && pathname === "/favourites") || - (searchMode === "differentials" && pathname === "/differentials")); + (searchMode === "differentials" && pathname === "/differentials") || + (searchMode === "tools" && pathname === "/applications")); const isDifferentialPresentationWorkflow = pathname.startsWith("/differentials/presentations"); const shouldShowDesktopSidebar = !hideDesktopSidebar; const effectiveSidebarCollapsed = isDifferentialPresentationWorkflow ? true : sidebarCollapsed; @@ -285,8 +291,7 @@ function GlobalMockupSearchShellClient({ } const isMedicationDetailRoute = /^\/medications\/[^/]+$/.test(pathname); - const shouldRenderClinicalDashboard = - !isMedicationDetailRoute && (isHomeRoute || (shouldRenderDashboardSearch && !shouldRenderFormsSearchResults)); + const shouldRenderClinicalDashboard = !isMedicationDetailRoute && (isHomeRoute || shouldRenderDashboardSearch); if (shouldRenderClinicalDashboard) { return ( @@ -400,9 +405,7 @@ function GlobalMockupSearchShellClient({ // result views: compact the phone bottom composer so results keep // maximum screen space. Mode homes keep the chip-row layout. mobileBottomSearchVariant={useCompactBottomSearch ? "compact" : "default"} - desktopSearchPlacement={ - (desktopSearchPlacement === "hero" || isFormsOnlyShell) && isStandaloneModeHome ? "hero" : "default" - } + desktopSearchPlacement={desktopSearchPlacement === "hero" && isStandaloneModeHome ? "hero" : "default"} searchComposerVisible={shouldShowSearchComposer} desktopHomeComposerSlotId={isStandaloneModeHome ? modeHomeDesktopComposerSlotId : undefined} heroComposerFromTablet={isStandaloneModeHome} @@ -445,7 +448,7 @@ function GlobalMockupSearchShellClient({ onClearScopes: () => setCommandScopes([]), }} > - {shouldRenderFormsSearchResults ? : children} + {children}
diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 6feda36919..25fc13c05c 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -321,7 +321,11 @@ export function MasterSearchHeader({ }); const headerChromeHidden = scrollHidden && !modeMenuOpen && !actionMenuOpen && !scopeOpen && !scopeSheetOpen && !headerChromeFocused; - const bottomComposerScrollHiddenActive = Boolean(hideOnScroll && isMobileBottomComposer && usesPhoneSearchLayout); + const phoneBottomSearchDockActive = + usesPhoneSearchLayout && + searchComposerVisible && + (isAnswerFooterComposer || mobileSearchPlacement === "bottom"); + const bottomComposerScrollHiddenActive = Boolean(hideOnScroll && phoneBottomSearchDockActive); const bottomComposerHidden = bottomComposerScrollHiddenActive && scrollHidden && @@ -1191,7 +1195,9 @@ export function MasterSearchHeader({ const usesMobileBottomStyle = isMobileBottomComposer && !isDesktopHomeComposer; const usesCompactMobileBottomStyle = usesMobileBottomStyle && mobileBottomSearchVariant === "compact"; const usesBottomComposerPlacement = usesAnswerFooterStyle || (usesMobileBottomStyle && usesPhoneSearchLayout); - const usesFooterChipLayout = usesBottomComposerPlacement || isDesktopHomeComposer; + // Sticky-top result composers (tablet+) share the footer chip layout so the + // pill + chip row looks identical across homes, results, and the answer dock. + const usesFooterChipLayout = usesBottomComposerPlacement || isDesktopHomeComposer || usesMobileBottomStyle; // Keep footer suggestion chips on tablet/desktop; phones reach the same actions via "+". const showFooterSearchChips = usesFooterChipLayout && !usesPhoneSearchLayout; const usesSendAffordance = searchMode === "answer" || usesFooterChipLayout; @@ -1202,11 +1208,8 @@ export function MasterSearchHeader({ const secondaryFooterChip = footerSecondaryChipFor(searchMode); const TrustFooterChipIcon = trustFooterChip?.icon ?? BadgeCheck; const SecondaryFooterChipIcon = secondaryFooterChip?.icon ?? ListChecks; - const composerPlaceholder = - usesMobileBottomStyle && searchMode === "differentials" ? "Search a presentation" : queryPlaceholder; - const usesPhoneFooterDock = usesBottomComposerPlacement && usesPhoneSearchLayout; - const shouldHideBottomOnScroll = Boolean(hideOnScroll && usesMobileBottomStyle && usesPhoneFooterDock); + const shouldHideBottomOnScroll = Boolean(hideOnScroll && usesPhoneFooterDock); const commandSurfacePlacement = usesBottomComposerPlacement ? "bottom-dock" : "inline"; @@ -1346,7 +1349,7 @@ export function MasterSearchHeader({ if ((event.metaKey || event.ctrlKey) && event.key === "Enter") onAsk(); }} aria-label={`Search indexed guidelines by question or keyword - ${selectedSearch.inputAriaLabel}`} - placeholder={composerPlaceholder} + placeholder={queryPlaceholder} className={cn(chatComposerInput, "w-full min-w-0", "answer-footer-search-input")} /> {query && ( diff --git a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx index 597d4d4597..2d10779ba9 100644 --- a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx +++ b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx @@ -1,22 +1,10 @@ -"use client"; +"use client"; import { Activity, - AlertTriangle, - ArrowLeft, - ArrowLeftRight, - BadgeCheck, - Brain, CalendarDays, - Gauge, CheckCircle2, - ChevronDown, ChevronRight, - ClipboardCheck, - ClipboardList, - Droplet, - FileText, - FlaskConical, Lock, Pill, ShieldCheck, @@ -32,7 +20,7 @@ import { useSearchCommand } from "@/components/clinical-dashboard/search-command import { useMedicationCatalog } from "@/components/clinical-dashboard/use-medication-catalog"; import { medicationMatchesCommandScopes } from "@/lib/search-command-surface"; import { isDeployedClinicalKb } from "@/lib/deployed-app"; -import { cn, toneDanger, toneInfo, toneNeutral, toneSuccess, toneWarning } from "@/components/ui-primitives"; +import { cn } from "@/components/ui-primitives"; type MedicationPrescribingWorkspaceProps = { query: string; @@ -62,33 +50,6 @@ type MedicationResult = { href?: string; }; -// Badge tone key: clinical = action/instruction, success = verified/access, danger = stop/avoid, warning = adjust/check, neutral = passive metadata, info = process. -type ClinicalBadgeTone = "clinical" | "success" | "danger" | "warning" | "neutral" | "info"; - -type ClinicalBadgeItem = { - label: string; - tone?: ClinicalBadgeTone; - icon?: LucideIcon; -}; - -type DetailRow = { - label: string; - icon: LucideIcon; - summary?: string; - body?: string | string[]; - columns?: Array<{ label: string; value: string; meta?: string; metaTone?: ClinicalBadgeTone }>; - columnStyle?: "ledger" | "systems"; - badges?: ClinicalBadgeItem[]; - tone?: "default" | "danger"; - compact?: boolean; -}; - -type SideSection = { - title: string; - icon: LucideIcon; - items: Array<{ label: string; body: string; icon?: LucideIcon }>; -}; - type MedicationResultFilter = "best" | "indication" | "safety" | "monitoring"; const medicationResultFilters: Array<{ id: MedicationResultFilter; label: string }> = [ @@ -127,182 +88,6 @@ const medicationPrompts = [ { label: "sertraline max dose", icon: ShieldCheck }, ]; -const medicationIdentityBadges: ClinicalBadgeItem[] = [ - { label: "333 mg EC tablet", tone: "neutral" }, - { label: "PBS streamlined", tone: "success" }, - { label: "Reviewed", tone: "success", icon: BadgeCheck }, -]; - -const accessBadges: ClinicalBadgeItem[] = [ - { label: "Campral", tone: "neutral", icon: Pill }, - { label: "PBS streamlined", tone: "success" }, - { label: "Item 8357W", tone: "neutral" }, -]; - -const detailRows: DetailRow[] = [ - { - label: "Prescribing answer", - icon: ClipboardList, - summary: "Maintenance of alcohol abstinence after withdrawal, with renal function checked and support in place.", - body: [ - "Use for maintenance of alcohol abstinence once withdrawal is complete and the patient is abstinent.", - "Use alongside psychosocial support and relapse prevention. Not for acute alcohol withdrawal.", - ], - badges: [ - { label: "Abstinence maintenance", tone: "clinical", icon: CheckCircle2 }, - { label: "After withdrawal", tone: "neutral" }, - { label: "Psychosocial support", tone: "neutral" }, - ], - }, - { - label: "Dosing", - icon: CalendarDays, - summary: "666 mg TID with meals. Dose ceiling 1,998 mg/day.", - columnStyle: "ledger", - columns: [ - { label: "Usual dose", value: "666 mg (2 x 333 mg) TID with meals" }, - { label: "Dose ceiling", value: "1,998 mg/day", meta: "MAX", metaTone: "neutral" }, - { label: "Under 60 kg", value: "2 tablets morning, 1 midday, 1 night" }, - { label: "Treatment duration", value: "Around 1 year" }, - ], - badges: [ - { label: "666 mg TID", tone: "clinical", icon: CalendarDays }, - { label: "Max 1,998 mg/day", tone: "neutral", icon: Gauge }, - { label: "Reduce <60 kg", tone: "warning", icon: UserRound }, - { label: "Around 1 year", tone: "neutral" }, - ], - }, - { - label: "Administration", - icon: Pill, - summary: "Take with food. Swallow enteric-coated tablets whole.", - body: ["Take with food. Swallow EC tablets whole with water.", "Do not crush or chew."], - badges: [ - { label: "Take with food", tone: "clinical" }, - { label: "Swallow whole", tone: "clinical" }, - { label: "Do not crush", tone: "warning" }, - ], - }, - { - label: "Do not use", - icon: AlertTriangle, - tone: "danger", - summary: "Avoid if serum creatinine >120 micromol/L, Child-Pugh C, pregnancy, or breastfeeding.", - body: [ - "Known hypersensitivity to acamprosate or excipients.", - "Renal insufficiency: serum creatinine >120 micromol/L (contraindicated)", - "Severe hepatic failure (Child-Pugh C) (contraindicated)", - "Pregnancy (DO NOT USE)", - "Breastfeeding (DO NOT USE)", - ], - badges: [ - { label: "Cr >120 avoid", tone: "danger", icon: Droplet }, - { label: "Child-Pugh C", tone: "danger", icon: ShieldCheck }, - { label: "Pregnancy", tone: "danger" }, - { label: "Breastfeeding", tone: "danger" }, - ], - }, - { - label: "Populations", - icon: UserRound, - summary: "Avoid under 18 years and over 65 years because safety and efficacy are not established.", - body: "Avoid use in children/adolescents under 18 years and adults over 65 years: safety and efficacy are not established.", - badges: [ - { label: "Avoid <18 years", tone: "warning", icon: UserRound }, - { label: "Avoid >65 years", tone: "warning" }, - ], - }, - { - label: "Key risks", - icon: ShieldCheck, - summary: "Adverse effects grouped by system; separate from contraindications and do-not-use criteria.", - columnStyle: "systems", - columns: [ - { - label: "Gastrointestinal", - value: "Diarrhoea; nausea, vomiting, abdominal pain, flatulence", - meta: "Very common / common", - metaTone: "warning", - }, - { label: "Skin", value: "Rash and pruritus", meta: "Common", metaTone: "neutral" }, - { - label: "Sexual function", - value: "Reduced libido, impotence or frigidity", - meta: "Common", - metaTone: "neutral", - }, - { - label: "Neuropsychiatric", - value: "Mood change, depression, suicidal ideation: monitor clinically", - meta: "Monitor", - metaTone: "clinical", - }, - ], - badges: [ - { label: "Very common GI", tone: "warning" }, - { label: "Mood monitor", tone: "clinical" }, - { label: "Not contraindications", tone: "neutral" }, - ], - }, - { - label: "Pearls / PK", - icon: FlaskConical, - compact: true, - summary: "Renally excreted unchanged; half-life 13-28.4 hours.", - body: [ - "Mechanism is not fully established.", - "Not metabolised; excreted unchanged in urine.", - "Apparent half-life 13-28.4 h; minimal plasma protein binding.", - ], - badges: [ - { label: "Renal excretion", tone: "neutral" }, - { label: "Half-life 13-28.4 h", tone: "neutral" }, - { label: "Low protein binding", tone: "neutral" }, - ], - }, -]; - -const sideSections: SideSection[] = [ - { - title: "Checks and monitoring", - icon: Activity, - items: [ - { label: "Renal function", body: "Check baseline and periodically.", icon: Droplet }, - { label: "Hepatic status", body: "Avoid in severe hepatic failure; assess if suspected.", icon: ShieldCheck }, - { label: "Mood / suicidality", body: "Monitor, especially early in treatment.", icon: Brain }, - { label: "Adherence", body: "Reinforce regular dosing and psychosocial support.", icon: ClipboardCheck }, - ], - }, - { - title: "Interactions", - icon: ArrowLeftRight, - items: [ - { label: "Diazepam, disulfiram, imipramine", body: "No major PK interactions." }, - { label: "Naltrexone", body: "Increases acamprosate exposure; no dose adjustment required." }, - { label: "Other psychotropics", body: "Evidence is limited; monitor clinically." }, - ], - }, -]; - -type MedicationSectionId = "summary" | "dosing" | "safety" | "more"; -type ClinicalDetailView = "core" | "full"; - -const medicationSummaryTabs: Array<{ label: string; target: MedicationSectionId }> = [ - { label: "Summary", target: "summary" }, - { label: "Dosing", target: "dosing" }, - { label: "Safety", target: "safety" }, - { label: "More", target: "more" }, -]; - -const coreDetailLabels = new Set(["Prescribing answer", "Dosing", "Administration", "Do not use"]); - -function medicationSectionIdForLabel(label: string): MedicationSectionId { - if (label === "Dosing") return "dosing"; - if (label === "Do not use" || label === "Key risks") return "safety"; - if (label === "Populations" || label === "Pearls / PK") return "more"; - return "summary"; -} - function IconTile({ icon: Icon, tone = "teal", @@ -332,81 +117,6 @@ function IconTile({ ); } -function ClinicalBadge({ - label, - tone = "neutral", - icon: Icon, - compact = false, -}: ClinicalBadgeItem & { compact?: boolean }) { - const toneClassName: Record = { - clinical: - "border-[color:var(--clinical-accent)]/20 bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]", - success: toneSuccess, - danger: toneDanger, - warning: toneWarning, - neutral: toneNeutral, - info: toneInfo, - }; - - return ( - - {Icon ? - ); -} - -const clinicalBadgeTonePriority: Record = { - danger: 6, - warning: 5, - clinical: 4, - success: 3, - neutral: 2, - info: 1, -}; - -function BadgeCluster({ - items, - compact = false, - limit, - showOverflowCount = false, - className, -}: { - items?: ClinicalBadgeItem[]; - compact?: boolean; - limit?: number; - showOverflowCount?: boolean; - className?: string; -}) { - if (!items?.length) return null; - const orderedItems = - typeof limit === "number" - ? [...items].sort( - (a, b) => clinicalBadgeTonePriority[b.tone ?? "neutral"] - clinicalBadgeTonePriority[a.tone ?? "neutral"], - ) - : items; - const visibleItems = typeof limit === "number" ? orderedItems.slice(0, limit) : orderedItems; - const hiddenCount = typeof limit === "number" ? Math.max(0, items.length - visibleItems.length) : 0; - - return ( -
- {visibleItems.map((item, index) => ( - - ))} - {showOverflowCount && hiddenCount ? ( - - ) : null} -
- ); -} - function StatusNotice({ realDataReady, authUnavailable, @@ -613,7 +323,7 @@ function MedicationResults({ {catalog.loading ? ( -

Loading medication catalogue…

+

Loading medication catalogue…

) : catalog.error ? (

{catalog.error} @@ -773,579 +483,6 @@ function MedicationResults({

); } - -function DetailTile({ - icon, - label, - value, - meta, - danger = false, -}: { - icon: LucideIcon; - label: string; - value: string; - meta?: string; - danger?: boolean; -}) { - return ( -
-
- -
-

- {label} -

-

{value}

- {meta ?

{meta}

: null} -
-
-
- ); -} - -function DetailRowBlock({ row }: { row: DetailRow }) { - const Icon = row.icon; - const body = row.body ? (Array.isArray(row.body) ? row.body : [row.body]) : []; - const columnStyle = row.columnStyle ?? "ledger"; - - return ( -
-
- -

- {row.label} -

-
-
- - {row.columns ? ( -
= 4 ? "md:grid-cols-4" : "md:grid-cols-3"), - columnStyle === "ledger" && "md:divide-x md:divide-y-0", - columnStyle === "systems" && "text-[color:var(--text-muted)]", - )} - > - {row.columns.map((column) => ( -
-
-

{column.label}

- {column.meta ? ( - - ) : null} -
-

- {column.value} -

-
- ))} -
- ) : row.tone === "danger" ? ( -
    - {body.map((item) => ( -
  • -
  • - ))} -
- ) : ( -
-
- {body.map((item) => ( -

{item}

- ))} -
- {row.compact ? ( -
- )} -
-
- ); -} - -function ClinicalViewToggle({ - value, - onChange, -}: { - value: ClinicalDetailView; - onChange: (value: ClinicalDetailView) => void; -}) { - const options: Array<{ value: ClinicalDetailView; label: string }> = [ - { value: "core", label: "Core" }, - { value: "full", label: "Full" }, - ]; - - return ( -
- {options.map((option) => { - const active = option.value === value; - return ( - - ); - })} -
- ); -} - -function DetailLedger({ - view, - onViewChange, -}: { - view: ClinicalDetailView; - onViewChange: (value: ClinicalDetailView) => void; -}) { - const coreRows = detailRows.filter((row) => coreDetailLabels.has(row.label)); - const secondaryRows = detailRows.filter((row) => !coreDetailLabels.has(row.label)); - const visibleRows = view === "core" ? coreRows : detailRows; - - return ( -
-
-
-

Clinical summary

-

- {view === "core" ? "High-yield prescribing information" : "Full medication reference"} -

-
- -
- - {visibleRows.map((row) => ( - - ))} - - {view === "core" ? ( -
- - - - - {secondaryRows.length} sections - - -
- {secondaryRows.map((row) => ( - - ))} -
-
- ) : null} -
- ); -} - -function SidePanel({ section }: { section: SideSection }) { - const Icon = section.icon; - return ( -
-
-
-
- {section.items.map((item) => { - const ItemIcon = item.icon; - return ( -
-
- {ItemIcon ? ( - - ) : ( -
-
- ); - })} -
-
- ); -} - -function MedicationSummaryTabs({ - activeSection, - onSectionChange, -}: { - activeSection: MedicationSectionId; - onSectionChange: (section: MedicationSectionId) => void; -}) { - return ( -
-
- {medicationSummaryTabs.map((item) => ( - - ))} -
-
- ); -} - -function MedicationBadges() { - return ; -} - -function AccessPanel() { - return ( -
-
-
- -
- {[ - ["Brand", "Campral"], - ["PBS status", "PBS streamlined"], - ["PBS item", "8357W"], - ].map(([label, value], index) => ( -
-
{label}
-
{value}
-
- ))} -
-
- ); -} - -function SourcesDisclosure({ mobile = false }: { mobile?: boolean }) { - return ( -
- - - - -
- Australian Product Information, PBS, DACAS, Australian Prescriber. -
-
- ); -} - -function MobileDetailCard({ row, compact = false }: { row: DetailRow; compact?: boolean }) { - const Icon = row.icon; - const body = row.body ? (Array.isArray(row.body) ? row.body : [row.body]) : []; - const columnStyle = row.columnStyle ?? "ledger"; - - return ( -
- -
-

- {row.label} -

- - {compact && row.summary ? ( -

{row.summary}

- ) : null} - {row.columns && !compact ? ( -
- {row.columns.map((column) => ( -
-
-

{column.label}

- {column.meta ? ( - - ) : null} -
-

- {column.value} -

-
- ))} -
- ) : !compact && body.length ? ( -
- {body.map((item) => ( -

- {row.tone === "danger" ? ( -

- ))} -
- ) : null} -
-
- ); -} - -type MobileDisclosurePanelData = { - label: string; - icon: LucideIcon; - badges?: ClinicalBadgeItem[]; - body: string[]; -}; - -function MobileDisclosurePanel({ panel }: { panel: MobileDisclosurePanelData }) { - const Icon = panel.icon; - - return ( -
- - - - -
- -
    - {panel.body.map((item) => { - const separatorIndex = item.indexOf(": "); - const label = separatorIndex >= 0 ? item.slice(0, separatorIndex) : null; - const value = separatorIndex >= 0 ? item.slice(separatorIndex + 2) : item; - - return ( -
  • - {label ? {label} : null} - {value} -
  • - ); - })} -
-
-
- ); -} - -function MobileDetailList({ activeSection }: { activeSection: MedicationSectionId }) { - const rowsBySection: Record = { - summary: detailRows.filter((row) => ["Prescribing answer", "Dosing", "Do not use"].includes(row.label)), - dosing: detailRows.filter((row) => ["Dosing", "Administration"].includes(row.label)), - safety: detailRows.filter((row) => ["Do not use", "Populations", "Key risks"].includes(row.label)), - more: detailRows.filter((row) => row.label === "Pearls / PK"), - }; - const morePanels: MobileDisclosurePanelData[] = [ - { - label: "Checks and monitoring", - icon: Activity, - badges: [ - { label: "Renal function", tone: "clinical", icon: Droplet }, - { label: "Mood monitor", tone: "clinical", icon: Brain }, - { label: "Adherence", tone: "neutral", icon: ClipboardCheck }, - ], - body: sideSections[0].items.map((item) => `${item.label}: ${item.body}`), - }, - { - label: "Interactions", - icon: ArrowLeftRight, - badges: [{ label: "PK interactions limited", tone: "neutral" }], - body: sideSections[1].items.map((item) => `${item.label}: ${item.body}`), - }, - { - label: "Access", - icon: Lock, - badges: accessBadges, - body: ["Brand: Campral", "PBS status: PBS streamlined", "PBS item: 8357W"], - }, - ]; - - return ( -
-
- {rowsBySection[activeSection].map((row) => ( - - ))} -
- - {activeSection === "more" ? ( - <> -
- {morePanels.map((panel) => ( - - ))} -
- - - - ) : null} -
- ); -} - -function MedicationDetail() { - const [clinicalDetailView, setClinicalDetailView] = useState("core"); - const [activeMobileSection, setActiveMobileSection] = useState("summary"); - - return ( -
-
-
-
-
- -
-

- Acamprosate -

-

- Alcohol abstinence maintenance ·{" "} - GABA/glutamate modulator -

- -
-
-
- -
- - - - -
- - - - - - -
- - -
-
- ); -} - -export function AcamprosateMedicationPage() { - return ( -
-
- -
-
- -
-
- Clinical KB provides evidence summaries, not medical advice. Verify clinical decisions. -
-
- ); -} - export function MedicationPrescribingWorkspace({ query, loading, diff --git a/src/components/clinical-dashboard/medication-record-page.tsx b/src/components/clinical-dashboard/medication-record-page.tsx index 70e35d2396..c79c7974e1 100644 --- a/src/components/clinical-dashboard/medication-record-page.tsx +++ b/src/components/clinical-dashboard/medication-record-page.tsx @@ -9,6 +9,7 @@ import { ChevronDown, ClipboardList, FlaskConical, + Lock, Pill, ShieldCheck, type LucideIcon, @@ -16,16 +17,18 @@ import { import Link from "next/link"; import { useMemo, useState } from "react"; +import { BadgeCluster, clinicalBadgeToneClass } from "@/components/clinical-dashboard/clinical-badge"; import { useMedicationDetail } from "@/components/clinical-dashboard/use-medication-catalog"; import { - medicationDetailTiles, + medicationAccessBadges, + medicationAccessFields, medicationIdentityBadges, - type MedicationRecord, - type MedicationSection, -} from "@/lib/medications"; -import { cn, toneDanger, toneInfo, toneNeutral, toneSuccess, toneWarning } from "@/components/ui-primitives"; - -type ClinicalBadgeTone = "clinical" | "success" | "danger" | "warning" | "neutral" | "info"; + medicationRowBadges, + medicationStatTone, + type MedicationGovernance, +} from "@/lib/medication-badges"; +import { medicationDetailTiles, type MedicationRecord, type MedicationSection } from "@/lib/medications"; +import { cn } from "@/components/ui-primitives"; const sectionIcons: Record = { dose: CalendarDays, @@ -37,29 +40,6 @@ const sectionIcons: Record = { src: BadgeCheck, }; -function ClinicalBadge({ label, tone = "neutral" }: { label: string; tone?: ClinicalBadgeTone }) { - const toneClassName: Record = { - clinical: - "border-[color:var(--clinical-accent)]/20 bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]", - success: toneSuccess, - danger: toneDanger, - warning: toneWarning, - neutral: toneNeutral, - info: toneInfo, - }; - - return ( - - {label} - - ); -} - function DetailTile({ label, value, @@ -98,6 +78,7 @@ function DetailTile({ function SectionCard({ section }: { section: MedicationSection }) { const Icon = sectionIcons[section.type] || ClipboardList; + return (
- {section.rows.map((row) => ( -
-

{row.key}

-

- {row.val.replace(/\*\*/g, "")} -

- {row.tags?.length ? ( -
- {row.tags.map((tag) => ( - - ))} -
- ) : null} -
- ))} + {section.rows.map((row) => { + const rowBadges = medicationRowBadges(row, section.type); + return ( +
+

{row.key}

+ +

+ {row.val.replace(/\*\*/g, "")} +

+
+ ); + })}
); } -function MedicationRecordDetail({ record }: { record: MedicationRecord }) { +function MedicationAccessPanel({ record }: { record: MedicationRecord }) { + const badges = useMemo(() => medicationAccessBadges(record), [record]); + const fields = useMemo(() => medicationAccessFields(record), [record]); + if (!badges.length && !fields.length) return null; + + return ( +
+
+
+
+ + {fields.length ? ( +
+ {fields.map((field, index) => ( +
+
{field.label}
+
{field.value}
+
+ ))} +
+ ) : null} +
+
+ ); +} + +function MedicationRecordDetail({ + record, + governance, +}: { + record: MedicationRecord; + governance?: MedicationGovernance; +}) { const tiles = useMemo(() => medicationDetailTiles(record), [record]); - const badges = useMemo(() => medicationIdentityBadges(record), [record]); + const badges = useMemo(() => medicationIdentityBadges(record, governance), [record, governance]); const [activeTab, setActiveTab] = useState<"summary" | "dosing" | "safety" | "more">("summary"); const sectionsByTab = useMemo(() => { @@ -179,11 +202,7 @@ function MedicationRecordDetail({ record }: { record: MedicationRecord }) { ) : null}

-
- {badges.map((badge) => ( - - ))} -
+
@@ -237,7 +256,7 @@ function MedicationRecordDetail({ record }: { record: MedicationRecord }) { -