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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -215,4 +215,101 @@
|
|||||||
html {
|
html {
|
||||||
@apply font-sans;
|
@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.
|
// temetro logo + wordmark, centered.
|
||||||
export function AuthBrand() {
|
export function AuthBrand() {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center gap-2.5">
|
<div className="flex items-center justify-center gap-3">
|
||||||
<Image
|
<Image
|
||||||
alt="temetro"
|
alt="temetro"
|
||||||
className="size-8"
|
className="size-11"
|
||||||
height={32}
|
height={44}
|
||||||
priority
|
priority
|
||||||
src="/temetro-logo.png"
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,12 +75,24 @@ export function CommandPaletteProvider({ children }: { children: ReactNode }) {
|
|||||||
{
|
{
|
||||||
value: "pages",
|
value: "pages",
|
||||||
label: t("nav.commandGroup"),
|
label: t("nav.commandGroup"),
|
||||||
items: navItems.map((item) => ({
|
// Flatten sub-pages so e.g. "Appointments & Schedule" is reachable.
|
||||||
id: item.id,
|
items: navItems.flatMap((item) =>
|
||||||
label: t(item.labelKey),
|
item.subs?.length
|
||||||
link: item.link,
|
? item.subs.map((sub) => ({
|
||||||
Icon: item.icon,
|
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],
|
[t],
|
||||||
|
|||||||
@@ -91,8 +91,9 @@ export function NotesEditor({
|
|||||||
immediatelyRender: false,
|
immediatelyRender: false,
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
class:
|
// `note-content` styles headings/lists/etc. (see globals.css); padding +
|
||||||
"prose prose-sm dark:prose-invert max-w-none min-h-full focus:outline-none",
|
// 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,
|
onTransaction: bump,
|
||||||
@@ -132,7 +133,7 @@ export function NotesEditor({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Toolbar className="flex-wrap">
|
<Toolbar className="w-fit max-w-full self-start overflow-x-auto">
|
||||||
<ToolbarGroup>
|
<ToolbarGroup>
|
||||||
<FormatButton
|
<FormatButton
|
||||||
active={editor.isActive("bold")}
|
active={editor.isActive("bold")}
|
||||||
@@ -213,13 +214,9 @@ export function NotesEditor({
|
|||||||
</ToolbarGroup>
|
</ToolbarGroup>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
|
|
||||||
<button
|
<div className="min-h-0 flex-1 cursor-text overflow-y-auto rounded-xl border bg-card">
|
||||||
className="min-h-0 flex-1 cursor-text overflow-y-auto rounded-xl border bg-card p-4 text-left"
|
<EditorContent className="h-full" editor={editor} />
|
||||||
onClick={() => editor.chain().focus().run()}
|
</div>
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<EditorContent editor={editor} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FileText, Plus } from "lucide-react";
|
import { NotebookPen, Plus } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { NotesEditor } from "@/components/notes/notes-editor";
|
import { NotesEditor } from "@/components/notes/notes-editor";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Empty,
|
||||||
|
EmptyContent,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyMedia,
|
||||||
|
EmptyTitle,
|
||||||
|
} from "@/components/ui/empty";
|
||||||
import {
|
import {
|
||||||
createNote,
|
createNote,
|
||||||
deleteNote,
|
deleteNote,
|
||||||
@@ -25,6 +33,7 @@ const newDraft = (): Note => ({
|
|||||||
|
|
||||||
export function NotesView() {
|
export function NotesView() {
|
||||||
const [notes, setNotes] = useState<Note[]>([]);
|
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 [selected, setSelected] = useState<Note | null>(null);
|
||||||
const [draftKey, setDraftKey] = useState(0);
|
const [draftKey, setDraftKey] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -34,9 +43,7 @@ export function NotesView() {
|
|||||||
let active = true;
|
let active = true;
|
||||||
listNotes()
|
listNotes()
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!active) return;
|
if (active) setNotes(data);
|
||||||
setNotes(data);
|
|
||||||
setSelected((current) => current ?? data[0] ?? null);
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (active) {
|
if (active) {
|
||||||
@@ -84,7 +91,7 @@ export function NotesView() {
|
|||||||
await deleteNote(id);
|
await deleteNote(id);
|
||||||
const list = await listNotes();
|
const list = await listNotes();
|
||||||
setNotes(list);
|
setNotes(list);
|
||||||
setSelected(list[0] ?? null);
|
setSelected(null);
|
||||||
notify.success("Note deleted");
|
notify.success("Note deleted");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify.error(
|
notify.error(
|
||||||
@@ -95,16 +102,22 @@ export function NotesView() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex h-full w-full max-w-5xl gap-6 px-6 py-8">
|
<div className="flex h-full w-full">
|
||||||
<aside className="flex w-60 shrink-0 flex-col gap-3">
|
{/* Left: note list */}
|
||||||
<div className="flex items-center justify-between">
|
<aside className="flex w-72 shrink-0 flex-col border-border border-r">
|
||||||
<h1 className="font-semibold text-lg tracking-tight">Notes</h1>
|
<div className="flex items-center justify-between gap-2 border-border border-b px-4 py-3">
|
||||||
<Button onClick={startNew} size="sm" type="button">
|
<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" />
|
<Plus className="size-4" />
|
||||||
New
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 ? (
|
{loading ? (
|
||||||
<p className="px-2 py-1.5 text-muted-foreground text-sm">Loading…</p>
|
<p className="px-2 py-1.5 text-muted-foreground text-sm">Loading…</p>
|
||||||
) : notes.length === 0 ? (
|
) : notes.length === 0 ? (
|
||||||
@@ -115,7 +128,7 @@ export function NotesView() {
|
|||||||
notes.map((n) => (
|
notes.map((n) => (
|
||||||
<button
|
<button
|
||||||
className={cn(
|
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",
|
selected?.id === n.id && "bg-accent",
|
||||||
)}
|
)}
|
||||||
key={n.id}
|
key={n.id}
|
||||||
@@ -134,20 +147,37 @@ export function NotesView() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</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 ? (
|
{selected ? (
|
||||||
<NotesEditor
|
<div className="flex h-full flex-col p-6">
|
||||||
key={selected.id || `draft-${draftKey}`}
|
<NotesEditor
|
||||||
note={selected}
|
key={selected.id || `draft-${draftKey}`}
|
||||||
onDelete={selected.id ? () => remove(selected.id) : undefined}
|
note={selected}
|
||||||
onSave={save}
|
onDelete={selected.id ? () => remove(selected.id) : undefined}
|
||||||
saving={saving}
|
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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
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 {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetHeader,
|
SheetHeader,
|
||||||
@@ -10,13 +11,38 @@ import {
|
|||||||
SheetPopup,
|
SheetPopup,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { getPatient, type Patient } from "@/lib/patients";
|
import { getPatient, type Patient } from "@/lib/patients";
|
||||||
|
|
||||||
type Status = "loading" | "ready" | "not-found";
|
type Status = "loading" | "ready" | "not-found";
|
||||||
|
|
||||||
// Right-side Sheet showing a patient's full record. Reuses the chat's
|
function DetailSkeleton() {
|
||||||
// PatientResult cards in their vertical (column) layout. Opened from the
|
return (
|
||||||
// Patients table instead of routing into the AI chat.
|
<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({
|
export function PatientDetailSheet({
|
||||||
fileNumber,
|
fileNumber,
|
||||||
open,
|
open,
|
||||||
@@ -28,6 +54,9 @@ export function PatientDetailSheet({
|
|||||||
}) {
|
}) {
|
||||||
const [patient, setPatient] = useState<Patient | null>(null);
|
const [patient, setPatient] = useState<Patient | null>(null);
|
||||||
const [status, setStatus] = useState<Status>("loading");
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open || !fileNumber) return;
|
if (!open || !fileNumber) return;
|
||||||
@@ -56,23 +85,42 @@ export function PatientDetailSheet({
|
|||||||
: "Loading patient…";
|
: "Loading patient…";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet onOpenChange={onOpenChange} open={open}>
|
<>
|
||||||
<SheetPopup className="sm:max-w-xl" side="right">
|
<Sheet onOpenChange={onOpenChange} open={open}>
|
||||||
<SheetHeader>
|
<SheetPopup className="sm:max-w-xl" side="right">
|
||||||
<SheetTitle>{title}</SheetTitle>
|
<SheetHeader>
|
||||||
</SheetHeader>
|
<SheetTitle>{title}</SheetTitle>
|
||||||
<SheetPanel className="min-h-0 flex-1">
|
</SheetHeader>
|
||||||
{fileNumber && (
|
<SheetPanel className="min-h-0 flex-1">
|
||||||
<PatientResult
|
{status === "loading" && <DetailSkeleton />}
|
||||||
fileNumber={fileNumber}
|
{status === "not-found" && (
|
||||||
layout="column"
|
<p className="text-muted-foreground text-sm">
|
||||||
onPatientUpdated={setPatient}
|
No patient found for file #{fileNumber}.
|
||||||
patient={patient ?? undefined}
|
</p>
|
||||||
status={status}
|
)}
|
||||||
/>
|
{status === "ready" && patient && (
|
||||||
)}
|
<PatientDetail
|
||||||
</SheetPanel>
|
onEdit={() => {
|
||||||
</SheetPopup>
|
setEditKey((k) => k + 1);
|
||||||
</Sheet>
|
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,
|
SidebarContent,
|
||||||
SidebarFooter,
|
SidebarFooter,
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
|
SidebarSeparator,
|
||||||
SidebarTrigger,
|
SidebarTrigger,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipPopup,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { navItems } from "@/lib/nav";
|
import { navItems } from "@/lib/nav";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
@@ -54,6 +60,11 @@ export function DashboardSidebar() {
|
|||||||
title: t(item.labelKey),
|
title: t(item.labelKey),
|
||||||
icon: <item.icon className="size-4" />,
|
icon: <item.icon className="size-4" />,
|
||||||
link: item.link,
|
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 (
|
return (
|
||||||
@@ -66,21 +77,27 @@ export function DashboardSidebar() {
|
|||||||
: "flex-row items-center justify-between"
|
: "flex-row items-center justify-between"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<a href="#" className="flex items-center gap-2">
|
<Tooltip>
|
||||||
<Image
|
<TooltipTrigger
|
||||||
src="/temetro-logo.png"
|
render={
|
||||||
alt="temetro"
|
<a
|
||||||
width={32}
|
aria-label="temetro"
|
||||||
height={32}
|
className="flex items-center justify-center"
|
||||||
className="size-8 shrink-0"
|
href="#"
|
||||||
priority
|
>
|
||||||
|
<Image
|
||||||
|
alt="temetro"
|
||||||
|
className="size-9 shrink-0"
|
||||||
|
height={36}
|
||||||
|
priority
|
||||||
|
src="/temetro-logo.png"
|
||||||
|
width={36}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{!isCollapsed && (
|
<TooltipPopup side="right">temetro</TooltipPopup>
|
||||||
<span className="font-semibold text-black dark:text-white">
|
</Tooltip>
|
||||||
temetro
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
key={isCollapsed ? "header-collapsed" : "header-expanded"}
|
key={isCollapsed ? "header-collapsed" : "header-expanded"}
|
||||||
@@ -99,10 +116,23 @@ export function DashboardSidebar() {
|
|||||||
<SidebarContent className="gap-4 px-2 py-4">
|
<SidebarContent className="gap-4 px-2 py-4">
|
||||||
<DashboardNavigation routes={dashboardRoutes} />
|
<DashboardNavigation routes={dashboardRoutes} />
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter className="gap-2 px-2">
|
<SidebarFooter className="p-2">
|
||||||
<SidebarCommandButton />
|
<div
|
||||||
<OrgSwitcher />
|
className={cn(
|
||||||
<NavUser />
|
"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>
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,22 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from "@/components/ui/collapsible";
|
|
||||||
import {
|
import {
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
|
SidebarMenuAction,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
SidebarMenuSub,
|
SidebarMenuSub,
|
||||||
SidebarMenuSubButton,
|
SidebarMenuSubButton,
|
||||||
SidebarMenuItem as SidebarMenuSubItem,
|
SidebarMenuSubItem,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
import { ChevronDown } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useState } 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[] }) {
|
export default function DashboardNavigation({ routes }: { routes: Route[] }) {
|
||||||
const { state } = useSidebar();
|
const { state } = useSidebar();
|
||||||
const isCollapsed = state === "collapsed";
|
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 (
|
return (
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{routes.map((route) => {
|
{routes.map((route) => {
|
||||||
const isOpen = !isCollapsed && openCollapsible === route.id;
|
const hasSubs = !!route.subs?.length;
|
||||||
const hasSubRoutes = !!route.subs?.length;
|
const sectionActive =
|
||||||
|
isActive(route.link) || !!route.subs?.some((s) => isActive(s.link));
|
||||||
|
const isOpen = !isCollapsed && (overrides[route.id] ?? sectionActive);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem key={route.id}>
|
<SidebarMenuItem key={route.id}>
|
||||||
{hasSubRoutes ? (
|
<SidebarMenuButton
|
||||||
<Collapsible
|
className="text-muted-foreground"
|
||||||
open={isOpen}
|
isActive={sectionActive}
|
||||||
onOpenChange={(open) =>
|
render={<Link href={route.link} prefetch={true} />}
|
||||||
setOpenCollapsible(open ? route.id : null)
|
tooltip={route.title}
|
||||||
}
|
>
|
||||||
className="w-full"
|
{route.icon}
|
||||||
>
|
{!isCollapsed && (
|
||||||
<CollapsibleTrigger render={<SidebarMenuButton className={cn(
|
<span className="ml-2 flex-1 truncate font-medium text-sm">
|
||||||
"flex w-full items-center rounded-lg px-2 transition-colors",
|
{route.title}
|
||||||
isOpen
|
</span>
|
||||||
? "bg-sidebar-muted text-foreground"
|
)}
|
||||||
: "text-muted-foreground hover:bg-sidebar-muted hover:text-foreground",
|
</SidebarMenuButton>
|
||||||
isCollapsed && "justify-center"
|
|
||||||
)} />}>{route.icon}{!isCollapsed && (
|
|
||||||
<span className="ml-2 flex-1 text-sm font-medium">
|
|
||||||
{route.title}
|
|
||||||
</span>
|
|
||||||
)}{!isCollapsed && hasSubRoutes && (
|
|
||||||
<span className="ml-auto">
|
|
||||||
{isOpen ? (
|
|
||||||
<ChevronUp className="size-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="size-4" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}</CollapsibleTrigger>
|
|
||||||
|
|
||||||
{!isCollapsed && (
|
{hasSubs && !isCollapsed && (
|
||||||
<CollapsibleContent>
|
<SidebarMenuAction
|
||||||
<SidebarMenuSub className="my-1 ml-3.5 ">
|
aria-label={isOpen ? "Collapse" : "Expand"}
|
||||||
{route.subs?.map((subRoute) => (
|
onClick={() =>
|
||||||
<SidebarMenuSubItem
|
setOverrides((prev) => ({ ...prev, [route.id]: !isOpen }))
|
||||||
key={`${route.id}-${subRoute.title}`}
|
}
|
||||||
className="h-auto"
|
>
|
||||||
>
|
<ChevronDown
|
||||||
<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>
|
className={cn(
|
||||||
</SidebarMenuSubItem>
|
"transition-transform duration-200",
|
||||||
))}
|
isOpen && "rotate-180"
|
||||||
</SidebarMenuSub>
|
)}
|
||||||
</CollapsibleContent>
|
/>
|
||||||
)}
|
</SidebarMenuAction>
|
||||||
</Collapsible>
|
)}
|
||||||
) : (
|
|
||||||
<SidebarMenuButton tooltip={route.title} render={<Link href={route.link} prefetch={true} className={cn(
|
{hasSubs && isOpen && (
|
||||||
"flex items-center rounded-lg px-2 transition-colors text-muted-foreground hover:bg-sidebar-muted hover:text-foreground",
|
<SidebarMenuSub>
|
||||||
isCollapsed && "justify-center"
|
{route.subs?.map((subRoute) => (
|
||||||
)} />}>{route.icon}{!isCollapsed && (
|
<SidebarMenuSubItem key={`${route.id}-${subRoute.link}`}>
|
||||||
<span className="ml-2 text-sm font-medium">
|
<SidebarMenuSubButton
|
||||||
{route.title}
|
className="text-muted-foreground"
|
||||||
</span>
|
isActive={isActive(subRoute.link)}
|
||||||
)}</SidebarMenuButton>
|
render={<Link href={subRoute.link} prefetch={true} />}
|
||||||
|
>
|
||||||
|
{subRoute.icon}
|
||||||
|
<span className="truncate">{subRoute.title}</span>
|
||||||
|
</SidebarMenuSubButton>
|
||||||
|
</SidebarMenuSubItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenuSub>
|
||||||
)}
|
)}
|
||||||
</SidebarMenuItem>
|
</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": {
|
"nav": {
|
||||||
"newChat": "New chat",
|
"newChat": "New chat",
|
||||||
"patients": "Patients",
|
"patients": "Patients",
|
||||||
|
"appointments": "Appointments & Schedule",
|
||||||
"analysis": "Analysis",
|
"analysis": "Analysis",
|
||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
|
|||||||
+26
-1
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
CalendarClock,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
NotebookPen,
|
NotebookPen,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -7,12 +8,22 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} 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 = {
|
export type NavItem = {
|
||||||
id: string;
|
id: string;
|
||||||
// i18n key resolved with t() at render time.
|
// i18n key resolved with t() at render time.
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
link: string;
|
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
|
// 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.
|
// (components/command-palette.tsx) so the two never drift.
|
||||||
export const navItems: NavItem[] = [
|
export const navItems: NavItem[] = [
|
||||||
{ id: "new-chat", labelKey: "nav.newChat", icon: Plus, link: "/" },
|
{ 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",
|
id: "analysis",
|
||||||
labelKey: "nav.analysis",
|
labelKey: "nav.analysis",
|
||||||
|
|||||||
Reference in New Issue
Block a user