mirror of
https://github.com/temetro/temetro.git
synced 2026-08-31 12:58:08 +00:00
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:
@@ -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 { CalendarClock, Clock, Plus, Stethoscope, Users } from "lucide-react";
|
||||||
import { type ReactNode, useState } from "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 { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -202,8 +205,16 @@ function Section({
|
|||||||
|
|
||||||
export function AppointmentsView() {
|
export function AppointmentsView() {
|
||||||
const [addOpen, setAddOpen] = useState(false);
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
// Bumped on open so the create dialog remounts with a fresh file # / form.
|
const [schedule, setSchedule] = useState<Appointment[]>(today);
|
||||||
const [addKey, setAddKey] = useState(0);
|
|
||||||
|
// 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 (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-10 px-6 py-10">
|
<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>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="rounded-3xl"
|
className="rounded-3xl"
|
||||||
onClick={() => {
|
onClick={() => setAddOpen(true)}
|
||||||
setAddKey((k) => k + 1);
|
|
||||||
setAddOpen(true);
|
|
||||||
}}
|
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
@@ -236,7 +244,7 @@ export function AppointmentsView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Section description="Wednesday, June 5" title="Today">
|
<Section description="Wednesday, June 5" title="Today">
|
||||||
<ScheduleList items={today} />
|
<ScheduleList items={schedule} />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{upcoming.map((group) => (
|
{upcoming.map((group) => (
|
||||||
@@ -245,10 +253,8 @@ export function AppointmentsView() {
|
|||||||
</Section>
|
</Section>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<PatientFormDialog
|
<AddAppointmentDialog
|
||||||
key={addKey}
|
onAdd={addAppointment}
|
||||||
mode="create"
|
|
||||||
onCreated={() => setAddOpen(false)}
|
|
||||||
onOpenChange={setAddOpen}
|
onOpenChange={setAddOpen}
|
||||||
open={addOpen}
|
open={addOpen}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export function NotesEditor({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
className="font-medium text-base"
|
className="font-medium text-base"
|
||||||
@@ -214,7 +214,7 @@ export function NotesEditor({
|
|||||||
</ToolbarGroup>
|
</ToolbarGroup>
|
||||||
</Toolbar>
|
</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} />
|
<EditorContent className="h-full" editor={editor} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -102,9 +102,9 @@ export function NotesView() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full">
|
<div className="flex h-full w-full gap-4 p-4">
|
||||||
{/* Left: note list */}
|
{/* 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">
|
<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>
|
<h1 className="font-semibold text-base tracking-tight">Notes</h1>
|
||||||
<Button
|
<Button
|
||||||
@@ -150,34 +150,34 @@ export function NotesView() {
|
|||||||
{/* Right: editor or empty state */}
|
{/* Right: editor or empty state */}
|
||||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<div className="flex h-full flex-col p-6">
|
<NotesEditor
|
||||||
<NotesEditor
|
key={selected.id || `draft-${draftKey}`}
|
||||||
key={selected.id || `draft-${draftKey}`}
|
note={selected}
|
||||||
note={selected}
|
onDelete={selected.id ? () => remove(selected.id) : undefined}
|
||||||
onDelete={selected.id ? () => remove(selected.id) : undefined}
|
onSave={save}
|
||||||
onSave={save}
|
saving={saving}
|
||||||
saving={saving}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<Empty className="flex-1">
|
<div className="flex flex-1 items-center justify-center rounded-2xl border bg-card/30">
|
||||||
<EmptyHeader>
|
<Empty>
|
||||||
<EmptyMedia variant="icon">
|
<EmptyHeader>
|
||||||
<NotebookPen />
|
<EmptyMedia variant="icon">
|
||||||
</EmptyMedia>
|
<NotebookPen />
|
||||||
<EmptyTitle>No note selected</EmptyTitle>
|
</EmptyMedia>
|
||||||
<EmptyDescription>
|
<EmptyTitle>No note selected</EmptyTitle>
|
||||||
Select a note from the list, or create a new one to start
|
<EmptyDescription>
|
||||||
writing.
|
Select a note from the list, or create a new one to start
|
||||||
</EmptyDescription>
|
writing.
|
||||||
</EmptyHeader>
|
</EmptyDescription>
|
||||||
<EmptyContent>
|
</EmptyHeader>
|
||||||
<Button onClick={startNew} type="button">
|
<EmptyContent>
|
||||||
<Plus className="size-4" />
|
<Button onClick={startNew} type="button">
|
||||||
New note
|
<Plus className="size-4" />
|
||||||
</Button>
|
New note
|
||||||
</EmptyContent>
|
</Button>
|
||||||
</Empty>
|
</EmptyContent>
|
||||||
|
</Empty>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ export function NavUser() {
|
|||||||
{/* Command palette: hint + shortcut, sits below Theme. */}
|
{/* Command palette: hint + shortcut, sits below Theme. */}
|
||||||
<MenuItem onClick={openCommand}>
|
<MenuItem onClick={openCommand}>
|
||||||
<Search />
|
<Search />
|
||||||
Quick nav
|
Search
|
||||||
<KbdGroup className="ms-auto">
|
<KbdGroup className="ms-auto">
|
||||||
<Kbd>⌘</Kbd>
|
<Kbd>⌘</Kbd>
|
||||||
<Kbd>K</Kbd>
|
<Kbd>K</Kbd>
|
||||||
@@ -195,7 +195,7 @@ export function NavUser() {
|
|||||||
<Building2 />
|
<Building2 />
|
||||||
<span className="truncate">{activeName}</span>
|
<span className="truncate">{activeName}</span>
|
||||||
</MenuSubTrigger>
|
</MenuSubTrigger>
|
||||||
<MenuSubPopup className="min-w-64">
|
<MenuSubPopup className="min-w-64" sideOffset={8}>
|
||||||
<div className="px-2 py-1.5">
|
<div className="px-2 py-1.5">
|
||||||
<p className="truncate font-medium text-foreground text-sm">
|
<p className="truncate font-medium text-foreground text-sm">
|
||||||
{activeOrg?.name ?? "No clinic selected"}
|
{activeOrg?.name ?? "No clinic selected"}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"newChat": "New chat",
|
"newChat": "New chat",
|
||||||
"patients": "Patients",
|
"patients": "Patients",
|
||||||
"appointments": "Appointments & Schedule",
|
"appointments": "Appointments",
|
||||||
"analysis": "Analysis",
|
"analysis": "Analysis",
|
||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
|
|||||||
Reference in New Issue
Block a user