diff --git a/frontend/app/(app)/appointments/page.tsx b/frontend/app/(app)/appointments/page.tsx new file mode 100644 index 0000000..695e757 --- /dev/null +++ b/frontend/app/(app)/appointments/page.tsx @@ -0,0 +1,10 @@ +import { AppointmentsView } from "@/components/appointments/appointments-view"; +import { SidebarInset } from "@/components/ui/sidebar"; + +export default function AppointmentsPage() { + return ( + + + + ); +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 5f741e4..82ec521 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -215,4 +215,101 @@ html { @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; } \ No newline at end of file diff --git a/frontend/components/appointments/appointments-view.tsx b/frontend/components/appointments/appointments-view.tsx new file mode 100644 index 0000000..8c6cea3 --- /dev/null +++ b/frontend/components/appointments/appointments-view.tsx @@ -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 = { + "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 ( + +
+ +
+
+ {label} + + {value} + +
+
+ ); +} + +function ApptRow({ appt }: { appt: Appointment }) { + return ( +
+ + {appt.time} + + + {appt.initials} + +
+ + {appt.name} + + + {appt.type} · {appt.provider} + +
+ {statusLabel[appt.status]} +
+ ); +} + +function ScheduleList({ items }: { items: Appointment[] }) { + return ( +
+ {items.map((appt) => ( + + ))} +
+ ); +} + +function Section({ + title, + description, + children, +}: { + title: string; + description?: string; + children: ReactNode; +}) { + return ( +
+
+

{title}

+ {description && ( +

{description}

+ )} +
+ {children} +
+ ); +} + +export function AppointmentsView() { + return ( +
+
+

+ Appointments & Schedule +

+

+ Today's clinic schedule and what's coming up. Sample data. +

+
+ +
+ {kpis.map((k) => ( + + ))} +
+ +
+ +
+ + {upcoming.map((group) => ( +
+ +
+ ))} +
+ ); +} diff --git a/frontend/components/auth/auth-ui.tsx b/frontend/components/auth/auth-ui.tsx index 722b9e7..34ea1bb 100644 --- a/frontend/components/auth/auth-ui.tsx +++ b/frontend/components/auth/auth-ui.tsx @@ -13,16 +13,16 @@ import { cn } from "@/lib/utils"; // temetro logo + wordmark, centered. export function AuthBrand() { return ( -
+
temetro - temetro + temetro
); } diff --git a/frontend/components/command-palette.tsx b/frontend/components/command-palette.tsx index c67009a..1e954c9 100644 --- a/frontend/components/command-palette.tsx +++ b/frontend/components/command-palette.tsx @@ -75,12 +75,24 @@ export function CommandPaletteProvider({ children }: { children: ReactNode }) { { value: "pages", label: t("nav.commandGroup"), - items: navItems.map((item) => ({ - id: item.id, - label: t(item.labelKey), - link: item.link, - Icon: item.icon, - })), + // 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], diff --git a/frontend/components/notes/notes-editor.tsx b/frontend/components/notes/notes-editor.tsx index 84fb001..32fc674 100644 --- a/frontend/components/notes/notes-editor.tsx +++ b/frontend/components/notes/notes-editor.tsx @@ -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({
- + - +
+ +
); } diff --git a/frontend/components/notes/notes-view.tsx b/frontend/components/notes/notes-view.tsx index c4ed30b..546ba60 100644 --- a/frontend/components/notes/notes-view.tsx +++ b/frontend/components/notes/notes-view.tsx @@ -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([]); + // No auto-selection: with nothing chosen the right pane shows the Empty state. const [selected, setSelected] = useState(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 ( -
- -
+ {/* Right: editor or empty state */} +
{selected ? ( - remove(selected.id) : undefined} - onSave={save} - saving={saving} - /> - ) : ( -
- -

Select a note or create a new one.

+
+ remove(selected.id) : undefined} + onSave={save} + saving={saving} + />
+ ) : ( + + + + + + No note selected + + Select a note from the list, or create a new one to start + writing. + + + + + + )}
diff --git a/frontend/components/patients/patient-detail-sheet.tsx b/frontend/components/patients/patient-detail-sheet.tsx index 69a95d2..13298bc 100644 --- a/frontend/components/patients/patient-detail-sheet.tsx +++ b/frontend/components/patients/patient-detail-sheet.tsx @@ -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 ( +
+
+ +
+ + +
+
+ {[0, 1, 2, 3].map((section) => ( +
+ +
+ {[0, 1, 2].map((row) => ( + + ))} +
+
+ ))} +
+ ); +} + +// 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(null); const [status, setStatus] = useState("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 ( - - - - {title} - - - {fileNumber && ( - - )} - - - + <> + + + + {title} + + + {status === "loading" && } + {status === "not-found" && ( +

+ No patient found for file #{fileNumber}. +

+ )} + {status === "ready" && patient && ( + { + setEditKey((k) => k + 1); + setEditOpen(true); + }} + patient={patient} + /> + )} +
+
+
+ + {patient && ( + setPatient(updated)} + open={editOpen} + patient={patient} + /> + )} + ); } diff --git a/frontend/components/patients/patient-detail.tsx b/frontend/components/patients/patient-detail.tsx new file mode 100644 index 0000000..be8e4e6 --- /dev/null +++ b/frontend/components/patients/patient-detail.tsx @@ -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 = { + mild: "outline", + moderate: "secondary", + severe: "destructive", +}; +const labFlagVariant: Record = { + normal: "outline", + low: "secondary", + high: "secondary", + critical: "destructive", +}; +const statusVariant: Record = { + active: "secondary", + inpatient: "destructive", + discharged: "outline", +}; +const sexLabel: Record = { F: "Female", M: "Male" }; + +function Section({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function Stat({ label, value }: { label: string; value: ReactNode }) { + return ( +
+ {label} + {value} +
+ ); +} + +function Row({ label, value }: { label: ReactNode; value: ReactNode }) { + return ( +
+ {label} + {value} +
+ ); +} + +function TrendBlock({ trend }: { trend: Trend }) { + if (trend.points.length === 0) return null; + return ( +
+
+ + {trend.label} + + + {trend.points.at(-1)} + {trend.unit} + +
+
+ +
+
+ ); +} + +// 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 ( +
+
+ + {patient.initials} + +
+
+ + {patient.name} + + + {patient.status} + +
+ {idLine} + {patient.alerts.length > 0 && ( +
+ {patient.alerts.map((alert) => ( + + {alert} + + ))} +
+ )} +
+ {onEdit && ( + + )} +
+ +
+
+ + + + +
+
+ +
+
+ + + + +
+

+ Taken {patient.vitals.takenAt} +

+ +
+ +
+ {patient.labs.length === 0 ? ( +

No labs on file.

+ ) : ( +
+ {patient.labs.map((lab) => ( + + {lab.value} + + {lab.flag} + + + } + /> + ))} +
+ )} + +
+ +
+ {patient.medications.length === 0 ? ( +

No active medications.

+ ) : ( +
+ {patient.medications.map((med) => ( + + ))} +
+ )} +
+ +
+ {patient.problems.length === 0 ? ( +

No active problems.

+ ) : ( +
+ {patient.problems.map((problem) => ( + + ))} +
+ )} +
+ +
+ {patient.allergies.length === 0 ? ( +

No known allergies.

+ ) : ( +
+ {patient.allergies.map((allergy) => ( + + {allergy.substance} + + {" "} + — {allergy.reaction} + + + } + value={ + + {allergy.severity} + + } + /> + ))} +
+ )} +
+ +
+ {patient.encounters.length === 0 ? ( +

No visits yet.

+ ) : ( +
+ {patient.encounters.map((encounter) => ( +
+
+ + {encounter.type} + + + {encounter.date} + +
+ + {encounter.summary} + + + {encounter.provider} + +
+ ))} +
+ )} +
+
+ ); +} diff --git a/frontend/components/sidebar-02/app-sidebar.tsx b/frontend/components/sidebar-02/app-sidebar.tsx index 153427b..d7f567d 100644 --- a/frontend/components/sidebar-02/app-sidebar.tsx +++ b/frontend/components/sidebar-02/app-sidebar.tsx @@ -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: , link: item.link, + subs: item.subs?.map((sub) => ({ + title: t(sub.labelKey), + link: sub.link, + icon: sub.icon ? : undefined, + })), })); return ( @@ -66,21 +77,27 @@ export function DashboardSidebar() { : "flex-row items-center justify-between" )} > - - temetro + + temetro + + } /> - {!isCollapsed && ( - - temetro - - )} - + temetro + - - - - + +
+ + + + + +
); diff --git a/frontend/components/sidebar-02/nav-main.tsx b/frontend/components/sidebar-02/nav-main.tsx index d9b7fe6..2cee71a 100644 --- a/frontend/components/sidebar-02/nav-main.tsx +++ b/frontend/components/sidebar-02/nav-main.tsx @@ -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(null); + const isActive = useIsActive(); + // Manual expand/collapse override per parent; falls back to "open when active". + const [overrides, setOverrides] = useState>({}); return ( {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 ( - {hasSubRoutes ? ( - - setOpenCollapsible(open ? route.id : null) - } - className="w-full" - > - }>{route.icon}{!isCollapsed && ( - - {route.title} - - )}{!isCollapsed && hasSubRoutes && ( - - {isOpen ? ( - - ) : ( - - )} - - )} + } + tooltip={route.title} + > + {route.icon} + {!isCollapsed && ( + + {route.title} + + )} + - {!isCollapsed && ( - - - {route.subs?.map((subRoute) => ( - - }>{subRoute.title} - - ))} - - - )} - - ) : ( - }>{route.icon}{!isCollapsed && ( - - {route.title} - - )} + {hasSubs && !isCollapsed && ( + + setOverrides((prev) => ({ ...prev, [route.id]: !isOpen })) + } + > + + + )} + + {hasSubs && isOpen && ( + + {route.subs?.map((subRoute) => ( + + } + > + {subRoute.icon} + {subRoute.title} + + + ))} + )} ); diff --git a/frontend/components/ui/empty.tsx b/frontend/components/ui/empty.tsx new file mode 100644 index 0000000..48eb441 --- /dev/null +++ b/frontend/components/ui/empty.tsx @@ -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 ( +
+ ); +} + +export function EmptyHeader({ + className, + ...props +}: React.ComponentProps<"div">): React.ReactElement { + return ( +
+ ); +} + +export function EmptyMedia({ + className, + variant = "default", + ...props +}: React.ComponentProps<"div"> & + VariantProps): React.ReactElement { + return ( +
+ {variant === "icon" && ( + <> +