From 9588d168696bf7b505c5458292f4b6b0ed39081b Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Fri, 5 Jun 2026 00:49:43 +0300 Subject: [PATCH] Refine sidebar, Notes, and Patients per review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 - + - +
+ +
); } 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" && ( + <> +