mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & Schedule
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Today's clinic schedule and what'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user