diff --git a/docs/site-map.md b/docs/site-map.md index ad1cd4c3cc..90f807532e 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -13,6 +13,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/forms` - Forms home and search surface. Source: `src/app/forms/page.tsx`. - `/medications` - Medication index redirect. Source: `src/app/medications/page.tsx`. - `/services` - Services home and search surface. Source: `src/app/services/page.tsx`. +- `/services-navigator-preview` - Route discovered from app directory Source: `src/app/services-navigator-preview/page.tsx`. ## Mode/query routes @@ -92,6 +93,8 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/mockups/settings-search-clinical` - Route discovered from app directory Source: `src/app/mockups/settings-search-clinical/page.tsx`. - `/mockups/settings-search-general` - Route discovered from app directory Source: `src/app/mockups/settings-search-general/page.tsx`. - `/mockups/settings-search-privacy` - Route discovered from app directory Source: `src/app/mockups/settings-search-privacy/page.tsx`. +- `/mockups/tools-action-workbench` - Route discovered from app directory Source: `src/app/mockups/tools-action-workbench/page.tsx`. +- `/mockups/tools-clinical-lanes` - Route discovered from app directory Source: `src/app/mockups/tools-clinical-lanes/page.tsx`. - `/mockups/tools-command-center` - Route discovered from app directory Source: `src/app/mockups/tools-command-center/page.tsx`. - `/mockups/tools-split-pane` - Route discovered from app directory Source: `src/app/mockups/tools-split-pane/page.tsx`. - `/mockups/tools-task-directory` - Route discovered from app directory Source: `src/app/mockups/tools-task-directory/page.tsx`. diff --git a/src/app/mockups/tools-action-workbench/page.tsx b/src/app/mockups/tools-action-workbench/page.tsx new file mode 100644 index 0000000000..1a9a4cc07a --- /dev/null +++ b/src/app/mockups/tools-action-workbench/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { ToolsActionWorkbenchMockup } from "@/components/tools-page-mockups/rectangle-direction-mockups"; + +export const metadata: Metadata = { + title: "Tools Action Workbench Mockup - Clinical KB", + description: "Rectangle-first action workbench Tools page mockup for Clinical KB.", +}; + +export default function ToolsActionWorkbenchMockupRoute() { + return ; +} diff --git a/src/app/mockups/tools-clinical-lanes/page.tsx b/src/app/mockups/tools-clinical-lanes/page.tsx new file mode 100644 index 0000000000..4c5615bc80 --- /dev/null +++ b/src/app/mockups/tools-clinical-lanes/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { ToolsClinicalLanesMockup } from "@/components/tools-page-mockups/rectangle-direction-mockups"; + +export const metadata: Metadata = { + title: "Tools Clinical Lanes Mockup - Clinical KB", + description: "Rectangle-first clinical lane Tools page mockup for Clinical KB.", +}; + +export default function ToolsClinicalLanesMockupRoute() { + return ; +} diff --git a/src/app/services-navigator-preview/page.tsx b/src/app/services-navigator-preview/page.tsx new file mode 100644 index 0000000000..9c80c10716 --- /dev/null +++ b/src/app/services-navigator-preview/page.tsx @@ -0,0 +1,5 @@ +import { ServicesNavigatorPreview } from "@/components/services/services-navigator-preview"; + +export default function ServicesNavigatorPreviewRoute() { + return ; +} diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 81704772c2..5fdccee15f 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -14,15 +14,20 @@ import { createPortal } from "react-dom"; import { Activity, + BadgeCheck, BrainCircuit, CalendarDays, Check, CheckCircle2, ChevronDown, + FileSignature, FileText, Filter, + FolderOpen, + GitBranch, Globe2, Heart, + ListChecks, Loader2, Menu, MessageSquarePlus, @@ -81,7 +86,7 @@ const appModeIcons: Record = { answer: Sparkles, documents: FileText, services: ShieldCheck, - forms: FileText, + forms: FileSignature, favourites: Heart, differentials: BrainCircuit, prescribing: Pill, @@ -893,14 +898,168 @@ export function MasterSearchHeader({ ); } + // A couple of footer chips are mode-local shortcuts rather than shared + // ModeActionIds. Keep them explicit so they cannot fall through to document + // library behavior on non-document pages. + type FooterChipActionId = ModeActionId | "open-evidence" | "forms-library"; + + type FooterActionChip = { + icon: typeof Search; + shortLabel: string; + longLabel: string; + actionId: FooterChipActionId; + ariaLabel: string; + }; + + // The first ("trust") chip on the universal small-screen footer. Every mode gets + // one, mirroring Answer's "Evidence-based" chip in tone, each wired to a real + // action from that mode's own action menu rather than being decorative. + function footerTrustChipFor(mode: AppModeId): FooterActionChip | null { + switch (mode) { + case "answer": + return { + icon: ListChecks, + shortLabel: "Evidence", + longLabel: "Evidence-based", + actionId: "open-evidence", + ariaLabel: "Open evidence-backed answer sources", + }; + case "documents": + return { + icon: BadgeCheck, + shortLabel: "Indexed", + longLabel: "Fully indexed", + actionId: "documents-collections", + ariaLabel: "Open the indexed document library", + }; + case "forms": + return { + icon: BadgeCheck, + shortLabel: "Library", + longLabel: "Form library", + actionId: "forms-library", + ariaLabel: "Open the form library", + }; + case "services": + return { + icon: BadgeCheck, + shortLabel: "Verified", + longLabel: "Verified directory", + actionId: "services-records", + ariaLabel: "Browse verified service records", + }; + case "favourites": + return { + icon: BadgeCheck, + shortLabel: "Trusted", + longLabel: "Trusted picks", + actionId: "favourites-browse", + ariaLabel: "Browse trusted favourites", + }; + // Differentials has no differential-specific evidence view yet — the only + // handler for "differentials-evidence" is the answer-mode evidence drawer, + // which would take the user out of differentials context. Omit the trust + // chip here until a differential-specific action exists; "Criteria" (the + // secondary chip below) still gives differentials a footer action. + case "prescribing": + return { + icon: ShieldCheck, + shortLabel: "Safety", + longLabel: "Safety-checked", + actionId: "medication-safety", + ariaLabel: "Review contraindications and cautions", + }; + case "tools": + return { + icon: BadgeCheck, + shortLabel: "Curated", + longLabel: "Curated registry", + actionId: "tools-browse", + ariaLabel: "Browse the curated tools registry", + }; + default: + return null; + } + } + + // The second footer chip. Answer/Documents use the shared document-scope + // trigger instead (see hasScopeFooterChip below) since scope is a real, existing + // concept for those two modes. Tools has no genuine second action yet, so it + // intentionally ships with a single chip rather than an invented one. + function footerSecondaryChipFor(mode: AppModeId): FooterActionChip | null { + switch (mode) { + case "services": + return { + icon: ListChecks, + shortLabel: "Pathways", + longLabel: "Pathways", + actionId: "services-pathways", + ariaLabel: "Browse referral pathways", + }; + case "favourites": + return { + icon: FolderOpen, + shortLabel: "Sets", + longLabel: "Sets", + actionId: "favourites-sets", + ariaLabel: "Open saved sets", + }; + case "differentials": + return { + icon: GitBranch, + shortLabel: "Criteria", + longLabel: "Criteria", + actionId: "differentials-criteria", + ariaLabel: "Compare distinguishing criteria", + }; + case "prescribing": + return { + icon: Activity, + shortLabel: "Monitor", + longLabel: "Monitoring", + actionId: "medication-monitoring", + ariaLabel: "Review the monitoring schedule", + }; + default: + return null; + } + } + + function runFooterChipAction(actionId: FooterChipActionId) { + if (actionId === "open-evidence") { + onOpenEvidence?.(); + return; + } + if (actionId === "forms-library") { + onSearchModeChange("forms"); + onQueryChange(""); + return; + } + runModeAction(actionId); + } + function renderSearchComposer(placement: "default" | "desktop-home") { const isDesktopHomeComposer = placement === "desktop-home"; const usesAnswerFooterStyle = isAnswerFooterComposer && !isDesktopHomeComposer; const usesMobileBottomStyle = isMobileBottomComposer && !isDesktopHomeComposer; const usesUniversalFooterStyle = usesAnswerFooterStyle || (usesMobileBottomStyle && usesPhoneSearchLayout); - const showFooterSearchChips = usesUniversalFooterStyle && searchMode === "answer"; - // Only the Answer chat composer uses the send affordance; every search-mode home uses the magnifier. + // Every mode shows the universal footer chip row on its small-screen composer now; + // larger screens (sticky-top / hero composers) are untouched for now. + const showFooterSearchChips = usesUniversalFooterStyle; + // Answer keeps the send affordance everywhere (it's the one conversational compose + // mode). Every other mode swaps the magnifier for its own mode-identity glyph, but + // only on the small-screen floating composer — larger screens keep the magnifier. const usesSendAffordance = usesAnswerFooterStyle; + const usesModeIdentityAffordance = usesUniversalFooterStyle && !usesSendAffordance; + const ModeIdentityIcon = appModeIcons[searchMode]; + const hasScopeFooterChip = searchMode === "answer" || searchMode === "documents"; + const trustFooterChip = footerTrustChipFor(searchMode); + const secondaryFooterChip = footerSecondaryChipFor(searchMode); + // Fallback icons here are never rendered — both are only used inside a JSX guard + // on the corresponding chip being non-null — but keep the icon variables typed as + // components (not `| null`) so the JSX below type-checks without a cast. + const TrustFooterChipIcon = trustFooterChip?.icon ?? BadgeCheck; + const SecondaryFooterChipIcon = secondaryFooterChip?.icon ?? ListChecks; const composerPlaceholder = usesMobileBottomStyle && searchMode === "differentials" ? "Search a presentation" : queryPlaceholder; @@ -1004,38 +1163,56 @@ export function MasterSearchHeader({ ) : usesSendAffordance ? ( + ) : usesModeIdentityAffordance ? ( + ) : ( )} {submitLabel} - {showFooterSearchChips ? ( + {showFooterSearchChips && (trustFooterChip || hasScopeFooterChip || secondaryFooterChip) ? (
- - - {!usesScopeSheet && scopeOpen ? ( + {trustFooterChip ? ( + + ) : null} + {hasScopeFooterChip ? ( + + ) : null} + {!hasScopeFooterChip && secondaryFooterChip ? ( + + ) : null} + {hasScopeFooterChip && !usesScopeSheet && scopeOpen ? (
; + passage: string[]; + tableRows: Array<[string, string, string]>; +}; const focusRing = "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; const defaultQuery = "clozapine monitoring table"; +const mockSources: MockSourceDocument[] = [ + { + slug: "clozapine-monitoring", + title: "Clozapine physical health monitoring protocol", + fileName: "clozapine-physical-health-monitoring.pdf", + kind: "Protocol", + defaultPage: 12, + pageCount: 18, + status: "Current", + review: "Review 2026", + section: "Blood test monitoring table", + summary: + "Mock source preview for the command-centre handoff. It keeps the exact page, table evidence, and actions visible without requiring private document authentication.", + tags: ["Medication", "Monitoring", "Shared care"], + matchedTerms: ["clozapine", "monitoring", "table"], + evidence: [ + { label: "Table evidence", value: "8 rows", icon: Table2, tone: "success" }, + { label: "PDF page", value: "p.12", icon: FileText, tone: "info" }, + { label: "Review note", value: "2026", icon: AlertCircle, tone: "warning" }, + ], + passage: [ + "Monitoring requirements are grouped by treatment stage and missed-dose interval.", + "Restart and escalation decisions should be checked against the local protocol table.", + "Shared-care transfer requires the monitoring schedule and review responsibility to be visible.", + ], + tableRows: [ + ["Stable treatment", "Continue scheduled FBC/ANC checks", "Routine review"], + ["Missed dose 48-72h", "Restart pathway and monitoring check", "Prescriber review"], + ["Review due", "Confirm local protocol currency", "Document source status"], + ], + }, + { + slug: "acute-agitation-pathway", + title: "Acute agitation clinical pathway", + fileName: "acute-agitation-clinical-pathway.pdf", + kind: "Guideline", + defaultPage: 4, + pageCount: 9, + status: "Current", + review: "Local pathway", + section: "Flowchart and escalation pathway", + summary: + "Mock source preview showing how image and flowchart evidence can stay attached to the selected search result.", + tags: ["Risk", "Escalation", "ED"], + matchedTerms: ["agitation", "pathway", "flowchart"], + evidence: [ + { label: "Image evidence", value: "flowchart", icon: FileImage, tone: "info" }, + { label: "PDF page", value: "p.4", icon: FileText, tone: "success" }, + { label: "Risk pathway", value: "visible", icon: AlertCircle, tone: "warning" }, + ], + passage: [ + "The pathway separates immediate safety steps from medication and senior review prompts.", + "Flowchart evidence remains visible before opening the full source file.", + "Escalation points are grouped so the result can be scoped or used for a follow-up answer.", + ], + tableRows: [ + ["Immediate risk", "Use local safety pathway", "Escalate"], + ["De-escalation", "Document response and triggers", "Review"], + ["Senior input", "Confirm local governance", "Open source"], + ], + }, + { + slug: "mental-health-act-forms", + title: "Mental Health Act forms quick reference", + fileName: "mental-health-act-forms-reference.pdf", + kind: "Quick reference", + defaultPage: 2, + pageCount: 6, + status: "Indexed", + review: "Form checklist", + section: "Forms and documentation", + summary: + "Mock source preview for form-heavy results, keeping the document type and target page obvious from the handoff.", + tags: ["Forms", "Workflow", "Legal"], + matchedTerms: ["forms", "workflow", "reference"], + evidence: [ + { label: "Checklist", value: "forms", icon: BadgeCheck, tone: "success" }, + { label: "PDF page", value: "p.2", icon: FileText, tone: "info" }, + { label: "Workflow", value: "legal", icon: AlertCircle, tone: "warning" }, + ], + passage: [ + "The quick reference groups forms by use case and required documentation step.", + "The handoff preserves the target page so users can inspect the original source quickly.", + "Scope and answer actions remain available from the selected source preview.", + ], + tableRows: [ + ["Assessment", "Open form checklist", "Confirm status"], + ["Transfer", "Check required document", "Open source"], + ["Review", "Record local governance", "Scope"], + ], + }, +]; + async function fetchJson(url: string, signal: AbortSignal, authorizationHeader: Record): Promise { const response = await fetch(url, { cache: "no-store", @@ -74,12 +199,315 @@ function liveDocumentHref(documentId: string, result: ChunkSearchResult | undefi return `/documents/${documentId}?${params.toString()}`; } +function numberParam(value: string | null, fallback: number) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; +} + +function mockSourceFor(documentHint: string, query: string) { + const normalized = `${documentHint} ${query}`.toLowerCase(); + return ( + mockSources.find((source) => normalized.includes(source.slug) || normalized.includes(source.title.toLowerCase())) ?? + (normalized.includes("agitation") + ? mockSources.find((source) => source.slug === "acute-agitation-pathway") + : null) ?? + (normalized.includes("mental health act") || normalized.includes("forms") + ? mockSources.find((source) => source.slug === "mental-health-act-forms") + : null) ?? + mockSources[0] + ); +} + +function TonePill({ + children, + tone = "neutral", +}: { + children: React.ReactNode; + tone?: "accent" | "info" | "success" | "warning" | "neutral"; +}) { + const toneClass = + tone === "accent" + ? "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]" + : tone === "info" + ? "border-[color:var(--info-border)] bg-[color:var(--info-soft)] text-[color:var(--info)]" + : tone === "success" + ? "border-[color:var(--success-border)] bg-[color:var(--success-soft)] text-[color:var(--success)]" + : tone === "warning" + ? "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]" + : "border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)]"; + return ( + + {children} + + ); +} + +function EvidenceCard({ label, value, icon: Icon, tone }: MockSourceDocument["evidence"][number]) { + const toneClass = + tone === "success" + ? "border-[color:var(--success-border)] bg-[color:var(--success-soft)] text-[color:var(--success)]" + : tone === "warning" + ? "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]" + : "border-[color:var(--info-border)] bg-[color:var(--info-soft)] text-[color:var(--info)]"; + return ( +
+ + +

{label}

+

{value}

+
+ ); +} + +function MockDocumentPagePreview({ source, page, chunk }: { source: MockSourceDocument; page: number; chunk: string }) { + return ( +
+
+
+

+ Mock source page +

+

{source.section}

+
+
+ p.{page} + {chunk.replaceAll("-", " ")} +
+
+
+
+
+ + + +
+
+

{source.passage[0]}

+
+
+ {source.passage.slice(1).map((line) => ( +

+ {line} +

+ ))} +
+
+ + + + + + + + + + {source.tableRows.map((row, index) => ( + + {row.map((cell) => ( + + ))} + + ))} + +
Source rowWhat to reviewAction
+ {cell} +
+
+
+ +
+
+ ); +} + +function MockSourceWorkbench({ + source, + page, + chunk, + query, + message, + liveHref, +}: { + source: MockSourceDocument; + page: number; + chunk: string; + query: string; + message: string; + liveHref?: string; +}) { + return ( +
+
+
+ + +
+

+ Mock source preview +

+

+ {source.title} +

+

{message}

+
+ {liveHref ? ( + +
+
+ +
+
+ +
+ {source.evidence.map((item) => ( + + ))} +
+
+ + +
+
+ ); +} + export function DocumentSearchLiveOpener() { const router = useRouter(); const searchParams = useSearchParams(); - const { authorizationHeader } = useAuthSession(); + const { authorizationHeader, status: authStatus } = useAuthSession(); const query = searchParams.get("q")?.trim() || defaultQuery; const documentHint = searchParams.get("document")?.trim() || "clozapine"; + const mockSource = useMemo(() => mockSourceFor(documentHint, query), [documentHint, query]); + const requestedPage = numberParam(searchParams.get("page"), mockSource.defaultPage); + const chunk = searchParams.get("chunk")?.trim() || "best-match"; const [state, setState] = useState({ status: "opening", message: "Finding an indexed document and matching source chunk.", @@ -91,6 +519,11 @@ export function DocumentSearchLiveOpener() { const controller = new AbortController(); async function openLiveDocument() { + if (authStatus === "loading") { + setState({ status: "opening", message: "Checking browser document access." }); + return; + } + try { setState({ status: "opening", message: "Finding a real indexed document." }); const documentParams = new URLSearchParams({ @@ -118,8 +551,9 @@ export function DocumentSearchLiveOpener() { if (documents.length === 0) { setState({ - status: "error", - message: "No indexed documents are available to open in the live viewer.", + status: "mock", + message: + "No indexed live document was available for this lookup. This mock preview shows the intended source handoff.", }); return; } @@ -161,19 +595,22 @@ export function DocumentSearchLiveOpener() { } catch (error) { if (controller.signal.aborted) return; setState({ - status: "error", - message: error instanceof Error ? error.message : "The live document could not be opened.", + status: "mock", + message: + error instanceof Error + ? `${error.message} Showing the mock source preview instead.` + : "The live document could not be opened. Showing the mock source preview instead.", }); } } void openLiveDocument(); return () => controller.abort(); - }, [authorizationHeader, lookupTerm, query, router]); + }, [authStatus, authorizationHeader, lookupTerm, query, router]); return (
-
+
-
-
- - {state.status === "opening" ? ( + {state.status === "opening" ? ( +
+
+ -
-

- Live document handoff -

-

- {state.status === "opening" ? "Opening the actual document" : "Could not open the actual document"} -

-

{state.message}

-
- - - - + +
+

+ Live document handoff +

+

+ Opening the actual document +

+

{state.message}

+
+ + + + +
-
- - {state.liveHref ? ( - -
+
+ ) : ( + + )}
); diff --git a/src/components/forms/form-detail-page.tsx b/src/components/forms/form-detail-page.tsx index daad9f0fb5..d12ee6db03 100644 --- a/src/components/forms/form-detail-page.tsx +++ b/src/components/forms/form-detail-page.tsx @@ -644,6 +644,10 @@ export function FormDetailPage({ form }: { form: FormRecord }) { )} +
+ +
+

Priority facts @@ -655,10 +659,6 @@ export function FormDetailPage({ form }: { form: FormRecord }) {

-
- -
-
diff --git a/src/components/services/services-navigator-preview.tsx b/src/components/services/services-navigator-preview.tsx new file mode 100644 index 0000000000..bb6219ab0d --- /dev/null +++ b/src/components/services/services-navigator-preview.tsx @@ -0,0 +1,771 @@ +"use client"; + +import Link from "next/link"; +import { + ArrowRight, + Bookmark, + Check, + ChevronDown, + CircleAlert, + CircleCheck, + CircleX, + DollarSign, + ExternalLink, + Menu, + Mic, + Phone, + Plus, + Search, + Send, + ShieldCheck, + SlidersHorizontal, + Users, + X, + type LucideIcon, +} from "lucide-react"; +import { useMemo, useState } from "react"; + +import { cn } from "@/components/ui-primitives"; +import { searchServiceRecords, serviceRecords, type ServiceRecord, type ServiceStatusChip } from "@/lib/services"; + +const defaultQuery = "13YARN crisis support aboriginal phone"; + +function visibleText(value: string | null | undefined, fallback = "Confirm locally") { + return value?.trim() ? value.trim() : fallback; +} + +function chipToneClass(tone: ServiceStatusChip["tone"] | undefined | null) { + if (tone === "danger") return "border-red-200 bg-red-50 text-red-700"; + if (tone === "info") return "border-sky-200 bg-sky-50 text-sky-700"; + if (tone === "warning") return "border-orange-200 bg-orange-50 text-orange-700"; + if (tone === "success") return "border-emerald-200 bg-emerald-50 text-emerald-700"; + return "border-slate-200 bg-slate-50 text-slate-600"; +} + +function criterionCounts(records: ServiceRecord[]) { + return records.reduce( + (totals, service) => { + for (const criterion of service.criteria ?? []) { + if (criterion.tone === "meet") totals.meets += 1; + if (criterion.tone === "caution") totals.cautions += 1; + if (criterion.tone === "reject") totals.rejects += 1; + } + return totals; + }, + { meets: 0, cautions: 0, rejects: 0 }, + ); +} + +function confidenceCounts(records: ServiceRecord[]) { + return records.reduce( + (totals, service) => { + const confidence = service.verification?.confidence ?? "Unknown"; + if (confidence === "High") totals.high += 1; + else if (confidence === "Medium") totals.medium += 1; + else if (confidence === "Low") totals.low += 1; + else totals.unknown += 1; + return totals; + }, + { high: 0, medium: 0, low: 0, unknown: 0 }, + ); +} + +function ServiceBadge({ chip }: { chip: ServiceStatusChip }) { + return ( + + + {visibleText(chip.label, "Status")} + + ); +} + +function Metric({ + icon: Icon, + label, + value, + detail, +}: { + icon: LucideIcon; + label: string; + value: string; + detail: string; +}) { + return ( +
+ + + + + {label} + {value} + {detail} + +
+ ); +} + +function ServiceCard({ + service, + index, + selected, + onToggleSelected, + compact = false, +}: { + service: ServiceRecord; + index: number; + selected: boolean; + onToggleSelected: (slug: string) => void; + compact?: boolean; +}) { + const rank = index + 1; + const highlighted = rank <= 2; + const contact = visibleText(service.primaryContact?.value); + const route = visibleText(service.primaryContact?.detail ?? service.route, "Referral route pending"); + const eligibility = visibleText(service.eligibility, "Eligibility pending"); + const cost = visibleText(service.cost, "Cost pending"); + const tags = [...(service.catchments ?? []), ...(service.tags ?? [])].slice(0, compact ? 3 : 5); + + return ( +
+
+ + {rank} + +
+
+

+ {service.title} +

+ {!compact && highlighted ? ( + Best fit + ) : null} +
+
+ {(service.statusChips ?? []).slice(0, compact ? 3 : 4).map((chip) => ( + + ))} +
+

+ {visibleText(service.subtitle ?? service.bestUse, "Open the record for referral details.")} +

+
+ +
+ +
+ + + + +
+ +
+
+ {tags.map((tag, tagIndex) => ( + 2 ? "max-sm:hidden" : "", + )} + > + {tag} + + ))} + {(service.tags?.length ?? 0) + (service.catchments?.length ?? 0) > tags.length ? ( + + +1 + + ) : null} +
+
+ + + Open + + +
+
+
+ ); +} + +function SearchBar({ + value, + onChange, + compact = false, + showSubmit = true, +}: { + value: string; + onChange: (next: string) => void; + compact?: boolean; + showSubmit?: boolean; +}) { + return ( +
event.preventDefault()} + > + + + onChange(event.target.value)} + placeholder="Search services..." + className="min-w-0 bg-transparent text-sm font-semibold text-[#061740] outline-none placeholder:text-slate-400" + /> + {value ? ( + + ) : null} + + {showSubmit ? ( + + ) : null} + + ); +} + +function Header() { + return ( +
+
+ + + +
+

Services Navigator

+

Psychiatry referral directory

+
+ + +
+
+ + + + AK + +
+
+ ); +} + +function Stepper() { + const steps = [ + ["1", "Search", "Find services"], + ["2", "Shortlist", "Pick best options"], + ["3", "Compare", "Review side by side"], + ["4", "Refer", "Send with confidence"], + ]; + return ( +
+ {steps.map(([number, title, body], index) => ( +
+ + {number} + + + + {title} + + {body} + +
+ ))} +
+ ); +} + +function DesktopRightRail({ + matches, + selected, + onToggleSelected, +}: { + matches: ServiceRecord[]; + selected: ServiceRecord[]; + onToggleSelected: (slug: string) => void; +}) { + const criteria = criterionCounts(matches); + const confidence = confidenceCounts(matches); + const localConfirmationCount = matches.filter((service) => + (service.source?.status ?? "").toLowerCase().includes("confirmation"), + ).length; + const verifiedCount = matches.filter( + (service) => + service.verification?.locallyVerified || (service.source?.status ?? "").toLowerCase().includes("source"), + ).length; + const checklistRows: Array<[string, number, LucideIcon, string]> = [ + ["Meets", criteria.meets, CircleCheck, "text-emerald-600"], + ["Caution", criteria.cautions, CircleAlert, "text-orange-500"], + ["Does not meet", criteria.rejects, CircleX, "text-red-600"], + ["Source verified", verifiedCount, CircleCheck, "text-emerald-600"], + ["Local confirmation", localConfirmationCount, CircleAlert, "text-orange-500"], + ]; + + return ( + + ); +} + +function PhonePreview({ + query, + onQueryChange, + matches, + selectedSlugs, + onToggleSelected, +}: { + query: string; + onQueryChange: (next: string) => void; + matches: ServiceRecord[]; + selectedSlugs: string[]; + onToggleSelected: (slug: string) => void; +}) { + return ( +
+
+
+
+ +
+ + + + Services Navigator +
+ +
+
+
+
+

{matches.length} referral matches

+

+ Best fit for crisis, ATSI-specific, phone referral. +

+
+ +
+
+ {["Best fit", "Crisis", "ATSI-specific", "+3"].map((chip, index) => ( + 2 ? "max-sm:hidden" : "", + index === 0 ? "border-[#007a78] bg-[#007a78] text-white" : "border-slate-200 bg-white text-slate-600", + )} + > + {chip} + + ))} +
+ +
+ {matches.slice(0, 3).map((service, index) => ( + + ))} +
+
+
+ {selectedSlugs.length} selected + Compare + +
+
+
+ + + +
+
+
+
+ ); +} + +export function ServicesNavigatorPreview() { + const [query, setQuery] = useState(defaultQuery); + const matches = useMemo(() => { + const ranked = searchServiceRecords(query); + return ranked.length ? ranked.map((match) => match.service) : serviceRecords; + }, [query]); + const [selectedSlugs, setSelectedSlugs] = useState(() => serviceRecords.slice(0, 2).map((service) => service.slug)); + const selectedServices = serviceRecords.filter((service) => selectedSlugs.includes(service.slug)); + + function toggleSelected(slug: string) { + setSelectedSlugs((current) => { + if (current.includes(slug)) return current.filter((item) => item !== slug); + return [slug, ...current].slice(0, 5); + }); + } + + return ( +
+
+
+
+
+
+ + +
+ +
+
+
+
+

+ {matches.length} referral matches +

+

+ Best fit for crisis, ATSI-specific, phone referral. +

+
+ +
+
+ {["Best fit", "Crisis", "ATSI-specific", "Phone referral", "Free", "WA"].map((chip, index) => ( + + ))} + +
+
+ {matches.map((service, index) => ( + + ))} +
+
+ +
+
+ +
+
+
+
+ +
event.preventDefault()} + > + + + setQuery(event.target.value)} + placeholder="Search services..." + className="min-w-0 flex-1 bg-transparent text-sm font-semibold text-[#061740] outline-none placeholder:text-slate-400" + /> + {query ? ( + + ) : null} + + + +
+
+
+ ); +} diff --git a/src/components/tools-page-mockups/rectangle-direction-mockups.tsx b/src/components/tools-page-mockups/rectangle-direction-mockups.tsx new file mode 100644 index 0000000000..6e5afbdea6 --- /dev/null +++ b/src/components/tools-page-mockups/rectangle-direction-mockups.tsx @@ -0,0 +1,527 @@ +"use client"; + +import Link from "next/link"; +import { + ArrowRight, + BookOpen, + CheckCircle2, + ClipboardList, + Clock3, + FileText, + HeartPulse, + Pill, + Pin, + Search, + ShieldCheck, + Sparkles, + Star, + Stethoscope, + type LucideIcon, +} from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn } from "@/components/ui-primitives"; + +import { areaLabels, pinnedToolIds, toolById, tools, type ToolFixture } from "./tool-fixtures"; +import { useToolFilter, type ToolFilterId } from "./use-tool-filter"; + +const focusRing = + "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; + +function IconTile({ icon: Icon, active = false }: { icon: LucideIcon; active?: boolean }) { + return ( + + + ); +} + +function SearchBar({ + value, + onChange, + placeholder = "Search tools by clinical job, source, or workflow", +}: { + value: string; + onChange: (value: string) => void; + placeholder?: string; +}) { + return ( +
event.preventDefault()} + className="grid min-h-[3.25rem] w-full grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 rounded-full border border-[color:var(--border-strong)] bg-[color:var(--surface)] px-2 shadow-[var(--shadow-tight)]" + > +
-
-
- - {mode === "workflow" ? ( -
- {["Assess", "Reference", "Treat", "Coordinate"].map((label) => ( - - {label} - - ))} -
- ) : null} + {selectedTool && SelectedIcon ? ( +
+ -
- {featured.map((tool, index) => { - const Icon = tool.icon; - return ( +
+ +

+ {selectedTool.title} +

+

+ {selectedTool.description} +

+
+ + {selectedTool.sourceBacked ? : null} +
+
+
+
+ Best for +
+
+ {selectedTool.secondary} +
+
+
+
+ Last used +
+
+ {selectedTool.lastUsed} +
+
+
- - - - {tool.title} - - - {areaLabels[tool.area]} - - - - {tool.sourceBacked ? ( - - ) : null} - + Open {selectedTool.primaryAction.toLowerCase()} +
+
-
-
- - Recent work - - View +
-
- {recentWork.slice(0, 2).map((item) => { - const Icon = item.icon; - return ( - -
+ + ); + + return interactive ? ( + + ) : ( + + {rowContent} + + ); + })} +
+ +
+
+ + Recent work + + View +
+
+ {recentWork.slice(0, 2).map((item) => { + const Icon = item.icon; + return ( + +
+
+ + ) : null}
@@ -917,6 +1053,9 @@ const splitPaneFilters: { id: ToolFilterId; label: string; icon: LucideIcon }[] function SplitPaneMockup() { const filter = useToolFilter(tools); const suggestedId = "services"; + const [selectedToolId, setSelectedToolId] = useState(suggestedId); + const selectedTool = selectedToolId ? toolById(selectedToolId) : undefined; + const overviewToolIds = ["clinical-kb-search", suggestedId, "medication-prescribing", "favourites"]; return ( <> @@ -995,13 +1134,21 @@ function SplitPaneMockup() {

Launcher overview

- Filters sit beside the overview, while the full-width All tools view below carries the main browsing - weight. + Choose a tool to preview its purpose and recent context in the phone frame before opening it.

- {["clinical-kb-search", suggestedId, "medication-prescribing", "favourites"].map((id) => ( - - ))} + {overviewToolIds.map((id) => { + const tool = toolById(id); + return ( + setSelectedToolId(id)} + /> + ); + })}
)} @@ -1012,6 +1159,9 @@ function SplitPaneMockup() { title="Pocket directory" toolIds={["clinical-kb-search", "documents", "differentials", suggestedId, "forms", "favourites"]} mode="directory" + selectedTool={selectedTool} + onSelectTool={(tool) => setSelectedToolId(tool.id)} + onBackToDirectory={() => setSelectedToolId("")} /> diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index eeee5c89fa..c9630429df 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -236,6 +236,13 @@ test.describe("Clinical KB applications launcher", () => { expect(headingBox).not.toBeNull(); expect((searchBox?.y ?? 0) + (searchBox?.height ?? 0) / 2).toBeGreaterThan(820 * 0.72); expect((headingBox?.y ?? 0) + (headingBox?.height ?? 0)).toBeLessThan(searchBox?.y ?? 0); + if (home.path === "/forms") { + await expect(page.getByRole("button", { name: "Open source scope" })).toHaveCount(0); + await page.getByRole("button", { name: "Open the form library" }).click(); + await expect(page).toHaveURL(/\/forms$/); + await expect(page.getByRole("button", { name: "Mode Forms" })).toBeVisible(); + await expect(page.getByTestId("form-search-results")).toHaveCount(0); + } await expectNoPageHorizontalOverflow(page); } });