Files
temetro/frontend/components/chat/patient-cards.tsx
T
Khalid Abdi a4fc9a4fe9 Add command palette, footer clinic menu, patient Sheet, Analysis page, favicon
Several navigation/UX additions:

- Command palette (⌘K): components/command-palette.tsx wraps the app shell
  (mounted in app/(app)/layout.tsx) with a controlled COSS CommandDialog listing
  the nav pages; a "Quick nav" Kbd button in the sidebar footer also opens it.
  Installed @coss/kbd. Extracted the nav list into lib/nav.ts so the sidebar and
  palette share one source of truth.
- Clinic switcher moved from the sidebar body into the footer; its menu now
  opens a read-only "Clinic info" dialog and a "Create clinic" dialog instead of
  routing to /onboarding. Shared CreateClinicForm (components/clinic/) is reused
  by onboarding.
- Patients: clicking a row opens a right-side Sheet with the full record
  (components/patients/patient-detail-sheet.tsx) instead of navigating to the
  chat; PatientResult gained a vertical "column" layout for the Sheet.
- New Analysis page (app/(app)/analysis/) — a mock dashboard (revenue/profit,
  patient volume, appointments, operations) reusing Sparkline + Card + Badge.
- Logo: set metadata.icons so the new mark appears as the browser-tab favicon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 19:40:11 +03:00

638 lines
18 KiB
TypeScript

"use client";
import { Pencil } from "lucide-react";
import { type ReactNode, useState } from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogDescription,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
import { Sparkline } from "@/components/chat/sparkline";
import { cn } from "@/lib/utils";
import type { AllergySeverity, LabFlag, Patient, Trend } from "@/lib/patients";
type BadgeVariant = "default" | "secondary" | "destructive" | "outline";
type PatientResultProps = {
status: "loading" | "ready" | "not-found";
fileNumber: string;
patient?: Patient;
onPatientUpdated?: (patient: Patient) => void;
// "row" = horizontal scroll (chat); "column" = full-width vertical stack
// (the Patients detail Sheet).
layout?: "row" | "column";
};
const severityVariant: Record<AllergySeverity, BadgeVariant> = {
mild: "outline",
moderate: "secondary",
severe: "destructive",
};
const labFlagVariant: Record<LabFlag, BadgeVariant> = {
normal: "outline",
low: "secondary",
high: "secondary",
critical: "destructive",
};
const statusVariant: Record<Patient["status"], BadgeVariant> = {
active: "secondary",
inpatient: "destructive",
discharged: "outline",
};
const sexLabel: Record<Patient["sex"], string> = { F: "Female", M: "Male" };
// Fixed width so the cards sit in a horizontal scroll row instead of squashing,
// plus a subtle clickable affordance (they open a detail dialog).
const rowCard =
"w-80 shrink-0 cursor-pointer text-left outline-none transition hover:ring-foreground/20 focus-visible:ring-2 focus-visible:ring-ring";
// COSS Card has no `size` variant; recreate the old compact ("sm") density by
// tightening the inner section padding from p-6 → p-4 via data-slot selectors.
const compactCard =
"[&_[data-slot=card-header]]:p-4 [&_[data-slot=card-panel]]:px-4 [&_[data-slot=card-panel]]:pb-4";
function SectionLabel({ children }: { children: ReactNode }) {
return (
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
{children}
</p>
);
}
function Stat({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-foreground">{value}</span>
</div>
);
}
function Row({ label, value }: { label: ReactNode; value: ReactNode }) {
return (
<div className="flex items-baseline justify-between gap-3">
<span className="text-foreground">{label}</span>
<span className="shrink-0 text-muted-foreground">{value}</span>
</div>
);
}
function Empty({ children }: { children: ReactNode }) {
return <p className="text-muted-foreground">{children}</p>;
}
function TrendBlock({ trend }: { trend: Trend }) {
if (trend.points.length === 0) {
return <Empty>No trend data yet.</Empty>;
}
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-baseline justify-between gap-2">
<SectionLabel>{`${trend.label} · last ${trend.points.length}`}</SectionLabel>
<span className="text-foreground">
{trend.points.at(-1)}
<span className="text-muted-foreground"> {trend.unit}</span>
</span>
</div>
<Sparkline points={trend.points} unit={trend.unit} />
</div>
);
}
function TrendDetail({ trend }: { trend: Trend }) {
if (trend.points.length === 0) {
return <Empty>No trend data yet.</Empty>;
}
const min = Math.min(...trend.points);
const max = Math.max(...trend.points);
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<SectionLabel>{`${trend.label} · last ${trend.points.length} readings`}</SectionLabel>
<div className="flex gap-3 text-xs text-muted-foreground">
<span>
Latest{" "}
<span className="text-foreground">
{trend.points.at(-1)} {trend.unit}
</span>
</span>
<span>
Min <span className="text-foreground">{min}</span>
</span>
<span>
Max <span className="text-foreground">{max}</span>
</span>
</div>
</div>
<Sparkline className="h-24" points={trend.points} unit={trend.unit} />
</div>
);
}
function AlertBadges({ alerts }: { alerts: string[] }) {
if (alerts.length === 0) {
return null;
}
return (
<div className="flex flex-wrap gap-1.5">
{alerts.map((alert) => (
<Badge key={alert} variant="outline">
{alert}
</Badge>
))}
</div>
);
}
// A card that previews `children` and opens a roomier dialog of `detail` on click.
function ExpandableCard({
title,
description,
detail,
children,
}: {
title: ReactNode;
description?: ReactNode;
detail: ReactNode;
children: ReactNode;
}) {
return (
<Dialog>
<DialogTrigger
nativeButton={false}
render={<Card className={cn(rowCard, compactCard)} />}
>
{children}
</DialogTrigger>
<DialogPopup className="max-h-[80dvh] sm:max-w-lg">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description ? <DialogDescription>{description}</DialogDescription> : null}
</DialogHeader>
<DialogPanel className="min-h-0 flex-1 overflow-y-auto">
{detail}
</DialogPanel>
</DialogPopup>
</Dialog>
);
}
function SummaryCard({
patient,
onEdit,
}: {
patient: Patient;
onEdit?: () => void;
}) {
const idLine = `${patient.age} · ${sexLabel[patient.sex]} · MRN ${patient.fileNumber}`;
return (
<ExpandableCard
description={idLine}
detail={
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<Stat label="Full name" value={patient.name} />
<Stat label="MRN" value={patient.fileNumber} />
<Stat label="Age" value={patient.age} />
<Stat label="Sex" value={sexLabel[patient.sex]} />
<Stat label="Primary care" value={patient.pcp} />
<Stat
label="Status"
value={<span className="capitalize">{patient.status}</span>}
/>
<Stat label="Last seen" value={patient.encounters[0]?.date ?? "—"} />
<Stat
label="Allergies"
value={patient.allergies.length || "None"}
/>
</div>
<AlertBadges alerts={patient.alerts} />
</div>
}
title={patient.name}
>
<CardHeader>
<div className="flex items-center gap-3">
<Avatar className="size-10">
<AvatarFallback>{patient.initials}</AvatarFallback>
</Avatar>
<div className="grid flex-1 gap-0.5">
<CardTitle>{patient.name}</CardTitle>
<CardDescription>{idLine}</CardDescription>
</div>
<Badge className="capitalize" variant={statusVariant[patient.status]}>
{patient.status}
</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-x-3 gap-y-3">
<Stat label="Primary care" value={patient.pcp} />
<Stat label="Last seen" value={patient.encounters[0]?.date ?? "—"} />
<Stat label="Active meds" value={patient.medications.length} />
<Stat label="Open problems" value={patient.problems.length} />
</div>
<AlertBadges alerts={patient.alerts} />
<button
className="mt-auto flex items-center justify-center gap-1.5 rounded-2xl border border-border/60 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={(event) => {
event.stopPropagation();
onEdit?.();
}}
type="button"
>
<Pencil className="size-4" />
Edit record
</button>
</CardContent>
</ExpandableCard>
);
}
function VitalsCard({ patient }: { patient: Patient }) {
const { vitals } = patient;
const vitalItems = [
{ label: "BP", value: vitals.bp },
{ label: "HR", value: vitals.hr },
{ label: "Temp", value: vitals.temp },
{ label: "SpO₂", value: vitals.spo2 },
];
const vitalsGrid = (gapY: string) => (
<div className={cn("grid grid-cols-2 gap-x-4", gapY)}>
{vitalItems.map((item) => (
<Stat key={item.label} label={item.label} value={item.value} />
))}
</div>
);
return (
<ExpandableCard
description={`Taken ${vitals.takenAt}`}
detail={
<div className="flex flex-col gap-4">
{vitalsGrid("gap-y-3")}
<Separator />
<TrendDetail trend={patient.vitalsTrend} />
</div>
}
title="Vitals"
>
<CardHeader>
<CardTitle>Vitals</CardTitle>
<CardDescription>Taken {vitals.takenAt}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{vitalsGrid("gap-y-3")}
<Separator />
<TrendBlock trend={patient.vitalsTrend} />
</CardContent>
</ExpandableCard>
);
}
function labValue(value: string, flag: LabFlag) {
return (
<span className="flex items-center gap-2">
{value}
<Badge className="capitalize" variant={labFlagVariant[flag]}>
{flag}
</Badge>
</span>
);
}
function LabsCard({ patient }: { patient: Patient }) {
return (
<ExpandableCard
description={`As of ${patient.labs[0]?.takenAt ?? "—"}`}
detail={
patient.labs.length === 0 ? (
<Empty>No labs on file.</Empty>
) : (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2.5">
{patient.labs.map((lab) => (
<div
className="flex items-center justify-between gap-3"
key={lab.name}
>
<div className="flex flex-col">
<span className="text-foreground">{lab.name}</span>
<span className="text-xs text-muted-foreground">
{lab.takenAt}
</span>
</div>
{labValue(lab.value, lab.flag)}
</div>
))}
</div>
<Separator />
<TrendDetail trend={patient.labTrend} />
</div>
)
}
title="Labs"
>
<CardHeader>
<CardTitle>Labs</CardTitle>
<CardDescription>As of {patient.labs[0]?.takenAt ?? "—"}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{patient.labs.length === 0 ? (
<Empty>No labs on file.</Empty>
) : (
<>
<div className="flex flex-col gap-2">
{patient.labs.map((lab) => (
<Row
key={lab.name}
label={lab.name}
value={labValue(lab.value, lab.flag)}
/>
))}
</div>
<Separator />
<TrendBlock trend={patient.labTrend} />
</>
)}
</CardContent>
</ExpandableCard>
);
}
function MedicationsCard({ patient }: { patient: Patient }) {
const list =
patient.medications.length === 0 ? (
<Empty>No active medications.</Empty>
) : (
<div className="flex flex-col gap-2">
{patient.medications.map((med) => (
<Row
key={med.name}
label={med.name}
value={`${med.dose} · ${med.frequency}`}
/>
))}
</div>
);
return (
<ExpandableCard
description={`${patient.medications.length} active`}
detail={list}
title="Medications"
>
<CardHeader>
<CardTitle>Medications</CardTitle>
<CardDescription>{patient.medications.length} active</CardDescription>
</CardHeader>
<CardContent>{list}</CardContent>
</ExpandableCard>
);
}
function ProblemsCard({ patient }: { patient: Patient }) {
const list =
patient.problems.length === 0 ? (
<Empty>No active problems.</Empty>
) : (
<div className="flex flex-col gap-2">
{patient.problems.map((problem) => (
<Row
key={problem.label}
label={problem.label}
value={`since ${problem.since}`}
/>
))}
</div>
);
return (
<ExpandableCard
description={`${patient.problems.length} active`}
detail={list}
title="Problems"
>
<CardHeader>
<CardTitle>Problems</CardTitle>
<CardDescription>{patient.problems.length} active</CardDescription>
</CardHeader>
<CardContent>{list}</CardContent>
</ExpandableCard>
);
}
function AllergiesList({ patient }: { patient: Patient }) {
return (
<div className="flex flex-col gap-4">
<AlertBadges alerts={patient.alerts} />
<div className="flex flex-col gap-2">
<SectionLabel>Allergies</SectionLabel>
{patient.allergies.length === 0 ? (
<p className="text-muted-foreground">No known allergies.</p>
) : (
patient.allergies.map((allergy) => (
<Row
key={allergy.substance}
label={
<>
{allergy.substance}
<span className="text-muted-foreground">
{" "}
{allergy.reaction}
</span>
</>
}
value={
<Badge
className="capitalize"
variant={severityVariant[allergy.severity]}
>
{allergy.severity}
</Badge>
}
/>
))
)}
</div>
</div>
);
}
function AllergiesCard({ patient }: { patient: Patient }) {
return (
<ExpandableCard
detail={<AllergiesList patient={patient} />}
title="Allergies & alerts"
>
<CardHeader>
<CardTitle>Allergies & alerts</CardTitle>
</CardHeader>
<CardContent>
<AllergiesList patient={patient} />
</CardContent>
</ExpandableCard>
);
}
function VisitsList({ patient }: { patient: Patient }) {
if (patient.encounters.length === 0) {
return <Empty>No visits yet.</Empty>;
}
return (
<div className="flex flex-col gap-3">
{patient.encounters.map((encounter) => (
<div
className="flex flex-col gap-0.5"
key={encounter.date + encounter.type}
>
<div className="flex items-baseline justify-between gap-3">
<span className="text-foreground">{encounter.type}</span>
<span className="shrink-0 text-xs text-muted-foreground">
{encounter.date}
</span>
</div>
<span className="text-muted-foreground">{encounter.summary}</span>
<span className="text-xs text-muted-foreground">
{encounter.provider}
</span>
</div>
))}
</div>
);
}
function VisitsCard({ patient }: { patient: Patient }) {
return (
<ExpandableCard
description={`${patient.encounters.length} recent`}
detail={<VisitsList patient={patient} />}
title="Recent visits"
>
<CardHeader>
<CardTitle>Recent visits</CardTitle>
</CardHeader>
<CardContent>
<VisitsList patient={patient} />
</CardContent>
</ExpandableCard>
);
}
function LoadingCards() {
return (
<>
<Card className={cn("w-80 shrink-0", compactCard)}>
<CardHeader>
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-full" />
<div className="grid flex-1 gap-1.5">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-52" />
</div>
</div>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-3">
{[0, 1, 2, 3].map((cell) => (
<Skeleton className="h-8 w-full" key={cell} />
))}
</CardContent>
</Card>
{[0, 1, 2, 3, 4, 5].map((card) => (
<Card className={cn("w-80 shrink-0", compactCard)} key={card}>
<CardHeader>
<Skeleton className="h-4 w-28" />
</CardHeader>
<CardContent className="flex flex-col gap-2.5">
{[0, 1, 2].map((row) => (
<Skeleton className="h-3.5 w-full" key={row} />
))}
{card % 2 === 1 && <Skeleton className="mt-1.5 h-10 w-full" />}
</CardContent>
</Card>
))}
</>
);
}
export function PatientResult({
status,
fileNumber,
patient,
onPatientUpdated,
layout = "row",
}: PatientResultProps) {
const [editOpen, setEditOpen] = useState(false);
// Bumped on open so the editor remounts with the latest patient data.
const [editKey, setEditKey] = useState(0);
if (status === "not-found") {
return (
<Card className={compactCard}>
<CardContent>
<p className="text-muted-foreground">
No patient found for file #{fileNumber}.
</p>
</CardContent>
</Card>
);
}
return (
<div
className={cn(
"flex w-full gap-4 p-2",
layout === "column"
? "flex-col [&_[data-slot=card]]:w-full"
: "no-scrollbar items-stretch overflow-x-auto",
)}
>
{status === "loading" || !patient ? (
<LoadingCards />
) : (
<>
<SummaryCard
onEdit={() => {
setEditKey((k) => k + 1);
setEditOpen(true);
}}
patient={patient}
/>
<VitalsCard patient={patient} />
<LabsCard patient={patient} />
<MedicationsCard patient={patient} />
<ProblemsCard patient={patient} />
<AllergiesCard patient={patient} />
<VisitsCard patient={patient} />
<PatientFormDialog
key={editKey}
mode="edit"
onOpenChange={setEditOpen}
onSaved={(updated) => onPatientUpdated?.(updated)}
open={editOpen}
patient={patient}
/>
</>
)}
</div>
);
}