Refine sidebar, Notes, and Patients per review feedback

Round-2 fixes to the 6 features:

- Logo: enlarge the sidebar mark, drop the inline wordmark, and show
  "temetro" via a hover tooltip; bigger logo on the auth screens.
- Sidebar footer: wrap the quick-nav, clinic switcher, and user menu in a
  single bordered block so it reads as one footer instead of three cards.
- Sidebar nav: highlight the active page (usePathname + data-[active]), and
  add an Appointments & Schedule sub-page under Patients that auto-expands
  for the current section. New mock /appointments page; reachable via ⌘K.
- Notes: redesign to a two-pane list + editor with an Empty state on the
  right when nothing is selected; fix the editor so text starts top-left
  (div instead of a centering <button>); compact the toolbar; add
  .note-content typography in globals.css so headings/lists/etc. actually
  render (the app has no typography plugin).
- Patients: new PatientDetail laid out to fit the side Sheet (stacked
  full-width sections, no nested click-to-expand dialogs); keep Edit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-05 00:49:43 +03:00
parent ea2ecb572c
commit 9588d16869
14 changed files with 1031 additions and 148 deletions
+10
View File
@@ -0,0 +1,10 @@
import { AppointmentsView } from "@/components/appointments/appointments-view";
import { SidebarInset } from "@/components/ui/sidebar";
export default function AppointmentsPage() {
return (
<SidebarInset className="flex flex-1 flex-col overflow-y-auto">
<AppointmentsView />
</SidebarInset>
);
}
+97
View File
@@ -216,3 +216,100 @@
@apply font-sans;
}
}
/*
* Rich-text styling for the Notes Tiptap editor. The app has no typography
* plugin, and Tailwind's preflight resets headings/lists — so without this,
* H1/H2/lists render identically to body text. Scoped to `.note-content`
* (set on the editor's contenteditable) and themed via semantic tokens.
*/
.note-content {
@apply text-foreground;
font-size: 0.95rem;
line-height: 1.7;
}
.note-content:focus {
outline: none;
}
.note-content > :first-child {
margin-top: 0;
}
.note-content p {
margin: 0.5rem 0;
}
.note-content h1 {
@apply font-heading font-bold;
font-size: 1.6rem;
line-height: 1.25;
margin: 1.2rem 0 0.6rem;
}
.note-content h2 {
@apply font-heading font-semibold;
font-size: 1.3rem;
line-height: 1.3;
margin: 1rem 0 0.5rem;
}
.note-content h3 {
@apply font-heading font-semibold;
font-size: 1.1rem;
margin: 0.9rem 0 0.4rem;
}
.note-content ul,
.note-content ol {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.note-content ul {
list-style: disc;
}
.note-content ol {
list-style: decimal;
}
.note-content li {
margin: 0.2rem 0;
}
.note-content li > p {
margin: 0;
}
.note-content blockquote {
@apply border-border text-muted-foreground;
border-left-width: 3px;
margin: 0.75rem 0;
padding-left: 1rem;
}
.note-content a {
@apply text-primary underline underline-offset-2;
}
.note-content strong {
font-weight: 600;
}
.note-content code {
@apply bg-muted font-mono;
border-radius: 0.375rem;
font-size: 0.85em;
padding: 0.1rem 0.35rem;
}
.note-content pre {
@apply bg-muted font-mono;
border-radius: 0.6rem;
margin: 0.75rem 0;
overflow-x: auto;
padding: 0.75rem 1rem;
}
.note-content pre code {
background: transparent;
padding: 0;
}
.note-content hr {
@apply border-border;
border-top-width: 1px;
margin: 1.25rem 0;
}
/* Tiptap placeholder (empty document) */
.note-content p.is-editor-empty:first-child::before {
@apply text-muted-foreground;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
@@ -0,0 +1,230 @@
"use client";
import { CalendarClock, Clock, Stethoscope, Users } from "lucide-react";
import type { ReactNode } from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
// All figures here are mock/placeholder data — there is no scheduling backend.
// They illustrate the Appointments & Schedule layout.
type ApptStatus = "confirmed" | "checked-in" | "completed" | "cancelled";
type Appointment = {
time: string;
name: string;
initials: string;
type: string;
provider: string;
status: ApptStatus;
};
const statusVariant: Record<
ApptStatus,
"default" | "secondary" | "destructive" | "outline"
> = {
"checked-in": "default",
confirmed: "secondary",
completed: "outline",
cancelled: "destructive",
};
const statusLabel: Record<ApptStatus, string> = {
"checked-in": "Checked in",
confirmed: "Confirmed",
completed: "Completed",
cancelled: "Cancelled",
};
const kpis = [
{ label: "Today", value: "12", icon: CalendarClock },
{ label: "This week", value: "146", icon: Users },
{ label: "Avg. visit", value: "22 min", icon: Clock },
{ label: "Utilization", value: "87%", icon: Stethoscope },
];
const today: Appointment[] = [
{
time: "09:00",
name: "Amina Yusuf",
initials: "AY",
type: "Follow-up",
provider: "Dr. Okafor",
status: "completed",
},
{
time: "09:30",
name: "Daniel Mensah",
initials: "DM",
type: "New patient",
provider: "Dr. Okafor",
status: "checked-in",
},
{
time: "10:15",
name: "Leila Haddad",
initials: "LH",
type: "Lab review",
provider: "Dr. Stein",
status: "confirmed",
},
{
time: "11:00",
name: "Carlos Rivera",
initials: "CR",
type: "Consultation",
provider: "Dr. Okafor",
status: "confirmed",
},
{
time: "13:30",
name: "Priya Nair",
initials: "PN",
type: "Follow-up",
provider: "Dr. Stein",
status: "cancelled",
},
{
time: "14:45",
name: "Tom Becker",
initials: "TB",
type: "Vaccination",
provider: "Dr. Okafor",
status: "confirmed",
},
];
const upcoming: { day: string; items: Appointment[] }[] = [
{
day: "Tomorrow",
items: [
{
time: "08:45",
name: "Grace Lin",
initials: "GL",
type: "Follow-up",
provider: "Dr. Stein",
status: "confirmed",
},
{
time: "10:00",
name: "Omar Farouk",
initials: "OF",
type: "New patient",
provider: "Dr. Okafor",
status: "confirmed",
},
],
},
];
function Kpi({
label,
value,
icon: Icon,
}: {
label: string;
value: string;
icon: typeof CalendarClock;
}) {
return (
<Card className="flex-row items-center gap-3 p-4">
<div className="flex size-9 items-center justify-center rounded-lg border bg-background text-muted-foreground">
<Icon className="size-4" />
</div>
<div className="flex flex-col">
<span className="text-muted-foreground text-xs">{label}</span>
<span className="font-semibold text-foreground text-lg tracking-tight">
{value}
</span>
</div>
</Card>
);
}
function ApptRow({ appt }: { appt: Appointment }) {
return (
<div className="flex items-center gap-3 px-4 py-3">
<span className="w-12 shrink-0 font-medium text-foreground text-sm tabular-nums">
{appt.time}
</span>
<Avatar className="size-8">
<AvatarFallback>{appt.initials}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-medium text-foreground text-sm">
{appt.name}
</span>
<span className="truncate text-muted-foreground text-xs">
{appt.type} · {appt.provider}
</span>
</div>
<Badge variant={statusVariant[appt.status]}>{statusLabel[appt.status]}</Badge>
</div>
);
}
function ScheduleList({ items }: { items: Appointment[] }) {
return (
<div className="divide-y divide-border overflow-hidden rounded-2xl border bg-card/30">
{items.map((appt) => (
<ApptRow appt={appt} key={appt.time + appt.name} />
))}
</div>
);
}
function Section({
title,
description,
children,
}: {
title: string;
description?: string;
children: ReactNode;
}) {
return (
<section className="flex flex-col gap-3">
<div>
<h2 className="font-semibold text-lg tracking-tight">{title}</h2>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
{children}
</section>
);
}
export function AppointmentsView() {
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-10 px-6 py-10">
<div>
<h1 className="font-semibold text-2xl tracking-tight">
Appointments &amp; Schedule
</h1>
<p className="text-muted-foreground text-sm">
Today&apos;s clinic schedule and what&apos;s coming up. Sample data.
</p>
</div>
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
{kpis.map((k) => (
<Kpi key={k.label} {...k} />
))}
</div>
<Section description="Wednesday, June 5" title="Today">
<ScheduleList items={today} />
</Section>
{upcoming.map((group) => (
<Section key={group.day} title={group.day}>
<ScheduleList items={group.items} />
</Section>
))}
</div>
);
}
+5 -5
View File
@@ -13,16 +13,16 @@ import { cn } from "@/lib/utils";
// temetro logo + wordmark, centered.
export function AuthBrand() {
return (
<div className="flex items-center justify-center gap-2.5">
<div className="flex items-center justify-center gap-3">
<Image
alt="temetro"
className="size-8"
height={32}
className="size-11"
height={44}
priority
src="/temetro-logo.png"
width={32}
width={44}
/>
<span className="text-lg font-semibold tracking-tight">temetro</span>
<span className="text-2xl font-semibold tracking-tight">temetro</span>
</div>
);
}
+14 -2
View File
@@ -75,12 +75,24 @@ export function CommandPaletteProvider({ children }: { children: ReactNode }) {
{
value: "pages",
label: t("nav.commandGroup"),
items: navItems.map((item) => ({
// Flatten sub-pages so e.g. "Appointments & Schedule" is reachable.
items: navItems.flatMap((item) =>
item.subs?.length
? item.subs.map((sub) => ({
id: sub.id,
label: t(sub.labelKey),
link: sub.link,
Icon: sub.icon ?? item.icon,
}))
: [
{
id: item.id,
label: t(item.labelKey),
link: item.link,
Icon: item.icon,
})),
},
],
),
},
],
[t],
+7 -10
View File
@@ -91,8 +91,9 @@ export function NotesEditor({
immediatelyRender: false,
editorProps: {
attributes: {
class:
"prose prose-sm dark:prose-invert max-w-none min-h-full focus:outline-none",
// `note-content` styles headings/lists/etc. (see globals.css); padding +
// min-h-full make the whole card a click target, text anchored top-left.
class: "note-content min-h-full p-4 focus:outline-none",
},
},
onTransaction: bump,
@@ -132,7 +133,7 @@ export function NotesEditor({
</Button>
</div>
<Toolbar className="flex-wrap">
<Toolbar className="w-fit max-w-full self-start overflow-x-auto">
<ToolbarGroup>
<FormatButton
active={editor.isActive("bold")}
@@ -213,13 +214,9 @@ export function NotesEditor({
</ToolbarGroup>
</Toolbar>
<button
className="min-h-0 flex-1 cursor-text overflow-y-auto rounded-xl border bg-card p-4 text-left"
onClick={() => editor.chain().focus().run()}
type="button"
>
<EditorContent editor={editor} />
</button>
<div className="min-h-0 flex-1 cursor-text overflow-y-auto rounded-xl border bg-card">
<EditorContent className="h-full" editor={editor} />
</div>
</div>
);
}
+48 -18
View File
@@ -1,10 +1,18 @@
"use client";
import { FileText, Plus } from "lucide-react";
import { NotebookPen, Plus } from "lucide-react";
import { useEffect, useState } from "react";
import { NotesEditor } from "@/components/notes/notes-editor";
import { Button } from "@/components/ui/button";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import {
createNote,
deleteNote,
@@ -25,6 +33,7 @@ const newDraft = (): Note => ({
export function NotesView() {
const [notes, setNotes] = useState<Note[]>([]);
// No auto-selection: with nothing chosen the right pane shows the Empty state.
const [selected, setSelected] = useState<Note | null>(null);
const [draftKey, setDraftKey] = useState(0);
const [loading, setLoading] = useState(true);
@@ -34,9 +43,7 @@ export function NotesView() {
let active = true;
listNotes()
.then((data) => {
if (!active) return;
setNotes(data);
setSelected((current) => current ?? data[0] ?? null);
if (active) setNotes(data);
})
.catch((err) => {
if (active) {
@@ -84,7 +91,7 @@ export function NotesView() {
await deleteNote(id);
const list = await listNotes();
setNotes(list);
setSelected(list[0] ?? null);
setSelected(null);
notify.success("Note deleted");
} catch (err) {
notify.error(
@@ -95,16 +102,22 @@ export function NotesView() {
};
return (
<div className="mx-auto flex h-full w-full max-w-5xl gap-6 px-6 py-8">
<aside className="flex w-60 shrink-0 flex-col gap-3">
<div className="flex items-center justify-between">
<h1 className="font-semibold text-lg tracking-tight">Notes</h1>
<Button onClick={startNew} size="sm" type="button">
<div className="flex h-full w-full">
{/* Left: note list */}
<aside className="flex w-72 shrink-0 flex-col border-border border-r">
<div className="flex items-center justify-between gap-2 border-border border-b px-4 py-3">
<h1 className="font-semibold text-base tracking-tight">Notes</h1>
<Button
aria-label="New note"
onClick={startNew}
size="icon-sm"
type="button"
variant="secondary"
>
<Plus className="size-4" />
New
</Button>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto">
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-2">
{loading ? (
<p className="px-2 py-1.5 text-muted-foreground text-sm">Loading</p>
) : notes.length === 0 ? (
@@ -115,7 +128,7 @@ export function NotesView() {
notes.map((n) => (
<button
className={cn(
"flex w-full flex-col items-start gap-0.5 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-accent",
"flex w-full flex-col items-start gap-0.5 rounded-lg px-2 py-2 text-left transition-colors hover:bg-accent",
selected?.id === n.id && "bg-accent",
)}
key={n.id}
@@ -134,8 +147,10 @@ export function NotesView() {
</div>
</aside>
<div className="min-w-0 flex-1">
{/* Right: editor or empty state */}
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{selected ? (
<div className="flex h-full flex-col p-6">
<NotesEditor
key={selected.id || `draft-${draftKey}`}
note={selected}
@@ -143,11 +158,26 @@ export function NotesView() {
onSave={save}
saving={saving}
/>
) : (
<div className="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground">
<FileText className="size-8" />
<p className="text-sm">Select a note or create a new one.</p>
</div>
) : (
<Empty className="flex-1">
<EmptyHeader>
<EmptyMedia variant="icon">
<NotebookPen />
</EmptyMedia>
<EmptyTitle>No note selected</EmptyTitle>
<EmptyDescription>
Select a note from the list, or create a new one to start
writing.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button onClick={startNew} type="button">
<Plus className="size-4" />
New note
</Button>
</EmptyContent>
</Empty>
)}
</div>
</div>
@@ -2,7 +2,8 @@
import { useEffect, useState } from "react";
import { PatientResult } from "@/components/chat/patient-cards";
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
import { PatientDetail } from "@/components/patients/patient-detail";
import {
Sheet,
SheetHeader,
@@ -10,13 +11,38 @@ import {
SheetPopup,
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { getPatient, type Patient } from "@/lib/patients";
type Status = "loading" | "ready" | "not-found";
// Right-side Sheet showing a patient's full record. Reuses the chat's
// PatientResult cards in their vertical (column) layout. Opened from the
// Patients table instead of routing into the AI chat.
function DetailSkeleton() {
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<Skeleton className="size-12 rounded-full" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-52" />
</div>
</div>
{[0, 1, 2, 3].map((section) => (
<div className="rounded-2xl border bg-card/30 p-4" key={section}>
<Skeleton className="mb-3 h-4 w-24" />
<div className="flex flex-col gap-2.5">
{[0, 1, 2].map((row) => (
<Skeleton className="h-3.5 w-full" key={row} />
))}
</div>
</div>
))}
</div>
);
}
// Right-side Sheet showing a patient's full record, laid out to fit the sheet
// width (see PatientDetail). Opened from the Patients table instead of routing
// into the AI chat.
export function PatientDetailSheet({
fileNumber,
open,
@@ -28,6 +54,9 @@ export function PatientDetailSheet({
}) {
const [patient, setPatient] = useState<Patient | null>(null);
const [status, setStatus] = useState<Status>("loading");
const [editOpen, setEditOpen] = useState(false);
// Bumped on open so the editor remounts with the latest patient data.
const [editKey, setEditKey] = useState(0);
useEffect(() => {
if (!open || !fileNumber) return;
@@ -56,23 +85,42 @@ export function PatientDetailSheet({
: "Loading patient…";
return (
<>
<Sheet onOpenChange={onOpenChange} open={open}>
<SheetPopup className="sm:max-w-xl" side="right">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<SheetPanel className="min-h-0 flex-1">
{fileNumber && (
<PatientResult
fileNumber={fileNumber}
layout="column"
onPatientUpdated={setPatient}
patient={patient ?? undefined}
status={status}
{status === "loading" && <DetailSkeleton />}
{status === "not-found" && (
<p className="text-muted-foreground text-sm">
No patient found for file #{fileNumber}.
</p>
)}
{status === "ready" && patient && (
<PatientDetail
onEdit={() => {
setEditKey((k) => k + 1);
setEditOpen(true);
}}
patient={patient}
/>
)}
</SheetPanel>
</SheetPopup>
</Sheet>
{patient && (
<PatientFormDialog
key={editKey}
mode="edit"
onOpenChange={setEditOpen}
onSaved={(updated) => setPatient(updated)}
open={editOpen}
patient={patient}
/>
)}
</>
);
}
@@ -0,0 +1,267 @@
"use client";
import { Pencil } from "lucide-react";
import type { ReactNode } from "react";
import { Sparkline } from "@/components/chat/sparkline";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { AllergySeverity, LabFlag, Patient, Trend } from "@/lib/patients";
type BadgeVariant = "default" | "secondary" | "destructive" | "outline";
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" };
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="rounded-2xl border bg-card/30 p-4">
<h3 className="mb-3 font-medium text-foreground text-sm">{title}</h3>
{children}
</section>
);
}
function Stat({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-muted-foreground text-xs">{label}</span>
<span className="text-foreground text-sm">{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 text-sm">{label}</span>
<span className="shrink-0 text-muted-foreground text-sm">{value}</span>
</div>
);
}
function TrendBlock({ trend }: { trend: Trend }) {
if (trend.points.length === 0) return null;
return (
<div className="mt-3 flex flex-col gap-1.5">
<div className="flex items-baseline justify-between gap-2">
<span className="text-muted-foreground text-xs uppercase tracking-wide">
{trend.label}
</span>
<span className="text-foreground text-sm">
{trend.points.at(-1)}
<span className="text-muted-foreground"> {trend.unit}</span>
</span>
</div>
<div className="text-primary">
<Sparkline className="h-12" points={trend.points} unit={trend.unit} />
</div>
</div>
);
}
// Full patient record laid out vertically for the side Sheet — plain full-width
// sections (no fixed-width cards, no nested click-to-expand dialogs).
export function PatientDetail({
patient,
onEdit,
}: {
patient: Patient;
onEdit?: () => void;
}) {
const idLine = `${patient.age} · ${sexLabel[patient.sex]} · MRN ${patient.fileNumber}`;
return (
<div className="flex flex-col gap-4">
<div className="flex items-start gap-3">
<Avatar className="size-12">
<AvatarFallback>{patient.initials}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-semibold text-base text-foreground">
{patient.name}
</span>
<Badge className="capitalize" variant={statusVariant[patient.status]}>
{patient.status}
</Badge>
</div>
<span className="text-muted-foreground text-sm">{idLine}</span>
{patient.alerts.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1.5">
{patient.alerts.map((alert) => (
<Badge key={alert} variant="outline">
{alert}
</Badge>
))}
</div>
)}
</div>
{onEdit && (
<Button onClick={onEdit} size="sm" type="button" variant="outline">
<Pencil className="size-4" />
Edit
</Button>
)}
</div>
<Section title="Overview">
<div className="grid grid-cols-2 gap-x-4 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>
</Section>
<Section title="Vitals">
<div className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-4">
<Stat label="BP" value={patient.vitals.bp} />
<Stat label="HR" value={patient.vitals.hr} />
<Stat label="Temp" value={patient.vitals.temp} />
<Stat label="SpO₂" value={patient.vitals.spo2} />
</div>
<p className="mt-2 text-muted-foreground text-xs">
Taken {patient.vitals.takenAt}
</p>
<TrendBlock trend={patient.vitalsTrend} />
</Section>
<Section title="Labs">
{patient.labs.length === 0 ? (
<p className="text-muted-foreground text-sm">No labs on file.</p>
) : (
<div className="flex flex-col gap-2">
{patient.labs.map((lab) => (
<Row
key={lab.name}
label={lab.name}
value={
<span className="flex items-center gap-2">
{lab.value}
<Badge
className="capitalize"
variant={labFlagVariant[lab.flag]}
>
{lab.flag}
</Badge>
</span>
}
/>
))}
</div>
)}
<TrendBlock trend={patient.labTrend} />
</Section>
<Section title="Medications">
{patient.medications.length === 0 ? (
<p className="text-muted-foreground text-sm">No active medications.</p>
) : (
<div className="flex flex-col gap-2">
{patient.medications.map((med) => (
<Row
key={med.name}
label={med.name}
value={`${med.dose} · ${med.frequency}`}
/>
))}
</div>
)}
</Section>
<Section title="Problems">
{patient.problems.length === 0 ? (
<p className="text-muted-foreground text-sm">No active problems.</p>
) : (
<div className="flex flex-col gap-2">
{patient.problems.map((problem) => (
<Row
key={problem.label}
label={problem.label}
value={`since ${problem.since}`}
/>
))}
</div>
)}
</Section>
<Section title="Allergies & alerts">
{patient.allergies.length === 0 ? (
<p className="text-muted-foreground text-sm">No known allergies.</p>
) : (
<div className="flex flex-col gap-2">
{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>
)}
</Section>
<Section title="Recent visits">
{patient.encounters.length === 0 ? (
<p className="text-muted-foreground text-sm">No visits yet.</p>
) : (
<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 text-sm">
{encounter.type}
</span>
<span className="shrink-0 text-muted-foreground text-xs">
{encounter.date}
</span>
</div>
<span className="text-muted-foreground text-sm">
{encounter.summary}
</span>
<span className="text-muted-foreground text-xs">
{encounter.provider}
</span>
</div>
))}
</div>
)}
</Section>
</div>
);
}
+41 -11
View File
@@ -5,9 +5,15 @@ import {
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarSeparator,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar";
import {
Tooltip,
TooltipPopup,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { navItems } from "@/lib/nav";
import { motion } from "framer-motion";
@@ -54,6 +60,11 @@ export function DashboardSidebar() {
title: t(item.labelKey),
icon: <item.icon className="size-4" />,
link: item.link,
subs: item.subs?.map((sub) => ({
title: t(sub.labelKey),
link: sub.link,
icon: sub.icon ? <sub.icon className="size-4" /> : undefined,
})),
}));
return (
@@ -66,21 +77,27 @@ export function DashboardSidebar() {
: "flex-row items-center justify-between"
)}
>
<a href="#" className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger
render={
<a
aria-label="temetro"
className="flex items-center justify-center"
href="#"
>
<Image
src="/temetro-logo.png"
alt="temetro"
width={32}
height={32}
className="size-8 shrink-0"
className="size-9 shrink-0"
height={36}
priority
src="/temetro-logo.png"
width={36}
/>
{!isCollapsed && (
<span className="font-semibold text-black dark:text-white">
temetro
</span>
)}
</a>
}
/>
<TooltipPopup side="right">temetro</TooltipPopup>
</Tooltip>
<motion.div
key={isCollapsed ? "header-collapsed" : "header-expanded"}
@@ -99,10 +116,23 @@ export function DashboardSidebar() {
<SidebarContent className="gap-4 px-2 py-4">
<DashboardNavigation routes={dashboardRoutes} />
</SidebarContent>
<SidebarFooter className="gap-2 px-2">
<SidebarFooter className="p-2">
<div
className={cn(
"flex flex-col gap-1 rounded-xl border border-sidebar-border bg-sidebar-accent/30 p-1",
isCollapsed && "border-0 bg-transparent p-0"
)}
>
<SidebarCommandButton />
<SidebarSeparator
className={cn("mx-0 my-0.5", isCollapsed && "hidden")}
/>
<OrgSwitcher />
<SidebarSeparator
className={cn("mx-0 my-0.5", isCollapsed && "hidden")}
/>
<NavUser />
</div>
</SidebarFooter>
</Sidebar>
);
+55 -53
View File
@@ -1,22 +1,19 @@
"use client";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuItem as SidebarMenuSubItem,
SidebarMenuSubItem,
useSidebar,
} from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
import { ChevronDown, ChevronUp } from "lucide-react";
import { ChevronDown } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type React from "react";
import { useState } from "react";
@@ -32,71 +29,76 @@ export type Route = {
}[];
};
// True when `link` matches the current path. "/" only matches exactly; any other
// link matches itself and its nested routes (so a parent stays lit on subpages).
function useIsActive() {
const pathname = usePathname();
return (link: string) =>
link === "/" ? pathname === "/" : pathname === link || pathname.startsWith(`${link}/`);
}
export default function DashboardNavigation({ routes }: { routes: Route[] }) {
const { state } = useSidebar();
const isCollapsed = state === "collapsed";
const [openCollapsible, setOpenCollapsible] = useState<string | null>(null);
const isActive = useIsActive();
// Manual expand/collapse override per parent; falls back to "open when active".
const [overrides, setOverrides] = useState<Record<string, boolean>>({});
return (
<SidebarMenu>
{routes.map((route) => {
const isOpen = !isCollapsed && openCollapsible === route.id;
const hasSubRoutes = !!route.subs?.length;
const hasSubs = !!route.subs?.length;
const sectionActive =
isActive(route.link) || !!route.subs?.some((s) => isActive(s.link));
const isOpen = !isCollapsed && (overrides[route.id] ?? sectionActive);
return (
<SidebarMenuItem key={route.id}>
{hasSubRoutes ? (
<Collapsible
open={isOpen}
onOpenChange={(open) =>
setOpenCollapsible(open ? route.id : null)
}
className="w-full"
<SidebarMenuButton
className="text-muted-foreground"
isActive={sectionActive}
render={<Link href={route.link} prefetch={true} />}
tooltip={route.title}
>
<CollapsibleTrigger render={<SidebarMenuButton className={cn(
"flex w-full items-center rounded-lg px-2 transition-colors",
isOpen
? "bg-sidebar-muted text-foreground"
: "text-muted-foreground hover:bg-sidebar-muted hover:text-foreground",
isCollapsed && "justify-center"
)} />}>{route.icon}{!isCollapsed && (
<span className="ml-2 flex-1 text-sm font-medium">
{route.icon}
{!isCollapsed && (
<span className="ml-2 flex-1 truncate font-medium text-sm">
{route.title}
</span>
)}{!isCollapsed && hasSubRoutes && (
<span className="ml-auto">
{isOpen ? (
<ChevronUp className="size-4" />
) : (
<ChevronDown className="size-4" />
)}
</span>
)}</CollapsibleTrigger>
</SidebarMenuButton>
{!isCollapsed && (
<CollapsibleContent>
<SidebarMenuSub className="my-1 ml-3.5 ">
{route.subs?.map((subRoute) => (
<SidebarMenuSubItem
key={`${route.id}-${subRoute.title}`}
className="h-auto"
{hasSubs && !isCollapsed && (
<SidebarMenuAction
aria-label={isOpen ? "Collapse" : "Expand"}
onClick={() =>
setOverrides((prev) => ({ ...prev, [route.id]: !isOpen }))
}
>
<SidebarMenuSubButton render={<Link href={subRoute.link} prefetch={true} className="flex items-center rounded-md px-4 py-1.5 text-sm font-medium text-muted-foreground hover:bg-sidebar-muted hover:text-foreground" />}>{subRoute.title}</SidebarMenuSubButton>
<ChevronDown
className={cn(
"transition-transform duration-200",
isOpen && "rotate-180"
)}
/>
</SidebarMenuAction>
)}
{hasSubs && isOpen && (
<SidebarMenuSub>
{route.subs?.map((subRoute) => (
<SidebarMenuSubItem key={`${route.id}-${subRoute.link}`}>
<SidebarMenuSubButton
className="text-muted-foreground"
isActive={isActive(subRoute.link)}
render={<Link href={subRoute.link} prefetch={true} />}
>
{subRoute.icon}
<span className="truncate">{subRoute.title}</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
)}
</Collapsible>
) : (
<SidebarMenuButton tooltip={route.title} render={<Link href={route.link} prefetch={true} className={cn(
"flex items-center rounded-lg px-2 transition-colors text-muted-foreground hover:bg-sidebar-muted hover:text-foreground",
isCollapsed && "justify-center"
)} />}>{route.icon}{!isCollapsed && (
<span className="ml-2 text-sm font-medium">
{route.title}
</span>
)}</SidebarMenuButton>
)}
</SidebarMenuItem>
);
+134
View File
@@ -0,0 +1,134 @@
import { cva, type VariantProps } from "class-variance-authority";
import type React from "react";
import { cn } from "@/lib/utils";
const emptyMediaVariants = cva(
"flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
defaultVariants: {
variant: "default",
},
variants: {
variant: {
default: "bg-transparent",
icon: "relative flex size-9 shrink-0 items-center justify-center rounded-md border bg-card not-dark:bg-clip-padding text-foreground shadow-sm/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-md)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)] [&_svg:not([class*='size-'])]:size-4.5",
},
},
},
);
export function Empty({
className,
...props
}: React.ComponentProps<"div">): React.ReactElement {
return (
<div
className={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 text-balance px-6 py-12 text-center md:py-20",
className,
)}
data-slot="empty"
{...props}
/>
);
}
export function EmptyHeader({
className,
...props
}: React.ComponentProps<"div">): React.ReactElement {
return (
<div
className={cn(
"flex max-w-sm flex-col items-center text-center",
className,
)}
data-slot="empty-header"
{...props}
/>
);
}
export function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof emptyMediaVariants>): React.ReactElement {
return (
<div
className={cn("relative mb-6", className)}
data-slot="empty-media"
data-variant={variant}
{...props}
>
{variant === "icon" && (
<>
<div
aria-hidden="true"
className={cn(
emptyMediaVariants({ className, variant }),
"pointer-events-none absolute bottom-px origin-bottom-left -translate-x-0.5 -rotate-10 scale-84 shadow-none",
)}
/>
<div
aria-hidden="true"
className={cn(
emptyMediaVariants({ className, variant }),
"pointer-events-none absolute bottom-px origin-bottom-right translate-x-0.5 rotate-10 scale-84 shadow-none",
)}
/>
</>
)}
<div
className={cn(emptyMediaVariants({ className, variant }))}
{...props}
/>
</div>
);
}
export function EmptyTitle({
className,
...props
}: React.ComponentProps<"div">): React.ReactElement {
return (
<div
className={cn("font-heading font-semibold text-xl", className)}
data-slot="empty-title"
{...props}
/>
);
}
export function EmptyDescription({
className,
...props
}: React.ComponentProps<"p">): React.ReactElement {
return (
<div
className={cn(
"text-muted-foreground text-sm [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4 [[data-slot=empty-title]+&]:mt-1",
className,
)}
data-slot="empty-description"
{...props}
/>
);
}
export function EmptyContent({
className,
...props
}: React.ComponentProps<"div">): React.ReactElement {
return (
<div
className={cn(
"flex w-full min-w-0 max-w-sm flex-col items-center gap-4 text-balance text-sm",
className,
)}
data-slot="empty-content"
{...props}
/>
);
}
@@ -47,6 +47,7 @@
"nav": {
"newChat": "New chat",
"patients": "Patients",
"appointments": "Appointments & Schedule",
"analysis": "Analysis",
"notes": "Notes",
"settings": "Settings",
+26 -1
View File
@@ -1,5 +1,6 @@
import {
BarChart3,
CalendarClock,
type LucideIcon,
NotebookPen,
Plus,
@@ -7,12 +8,22 @@ import {
Users,
} from "lucide-react";
export type NavSubItem = {
id: string;
// i18n key resolved with t() at render time.
labelKey: string;
icon?: LucideIcon;
link: string;
};
export type NavItem = {
id: string;
// i18n key resolved with t() at render time.
labelKey: string;
icon: LucideIcon;
link: string;
// Optional sub-pages revealed under this item in the sidebar.
subs?: NavSubItem[];
};
// Single source of truth for the primary navigation. Consumed by the sidebar
@@ -20,7 +31,21 @@ export type NavItem = {
// (components/command-palette.tsx) so the two never drift.
export const navItems: NavItem[] = [
{ id: "new-chat", labelKey: "nav.newChat", icon: Plus, link: "/" },
{ id: "patients", labelKey: "nav.patients", icon: Users, link: "/patients" },
{
id: "patients",
labelKey: "nav.patients",
icon: Users,
link: "/patients",
subs: [
{ id: "patients-list", labelKey: "nav.patients", link: "/patients" },
{
id: "appointments",
labelKey: "nav.appointments",
icon: CalendarClock,
link: "/appointments",
},
],
},
{
id: "analysis",
labelKey: "nav.analysis",