mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +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 { 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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,7 +150,6 @@ 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}
|
||||
@@ -158,9 +157,9 @@ export function NotesView() {
|
||||
onSave={save}
|
||||
saving={saving}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Empty className="flex-1">
|
||||
<div className="flex flex-1 items-center justify-center rounded-2xl border bg-card/30">
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<NotebookPen />
|
||||
@@ -178,6 +177,7 @@ export function NotesView() {
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user