Add Calendar dialog, Prescriptions & Messages pages, note delete confirm

- Appointments: a Calendar button next to Add opens a month-grid dialog
  (reuses the Calendar primitive + shared ScheduleList); the schedule day is
  marked and selecting it lists that day's appointments.
- Patients sub-nav: new Prescriptions page (mock list + KPIs) with a compact
  "New prescription" dialog reusing the patient quick-search pattern.
- Notes: deleting a note now goes through a reusable ConfirmDialog instead of
  deleting immediately.
- New top-level Messages page: two-pane email-style inbox (list + reading pane
  with mock reply composer), mirroring the Notes layout.
- Sidebar logo bumped from size-9 to size-10.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-06 17:37:17 +03:00
parent 67137c722d
commit 0f10aafa43
12 changed files with 990 additions and 15 deletions
+10
View File
@@ -0,0 +1,10 @@
import { MessagesView } from "@/components/messages/messages-view";
import { SidebarInset } from "@/components/ui/sidebar";
export default function MessagesPage() {
return (
<SidebarInset className="flex flex-1 flex-col overflow-hidden">
<MessagesView />
</SidebarInset>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { PrescriptionsView } from "@/components/prescriptions/prescriptions-view";
import { SidebarInset } from "@/components/ui/sidebar";
export default function PrescriptionsPage() {
return (
<SidebarInset className="flex flex-1 flex-col overflow-y-auto">
<PrescriptionsView />
</SidebarInset>
);
}
@@ -1,12 +1,20 @@
"use client";
import { CalendarClock, Clock, Plus, Stethoscope, Users } from "lucide-react";
import {
CalendarClock,
CalendarDays,
Clock,
Plus,
Stethoscope,
Users,
} from "lucide-react";
import { type ReactNode, useState } from "react";
import {
AddAppointmentDialog,
type NewAppointment,
} from "@/components/appointments/add-appointment-dialog";
import { CalendarDialog } from "@/components/appointments/calendar-dialog";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -17,7 +25,7 @@ import { Card } from "@/components/ui/card";
type ApptStatus = "confirmed" | "checked-in" | "completed" | "cancelled";
type Appointment = {
export type Appointment = {
time: string;
name: string;
initials: string;
@@ -171,7 +179,7 @@ function ApptRow({ appt }: { appt: Appointment }) {
);
}
function ScheduleList({ items }: { items: Appointment[] }) {
export function ScheduleList({ items }: { items: Appointment[] }) {
return (
<div className="divide-y divide-border overflow-hidden rounded-2xl border bg-card/30">
{items.map((appt) => (
@@ -205,6 +213,7 @@ function Section({
export function AppointmentsView() {
const [addOpen, setAddOpen] = useState(false);
const [calendarOpen, setCalendarOpen] = useState(false);
const [schedule, setSchedule] = useState<Appointment[]>(today);
// Insert a new (mock) appointment into today's schedule, kept time-sorted.
@@ -227,14 +236,25 @@ export function AppointmentsView() {
Today&apos;s clinic schedule and what&apos;s coming up. Sample data.
</p>
</div>
<Button
className="rounded-3xl"
onClick={() => setAddOpen(true)}
type="button"
>
<Plus className="size-4" />
Add
</Button>
<div className="flex items-center gap-2">
<Button
className="rounded-3xl"
onClick={() => setCalendarOpen(true)}
type="button"
variant="outline"
>
<CalendarDays className="size-4" />
Calendar
</Button>
<Button
className="rounded-3xl"
onClick={() => setAddOpen(true)}
type="button"
>
<Plus className="size-4" />
Add
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
@@ -258,6 +278,12 @@ export function AppointmentsView() {
onOpenChange={setAddOpen}
open={addOpen}
/>
<CalendarDialog
onOpenChange={setCalendarOpen}
open={calendarOpen}
schedule={schedule}
/>
</div>
);
}
@@ -0,0 +1,99 @@
"use client";
import { useState } from "react";
import {
type Appointment,
ScheduleList,
} from "@/components/appointments/appointments-view";
import { Calendar } from "@/components/ui/calendar";
import {
Dialog,
DialogDescription,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
// The mock schedule all belongs to this day (matches "Wednesday, June 5" in the
// Appointments view). Month is zero-based, so 5 = June.
const SCHEDULE_DATE = new Date(2026, 5, 5);
const sameDay = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
const fullDate = (d: Date) =>
d.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
});
// A month-grid calendar (à la Google Calendar) shown in a dialog. The day that
// owns the mock schedule is ringed; selecting it lists those appointments, while
// any other day shows an empty note. Mock-only — there's no per-date backend.
export function CalendarDialog({
open,
onOpenChange,
schedule,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
schedule: Appointment[];
}) {
const [selected, setSelected] = useState<Date>(SCHEDULE_DATE);
const dayItems = sameDay(selected, SCHEDULE_DATE) ? schedule : [];
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogPopup className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Calendar</DialogTitle>
<DialogDescription>
Browse the schedule by date. Sample data.
</DialogDescription>
</DialogHeader>
<DialogPanel className="flex flex-col gap-5 sm:flex-row sm:gap-6">
<div className="flex justify-center sm:block">
<Calendar
className="rounded-2xl border bg-card/30 p-3"
defaultMonth={SCHEDULE_DATE}
mode="single"
modifiers={{ scheduled: SCHEDULE_DATE }}
modifiersClassNames={{
scheduled: "[&_button]:ring-2 [&_button]:ring-primary",
}}
onSelect={(d) => d && setSelected(d)}
selected={selected}
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-3">
<div>
<h3 className="font-medium text-foreground text-sm">
{fullDate(selected)}
</h3>
<p className="text-muted-foreground text-xs">
{dayItems.length === 1
? "1 appointment"
: `${dayItems.length} appointments`}
</p>
</div>
{dayItems.length > 0 ? (
<ScheduleList items={dayItems} />
) : (
<div className="flex flex-1 items-center justify-center rounded-2xl border border-dashed bg-card/20 px-4 py-10 text-center text-muted-foreground text-sm">
No appointments on this day.
</div>
)}
</div>
</DialogPanel>
</DialogPopup>
</Dialog>
);
}
@@ -0,0 +1,226 @@
"use client";
import { Mail, SendHorizonal } from "lucide-react";
import { useState } from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { Textarea } from "@/components/ui/textarea";
import { notify } from "@/lib/toast";
import { cn } from "@/lib/utils";
// All messages here are mock/placeholder data — there is no messaging backend.
// They illustrate the email-style inbox layout (left list, right reading pane).
type Message = {
id: string;
sender: string;
initials: string;
subject: string;
preview: string;
body: string[];
time: string;
read: boolean;
};
const seed: Message[] = [
{
id: "1",
sender: "Dr. Stein",
initials: "DS",
subject: "Lab results ready",
preview: "The lab results for Amina Yusuf are now available…",
body: [
"Hi,",
"The lab results for Amina Yusuf (file #10293) are now available for your review. The lipid panel and HbA1c both came back within the expected range.",
"Let me know if you'd like to adjust her current plan before the follow-up.",
"— Dr. Stein",
],
time: "10:24",
read: false,
},
{
id: "2",
sender: "Dr. Okafor",
initials: "DO",
subject: "Re: Daniel Mensah intake",
preview: "Thanks for sending the intake notes over. I've…",
body: [
"Thanks for sending the intake notes over.",
"I've added him to tomorrow's schedule at 10:00. Could you confirm whether his prior records were imported?",
"— Dr. Okafor",
],
time: "09:12",
read: false,
},
{
id: "3",
sender: "Care team",
initials: "CT",
subject: "Vaccination stock update",
preview: "A reminder that the seasonal vaccine stock has…",
body: [
"A reminder that the seasonal vaccine stock has been replenished.",
"Slots are open across the week — please direct eligible patients to the front desk to book.",
],
time: "Yesterday",
read: true,
},
{
id: "4",
sender: "Reception",
initials: "RC",
subject: "Schedule change for Friday",
preview: "Two afternoon appointments were rescheduled…",
body: [
"Two afternoon appointments on Friday were rescheduled to next Monday at the patients' request.",
"The updated times are reflected in the schedule.",
],
time: "Mon",
read: true,
},
];
export function MessagesView() {
const [messages, setMessages] = useState<Message[]>(seed);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [reply, setReply] = useState("");
const selected = messages.find((m) => m.id === selectedId) ?? null;
const open = (id: string) => {
setSelectedId(id);
setReply("");
setMessages((prev) =>
prev.map((m) => (m.id === id ? { ...m, read: true } : m)),
);
};
const send = () => {
if (!(reply.trim() && selected)) return;
notify.success("Reply sent", `To ${selected.sender}`);
setReply("");
};
return (
<div className="flex h-full w-full gap-4 p-4">
{/* Left: inbox list */}
<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">Inbox</h1>
<span className="text-muted-foreground text-xs">
{messages.filter((m) => !m.read).length} unread
</span>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-2">
{messages.map((m) => (
<button
className={cn(
"flex w-full flex-col gap-0.5 rounded-lg px-2 py-2 text-left transition-colors hover:bg-accent",
selected?.id === m.id && "bg-accent",
)}
key={m.id}
onClick={() => open(m.id)}
type="button"
>
<div className="flex w-full items-center gap-2">
{!m.read && (
<span className="size-2 shrink-0 rounded-full bg-primary" />
)}
<span
className={cn(
"min-w-0 flex-1 truncate text-sm",
m.read
? "font-medium text-foreground"
: "font-semibold text-foreground",
)}
>
{m.sender}
</span>
<span className="shrink-0 text-muted-foreground text-xs">
{m.time}
</span>
</div>
<span className="w-full truncate text-foreground text-sm">
{m.subject}
</span>
<span className="w-full truncate text-muted-foreground text-xs">
{m.preview}
</span>
</button>
))}
</div>
</aside>
{/* Right: reading pane or empty state */}
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{selected ? (
<div className="flex h-full flex-col gap-4">
<div className="flex flex-col gap-3 rounded-2xl border bg-card/30 p-4">
<h2 className="font-semibold text-foreground text-lg tracking-tight">
{selected.subject}
</h2>
<div className="flex items-center gap-3">
<Avatar className="size-9">
<AvatarFallback>{selected.initials}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-medium text-foreground text-sm">
{selected.sender}
</span>
<span className="text-muted-foreground text-xs">
to me · {selected.time}
</span>
</div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border bg-card/30 p-4">
<div className="flex flex-col gap-3 text-foreground text-sm leading-relaxed">
{selected.body.map((para) => (
<p key={para}>{para}</p>
))}
</div>
</div>
<div className="flex flex-col gap-2 rounded-2xl border bg-card/30 p-3">
<Textarea
onChange={(e) => setReply(e.target.value)}
placeholder={`Reply to ${selected.sender}`}
size="sm"
value={reply}
/>
<div className="flex justify-end">
<Button disabled={!reply.trim()} onClick={send} type="button">
<SendHorizonal className="size-4" />
Send
</Button>
</div>
</div>
</div>
) : (
<div className="flex flex-1 items-center justify-center rounded-2xl border bg-card/30">
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<Mail />
</EmptyMedia>
<EmptyTitle>No message selected</EmptyTitle>
<EmptyDescription>
Choose a message from the inbox to read it here.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
)}
</div>
</div>
);
}
+14 -1
View File
@@ -19,6 +19,7 @@ import {
import { type ReactNode, useReducer, useState } from "react";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { Input } from "@/components/ui/input";
import {
Toolbar,
@@ -77,6 +78,7 @@ export function NotesEditor({
onDelete?: () => void;
}) {
const [title, setTitle] = useState(note.title);
const [confirmOpen, setConfirmOpen] = useState(false);
// Force a re-render on every editor transaction so the toolbar reflects the
// current formatting/undo state.
const [, bump] = useReducer((n: number) => n + 1, 0);
@@ -119,7 +121,7 @@ export function NotesEditor({
{onDelete && (
<Button
aria-label="Delete note"
onClick={onDelete}
onClick={() => setConfirmOpen(true)}
size="icon"
type="button"
variant="ghost"
@@ -217,6 +219,17 @@ export function NotesEditor({
<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>
{onDelete && (
<ConfirmDialog
confirmLabel="Delete note"
description={`"${title.trim() || "Untitled note"}" will be permanently deleted. This can't be undone.`}
onConfirm={onDelete}
onOpenChange={setConfirmOpen}
open={confirmOpen}
title="Delete this note?"
/>
)}
</div>
);
}
@@ -0,0 +1,268 @@
"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 NewPrescription = {
fileNumber: string;
name: string;
initials: string;
medication: string;
dose: string;
frequency: string;
duration: string;
};
const FREQUENCIES = [
"Once daily",
"Twice daily",
"Three times daily",
"Every 8 hours",
"As needed",
];
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 prescription" dialog. The patient is chosen via a quick search by
// name or file number (same pattern as the appointment dialog); the rest is the
// medication detail. Prescriptions are mock-only, so the new entry is handed back
// to the page via onAdd.
export function AddPrescriptionDialog({
open,
onOpenChange,
onAdd,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onAdd: (rx: NewPrescription) => void;
}) {
const [patients, setPatients] = useState<Patient[]>([]);
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<Patient | null>(null);
const [medication, setMedication] = useState("");
const [dose, setDose] = useState("");
const [frequency, setFrequency] = useState(FREQUENCIES[0]);
const [duration, setDuration] = 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);
setMedication("");
setDose("");
setFrequency(FREQUENCIES[0]);
setDuration("");
};
const submit = (event: FormEvent) => {
event.preventDefault();
if (!selected) {
notify.error("Pick a patient", "Search and select a patient first.");
return;
}
if (!medication.trim()) {
notify.error("Add a medication", "Enter the medication name.");
return;
}
onAdd({
fileNumber: selected.fileNumber,
name: selected.name,
initials: selected.initials,
medication: medication.trim(),
dose: dose.trim(),
frequency,
duration: duration.trim(),
});
notify.success("Prescription added", `${medication.trim()} for ${selected.name}`);
reset();
onOpenChange(false);
};
return (
<Dialog
onOpenChange={(o) => {
onOpenChange(o);
if (!o) reset();
}}
open={open}
>
<DialogPopup className="sm:max-w-md">
<DialogHeader>
<DialogTitle>New prescription</DialogTitle>
<DialogDescription>
Search for a patient by name or file number, then set the medication.
</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>
<Field label="Medication">
<Input
onChange={(event) => setMedication(event.target.value)}
placeholder="e.g. Amoxicillin"
value={medication}
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Dose">
<Input
onChange={(event) => setDose(event.target.value)}
placeholder="e.g. 500 mg"
value={dose}
/>
</Field>
<Field label="Frequency">
<select
className={controlClass}
onChange={(event) => setFrequency(event.target.value)}
value={frequency}
>
{FREQUENCIES.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</Field>
</div>
<Field label="Duration">
<Input
onChange={(event) => setDuration(event.target.value)}
placeholder="e.g. 7 days"
value={duration}
/>
</Field>
</DialogPanel>
<DialogFooter>
<DialogClose render={<Button type="button" variant="outline" />}>
Cancel
</DialogClose>
<Button disabled={!selected} type="submit">
Add prescription
</Button>
</DialogFooter>
</form>
</DialogPopup>
</Dialog>
);
}
@@ -0,0 +1,249 @@
"use client";
import { CircleCheck, Clock, Pill, Plus } from "lucide-react";
import { type ReactNode, useState } from "react";
import {
AddPrescriptionDialog,
type NewPrescription,
} from "@/components/prescriptions/add-prescription-dialog";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
// All figures here are mock/placeholder data — there is no prescriptions backend.
// They illustrate the Prescriptions layout.
type RxStatus = "active" | "completed" | "expired";
type Prescription = {
fileNumber: string;
name: string;
initials: string;
medication: string;
dose: string;
frequency: string;
prescriber: string;
date: string;
status: RxStatus;
};
const statusVariant: Record<
RxStatus,
"default" | "secondary" | "destructive" | "outline"
> = {
active: "default",
completed: "outline",
expired: "destructive",
};
const statusLabel: Record<RxStatus, string> = {
active: "Active",
completed: "Completed",
expired: "Expired",
};
const kpis = [
{ label: "Active", value: "8", icon: Pill },
{ label: "Due refill", value: "3", icon: Clock },
{ label: "Completed", value: "27", icon: CircleCheck },
];
const initial: Prescription[] = [
{
fileNumber: "10293",
name: "Amina Yusuf",
initials: "AY",
medication: "Lisinopril",
dose: "10 mg",
frequency: "Once daily",
prescriber: "Dr. Okafor",
date: "Jun 5, 2026",
status: "active",
},
{
fileNumber: "10311",
name: "Daniel Mensah",
initials: "DM",
medication: "Metformin",
dose: "500 mg",
frequency: "Twice daily",
prescriber: "Dr. Okafor",
date: "Jun 4, 2026",
status: "active",
},
{
fileNumber: "10342",
name: "Leila Haddad",
initials: "LH",
medication: "Amoxicillin",
dose: "500 mg",
frequency: "Three times daily",
prescriber: "Dr. Stein",
date: "May 28, 2026",
status: "completed",
},
{
fileNumber: "10358",
name: "Carlos Rivera",
initials: "CR",
medication: "Atorvastatin",
dose: "20 mg",
frequency: "Once daily",
prescriber: "Dr. Okafor",
date: "May 12, 2026",
status: "expired",
},
{
fileNumber: "10377",
name: "Priya Nair",
initials: "PN",
medication: "Salbutamol inhaler",
dose: "100 mcg",
frequency: "As needed",
prescriber: "Dr. Stein",
date: "Jun 1, 2026",
status: "active",
},
];
function Kpi({
label,
value,
icon: Icon,
}: {
label: string;
value: string;
icon: typeof Pill;
}) {
return (
<Card className="flex-row items-center gap-3 p-4">
<div className="flex size-9 items-center justify-center rounded-lg border bg-background text-muted-foreground">
<Icon className="size-4" />
</div>
<div className="flex flex-col">
<span className="text-muted-foreground text-xs">{label}</span>
<span className="font-semibold text-foreground text-lg tracking-tight">
{value}
</span>
</div>
</Card>
);
}
function RxRow({ rx }: { rx: Prescription }) {
return (
<div className="flex items-center gap-3 px-4 py-3">
<Avatar className="size-8">
<AvatarFallback>{rx.initials}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-medium text-foreground text-sm">
{rx.medication}
{rx.dose && (
<span className="font-normal text-muted-foreground"> · {rx.dose}</span>
)}
</span>
<span className="truncate text-muted-foreground text-xs">
{rx.name} · #{rx.fileNumber} · {rx.frequency}
</span>
</div>
<div className="hidden min-w-0 flex-col items-end sm:flex">
<span className="truncate text-foreground text-xs">{rx.prescriber}</span>
<span className="text-muted-foreground text-xs">{rx.date}</span>
</div>
<Badge variant={statusVariant[rx.status]}>{statusLabel[rx.status]}</Badge>
</div>
);
}
function Section({
title,
description,
children,
}: {
title: string;
description?: string;
children: ReactNode;
}) {
return (
<section className="flex flex-col gap-3">
<div>
<h2 className="font-semibold text-lg tracking-tight">{title}</h2>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
{children}
</section>
);
}
export function PrescriptionsView() {
const [addOpen, setAddOpen] = useState(false);
const [list, setList] = useState<Prescription[]>(initial);
// Insert a new (mock) prescription at the top of the list, marked active.
const addPrescription = (rx: NewPrescription) => {
setList((prev) => [
{
fileNumber: rx.fileNumber,
name: rx.name,
initials: rx.initials,
medication: rx.medication,
dose: rx.dose,
frequency: rx.frequency,
prescriber: "Dr. Okafor",
date: new Date().toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}),
status: "active" as const,
},
...prev,
]);
};
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-10 px-6 py-10">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="font-semibold text-2xl tracking-tight">Prescriptions</h1>
<p className="text-muted-foreground text-sm">
Medications prescribed across the clinic. Sample data.
</p>
</div>
<Button
className="rounded-3xl"
onClick={() => setAddOpen(true)}
type="button"
>
<Plus className="size-4" />
New prescription
</Button>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{kpis.map((k) => (
<Kpi key={k.label} {...k} />
))}
</div>
<Section description="Most recent first" title="Recent prescriptions">
<div className="divide-y divide-border overflow-hidden rounded-2xl border bg-card/30">
{list.map((rx) => (
<RxRow key={rx.fileNumber + rx.medication + rx.date} rx={rx} />
))}
</div>
</Section>
<AddPrescriptionDialog
onAdd={addPrescription}
onOpenChange={setAddOpen}
open={addOpen}
/>
</div>
);
}
@@ -84,11 +84,11 @@ export function DashboardSidebar() {
>
<Image
alt="temetro"
className="size-9 shrink-0"
height={36}
className="size-10 shrink-0"
height={40}
priority
src="/temetro-logo.png"
width={36}
width={40}
/>
</a>
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
// A small controlled confirmation dialog built on Dialog (there is no AlertDialog
// primitive). Use for destructive actions: the confirm button is `destructive`
// by default. onConfirm fires, then the dialog closes.
export function ConfirmDialog({
open,
onOpenChange,
onConfirm,
title,
description,
confirmLabel = "Delete",
cancelLabel = "Cancel",
variant = "destructive",
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
title: string;
description?: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
variant?: "destructive" | "default";
}) {
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogPopup className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<DialogFooter variant="bare">
<DialogClose render={<Button type="button" variant="outline" />}>
{cancelLabel}
</DialogClose>
<Button
onClick={() => {
onConfirm();
onOpenChange(false);
}}
type="button"
variant={variant}
>
{confirmLabel}
</Button>
</DialogFooter>
</DialogPopup>
</Dialog>
);
}
@@ -48,8 +48,10 @@
"newChat": "New chat",
"patients": "Patients",
"appointments": "Appointments",
"prescriptions": "Prescriptions",
"analysis": "Analysis",
"notes": "Notes",
"messages": "Messages",
"settings": "Settings",
"notifications": "Notifications",
"viewAllNotifications": "View all notifications",
+9
View File
@@ -2,7 +2,9 @@ import {
BarChart3,
CalendarClock,
type LucideIcon,
Mail,
NotebookPen,
Pill,
Plus,
Settings,
Users,
@@ -44,6 +46,12 @@ export const navItems: NavItem[] = [
icon: CalendarClock,
link: "/appointments",
},
{
id: "prescriptions",
labelKey: "nav.prescriptions",
icon: Pill,
link: "/prescriptions",
},
],
},
{
@@ -53,5 +61,6 @@ export const navItems: NavItem[] = [
link: "/analysis",
},
{ id: "notes", labelKey: "nav.notes", icon: NotebookPen, link: "/notes" },
{ id: "messages", labelKey: "nav.messages", icon: Mail, link: "/messages" },
{ id: "settings", labelKey: "nav.settings", icon: Settings, link: "/settings" },
];