Polish footer menu, Notes spacing, and Appointments add flow

- User menu: rename "Quick nav" to "Search"; add spacing to the clinic
  submenu so it no longer touches the parent menu (sideOffset).
- Notes: lay the list, title, toolbar, and writing area out as separate
  rounded panels with gaps so they're no longer stuck together.
- Sidebar: shorten the "Appointments & Schedule" label to "Appointments".
- Appointments: replace the oversized patient form with a compact
  "New appointment" dialog — pick a patient via quick search by name/file
  number, set time/type/provider; the entry is added to today's schedule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-05 01:28:32 +03:00
parent 346954db3f
commit 67137c722d
6 changed files with 304 additions and 46 deletions
@@ -0,0 +1,252 @@
"use client";
import { Search } from "lucide-react";
import { type FormEvent, type ReactNode, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { listPatients, type Patient } from "@/lib/patients";
import { notify } from "@/lib/toast";
export type NewAppointment = {
fileNumber: string;
name: string;
initials: string;
time: string;
type: string;
provider: string;
};
const TYPES = [
"Follow-up",
"New patient",
"Consultation",
"Lab review",
"Vaccination",
];
const controlClass =
"h-9 w-full rounded-3xl border border-transparent bg-input/50 px-3 text-sm text-foreground outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30";
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs">{label}</span>
{children}
</label>
);
}
// Compact "New appointment" dialog. The patient is chosen via a quick search by
// name or file number; the rest is the slot. Appointments are mock-only, so a
// confirmed entry is handed back to the page via onAdd.
export function AddAppointmentDialog({
open,
onOpenChange,
onAdd,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onAdd: (appt: NewAppointment) => void;
}) {
const [patients, setPatients] = useState<Patient[]>([]);
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<Patient | null>(null);
const [time, setTime] = useState("09:00");
const [type, setType] = useState(TYPES[0]);
const [provider, setProvider] = useState("");
// Load patients lazily when the dialog opens (for the quick search).
useEffect(() => {
if (!open) return;
let active = true;
listPatients()
.then((data) => {
if (active) setPatients(data);
})
.catch(() => {
/* search just stays empty */
});
return () => {
active = false;
};
}, [open]);
const matches = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return [];
return patients
.filter(
(p) =>
p.name.toLowerCase().includes(q) || p.fileNumber.includes(q),
)
.slice(0, 6);
}, [patients, query]);
const reset = () => {
setQuery("");
setSelected(null);
setTime("09:00");
setType(TYPES[0]);
setProvider("");
};
const submit = (event: FormEvent) => {
event.preventDefault();
if (!selected) {
notify.error("Pick a patient", "Search and select a patient first.");
return;
}
onAdd({
fileNumber: selected.fileNumber,
name: selected.name,
initials: selected.initials,
time,
type,
provider: provider.trim() || selected.pcp,
});
notify.success("Appointment added", `${selected.name} at ${time}`);
reset();
onOpenChange(false);
};
return (
<Dialog
onOpenChange={(o) => {
onOpenChange(o);
if (!o) reset();
}}
open={open}
>
<DialogPopup className="sm:max-w-md">
<DialogHeader>
<DialogTitle>New appointment</DialogTitle>
<DialogDescription>
Search for a patient by name or file number, then set the slot.
</DialogDescription>
</DialogHeader>
<form className="contents" onSubmit={submit}>
<DialogPanel className="flex flex-col gap-4">
<Field label="Patient">
{selected ? (
<div className="flex items-center justify-between gap-2 rounded-2xl border bg-input/30 px-3 py-2">
<div className="flex min-w-0 flex-col">
<span className="truncate font-medium text-foreground text-sm">
{selected.name}
</span>
<span className="text-muted-foreground text-xs">
File #{selected.fileNumber}
</span>
</div>
<Button
onClick={() => {
setSelected(null);
setQuery("");
}}
size="sm"
type="button"
variant="ghost"
>
Change
</Button>
</div>
) : (
<div className="flex flex-col gap-1.5">
<div className="relative">
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
<Input
autoFocus
className="pl-9"
onChange={(event) => setQuery(event.target.value)}
placeholder="Search name or file number"
value={query}
/>
</div>
{query.trim() && (
<div className="max-h-56 overflow-y-auto rounded-2xl border bg-popover p-1">
{matches.length === 0 ? (
<p className="px-2 py-2 text-muted-foreground text-sm">
No patients found.
</p>
) : (
matches.map((p) => (
<button
className="flex w-full items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-accent"
key={p.fileNumber}
onClick={() => {
setSelected(p);
setQuery("");
}}
type="button"
>
<span className="truncate text-foreground text-sm">
{p.name}
</span>
<span className="shrink-0 text-muted-foreground text-xs">
#{p.fileNumber}
</span>
</button>
))
)}
</div>
)}
</div>
)}
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Time">
<Input
onChange={(event) => setTime(event.target.value)}
type="time"
value={time}
/>
</Field>
<Field label="Type">
<select
className={controlClass}
onChange={(event) => setType(event.target.value)}
value={type}
>
{TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</Field>
</div>
<Field label="Provider">
<Input
onChange={(event) => setProvider(event.target.value)}
placeholder="e.g. Dr. Okafor"
value={provider}
/>
</Field>
</DialogPanel>
<DialogFooter>
<DialogClose render={<Button type="button" variant="outline" />}>
Cancel
</DialogClose>
<Button disabled={!selected} type="submit">
Add appointment
</Button>
</DialogFooter>
</form>
</DialogPopup>
</Dialog>
);
}
@@ -3,7 +3,10 @@
import { CalendarClock, Clock, Plus, Stethoscope, Users } from "lucide-react";
import { type ReactNode, useState } from "react";
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
import {
AddAppointmentDialog,
type NewAppointment,
} from "@/components/appointments/add-appointment-dialog";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -202,8 +205,16 @@ function Section({
export function AppointmentsView() {
const [addOpen, setAddOpen] = useState(false);
// Bumped on open so the create dialog remounts with a fresh file # / form.
const [addKey, setAddKey] = useState(0);
const [schedule, setSchedule] = useState<Appointment[]>(today);
// Insert a new (mock) appointment into today's schedule, kept time-sorted.
const addAppointment = (appt: NewAppointment) => {
setSchedule((prev) =>
[...prev, { ...appt, status: "confirmed" as const }].sort((a, b) =>
a.time.localeCompare(b.time),
),
);
};
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-10 px-6 py-10">
@@ -218,10 +229,7 @@ export function AppointmentsView() {
</div>
<Button
className="rounded-3xl"
onClick={() => {
setAddKey((k) => k + 1);
setAddOpen(true);
}}
onClick={() => setAddOpen(true)}
type="button"
>
<Plus className="size-4" />
@@ -236,7 +244,7 @@ export function AppointmentsView() {
</div>
<Section description="Wednesday, June 5" title="Today">
<ScheduleList items={today} />
<ScheduleList items={schedule} />
</Section>
{upcoming.map((group) => (
@@ -245,10 +253,8 @@ export function AppointmentsView() {
</Section>
))}
<PatientFormDialog
key={addKey}
mode="create"
onCreated={() => setAddOpen(false)}
<AddAppointmentDialog
onAdd={addAppointment}
onOpenChange={setAddOpen}
open={addOpen}
/>
+2 -2
View File
@@ -108,7 +108,7 @@ export function NotesEditor({
});
return (
<div className="flex h-full flex-col gap-3">
<div className="flex h-full flex-col gap-4">
<div className="flex items-center gap-2">
<Input
className="font-medium text-base"
@@ -214,7 +214,7 @@ export function NotesEditor({
</ToolbarGroup>
</Toolbar>
<div className="min-h-0 flex-1 cursor-text overflow-y-auto rounded-xl border bg-card">
<div className="min-h-0 flex-1 cursor-text overflow-y-auto rounded-2xl border bg-card/30">
<EditorContent className="h-full" editor={editor} />
</div>
</div>
+29 -29
View File
@@ -102,9 +102,9 @@ export function NotesView() {
};
return (
<div className="flex h-full w-full">
<div className="flex h-full w-full gap-4 p-4">
{/* Left: note list */}
<aside className="flex w-72 shrink-0 flex-col border-border border-r">
<aside className="flex w-72 shrink-0 flex-col overflow-hidden rounded-2xl border bg-card/30">
<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
@@ -150,34 +150,34 @@ export function NotesView() {
{/* 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}
onDelete={selected.id ? () => remove(selected.id) : undefined}
onSave={save}
saving={saving}
/>
</div>
<NotesEditor
key={selected.id || `draft-${draftKey}`}
note={selected}
onDelete={selected.id ? () => remove(selected.id) : undefined}
onSave={save}
saving={saving}
/>
) : (
<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 className="flex flex-1 items-center justify-center rounded-2xl border bg-card/30">
<Empty>
<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>
+2 -2
View File
@@ -182,7 +182,7 @@ export function NavUser() {
{/* Command palette: hint + shortcut, sits below Theme. */}
<MenuItem onClick={openCommand}>
<Search />
Quick nav
Search
<KbdGroup className="ms-auto">
<Kbd></Kbd>
<Kbd>K</Kbd>
@@ -195,7 +195,7 @@ export function NavUser() {
<Building2 />
<span className="truncate">{activeName}</span>
</MenuSubTrigger>
<MenuSubPopup className="min-w-64">
<MenuSubPopup className="min-w-64" sideOffset={8}>
<div className="px-2 py-1.5">
<p className="truncate font-medium text-foreground text-sm">
{activeOrg?.name ?? "No clinic selected"}
@@ -47,7 +47,7 @@
"nav": {
"newChat": "New chat",
"patients": "Patients",
"appointments": "Appointments & Schedule",
"appointments": "Appointments",
"analysis": "Analysis",
"notes": "Notes",
"settings": "Settings",