diff --git a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx index a11372c9f..e9fea8a11 100644 --- a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx +++ b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx @@ -73,6 +73,8 @@ type MedicationResult = { type MedicationRow = { result: MedicationResult; badges: ClinicalBadgeItem[]; + /** Per-medication (drug-class) identity accent hex, for a subtle icon tint. */ + accent?: string; }; type MedicationResultFilter = "best" | "indication" | "safety" | "monitoring"; @@ -298,8 +300,25 @@ function FilterStrip({ ); } -function ResultToneIcon({ result }: { result: MedicationResult }) { +function ResultToneIcon({ result, accent }: { result: MedicationResult; accent?: string }) { const tone = result.tone === "teal" ? "teal" : result.tone === "blue" ? "blue" : "slate"; + if (accent) { + // Decorative per-medication (class) accent tint; a soft wash + border keeps + // it legible in light and dark without carrying semantic meaning. + return ( + + ); + } return ; } @@ -382,14 +401,15 @@ function MedicationResults({ const governance = catalog.data?.governance; const toRow = (result: MedicationResult, medication?: MedicationRecord): MedicationRow => { const badges = medication ? medicationIdentityBadges(medication, governance?.[medication.slug]) : []; + const accent = medication?.accent; // Prepend a per-patient alert badge so the highest-severity consideration // surfaces first in the row's badge cluster (priority-sorted by tone). if (medication && !profileEmpty) { const alerts = evaluatePatientAlerts(medication, profile); const alertBadge = considerationSummaryBadge(alerts.considerations.length, alerts.highestTone); - if (alertBadge) return { result, badges: [alertBadge, ...badges] }; + if (alertBadge) return { result, badges: [alertBadge, ...badges], accent }; } - return { result, badges }; + return { result, badges, accent }; }; const sourceRows = catalog.data?.matches?.map((match) => toRow(match.result, match.medication)) ?? @@ -510,7 +530,7 @@ function MedicationResults({ const rowContent = ( <>
- +
@@ -590,7 +610,7 @@ function MedicationResults({ ); const cardContent = (
- +

diff --git a/src/components/clinical-dashboard/medication-record-page.tsx b/src/components/clinical-dashboard/medication-record-page.tsx index 7e2bf80ef..dc1aa5526 100644 --- a/src/components/clinical-dashboard/medication-record-page.tsx +++ b/src/components/clinical-dashboard/medication-record-page.tsx @@ -6,17 +6,20 @@ import { TriangleAlert, ArrowLeft, BadgeCheck, + BookOpen, CalendarDays, ChevronDown, ClipboardList, FlaskConical, + Gauge, Lock, Pill, ShieldCheck, + Sparkles, type LucideIcon, } from "lucide-react"; import Link from "next/link"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent } from "react"; import { BadgeCluster, clinicalBadgeToneClass } from "@/components/clinical-dashboard/clinical-badge"; import { MedicationConsiderations } from "@/components/clinical-dashboard/medication-considerations"; @@ -31,7 +34,7 @@ import { type MedicationGovernance, } from "@/lib/medication-badges"; import { medicationDetailTiles, type MedicationRecord, type MedicationSection } from "@/lib/medications"; -import { cn, pageContainer } from "@/components/ui-primitives"; +import { cn, EmptyState, LoadingPanel, pageContainer, toneDanger } from "@/components/ui-primitives"; const sectionIcons: Record = { dose: CalendarDays, @@ -43,35 +46,108 @@ const sectionIcons: Record = { src: BadgeCheck, }; +// Per-section toned icon tiles. Semantic tones stay reserved (red = contra/safety, +// amber = risk/caution, green = safe/verified); other sections use the neutral +// categorical --type-* hues so the list reads with colour without misusing meaning. +const sectionToneClass: Record = { + contra: "border-[color:var(--danger)]/25 bg-[color:var(--danger-soft)] text-[color:var(--danger)]", + risk: "border-[color:var(--warning)]/30 bg-[color:var(--warning-soft)] text-[color:var(--warning)]", + safe: "border-[color:var(--success)]/25 bg-[color:var(--success-soft)] text-[color:var(--success)]", + src: "border-[color:var(--success)]/25 bg-[color:var(--success-soft)] text-[color:var(--success)]", + mon: "border-[color:var(--info-border)] bg-[color:var(--info-soft)] text-[color:var(--info)]", + dose: "border-[color:var(--type-document-border)] bg-[color:var(--type-document-soft)] text-[color:var(--type-document)]", + inter: "border-[color:var(--type-source-border)] bg-[color:var(--type-source-soft)] text-[color:var(--type-source)]", +}; +const defaultSectionTone = + "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]"; + +// Decorative per-medication identity accent (record.accent is keyed to drug +// class). Exposed as CSS custom properties and softened with color-mix so it +// drives washes/rails only — never text — keeping contrast safe in light + dark +// and staying within the colour contract (semantic colour uses the tokens). +function medicationAccentStyle(accent: string | undefined): CSSProperties { + const base = accent?.trim() || "var(--clinical-accent)"; + return { + "--med-accent": base, + "--med-accent-soft": `color-mix(in srgb, ${base} 12%, var(--surface))`, + "--med-accent-border": `color-mix(in srgb, ${base} 34%, var(--surface))`, + } as CSSProperties; +} + +// Icon + categorical chip per detail tile (index-aligned with medicationDetailTiles: +// Prescribing answer / Dosing / Dose ceiling / Avoid). The danger tile is toned +// separately below. +const detailTileDecor: Array<{ icon: LucideIcon; chip: string }> = [ + { + icon: Sparkles, + chip: "border-[color:var(--type-source-border)] bg-[color:var(--type-source-soft)] text-[color:var(--type-source)]", + }, + { + icon: CalendarDays, + chip: "border-[color:var(--type-document-border)] bg-[color:var(--type-document-soft)] text-[color:var(--type-document)]", + }, + { + icon: Gauge, + chip: "border-[color:var(--type-service-border)] bg-[color:var(--type-service-soft)] text-[color:var(--type-service)]", + }, + { icon: Ban, chip: "border-[color:var(--danger)]/25 bg-[color:var(--danger-soft)] text-[color:var(--danger)]" }, +]; + function DetailTile({ label, value, meta, danger = false, + icon: Icon, + chip, }: { label: string; value: string; meta?: string; danger?: boolean; + icon: LucideIcon; + chip: string; }) { return (

-

{label}

+
+ + +

+ {label} +

+

{value}

{meta ? ( -

+

{meta}

) : null} @@ -79,18 +155,89 @@ function DetailTile({ ); } +const detailTabs = [ + ["summary", "Summary"], + ["dosing", "Dosing"], + ["safety", "Safety"], + ["more", "More"], +] as const; +type MedicationTabId = (typeof detailTabs)[number][0]; + +function SectionTabs({ active, onChange }: { active: MedicationTabId; onChange: (id: MedicationTabId) => void }) { + const tabRefs = useRef(new Map()); + + function handleKeyDown(event: ReactKeyboardEvent) { + const order = detailTabs.map((tab) => tab[0]); + const index = order.indexOf(active); + const next = + event.key === "ArrowRight" + ? order[(index + 1) % order.length] + : event.key === "ArrowLeft" + ? order[(index - 1 + order.length) % order.length] + : event.key === "Home" + ? order[0] + : event.key === "End" + ? order[order.length - 1] + : null; + if (!next) return; + event.preventDefault(); + if (next !== active) onChange(next); + tabRefs.current.get(next)?.focus(); + } + + return ( + + ); +} + function SectionCard({ section }: { section: MedicationSection }) { const Icon = sectionIcons[section.type] || ClipboardList; + const toneClass = sectionToneClass[section.type] || defaultSectionTone; return (
- - -