Redesign Calendar + Messages, fix favicon, add date picker, Tasks & Activity

- Appointments now carry a date; the Calendar button opens a Google-Calendar
  style month grid (color-coded event chips, month nav, Today, click a day for
  its list). Today/Upcoming sections derive from dates.
- Add-appointment dialog gains a date picker (Popover + Calendar).
- Messages rebuilt as chat threads: an input-based composer that appends to a
  two-way timeline (no more toast covering the field), and the unread count is
  now a filter toggle.
- Favicon: the brand logo now shows in the tab. Removed the stale default
  app/favicon.ico (which shadowed it) and added app/icon.png + app/apple-icon.png
  generated from the logo; dropped the redundant metadata.icons.
- Sidebar: swapped Notes <-> Messages order (both kept).
- New pages: Activity (signed-change audit timeline with hash chips + approval
  status) and Tasks (two-pane care-team to-do with add dialog).
- Prescriptions: adding a medication now surfaces mock drug-interaction and
  allergy warnings against the patient's record.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-06 18:18:48 +03:00
parent 0f10aafa43
commit 00152eb3cb
15 changed files with 1286 additions and 223 deletions
@@ -1,9 +1,11 @@
"use client";
import { Search } from "lucide-react";
import { CalendarDays, Search } from "lucide-react";
import { type FormEvent, type ReactNode, useEffect, useMemo, useState } from "react";
import { TODAY } from "@/components/appointments/appointments-view";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Dialog,
DialogClose,
@@ -15,6 +17,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Popover, PopoverPopup, PopoverTrigger } from "@/components/ui/popover";
import { listPatients, type Patient } from "@/lib/patients";
import { notify } from "@/lib/toast";
@@ -22,11 +25,18 @@ export type NewAppointment = {
fileNumber: string;
name: string;
initials: string;
date: string; // ISO YYYY-MM-DD
time: string;
type: string;
provider: string;
};
// Local-date ISO key (avoids UTC drift from toISOString).
const keyOf = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(
d.getDate(),
).padStart(2, "0")}`;
const TYPES = [
"Follow-up",
"New patient",
@@ -62,6 +72,8 @@ export function AddAppointmentDialog({
const [patients, setPatients] = useState<Patient[]>([]);
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<Patient | null>(null);
const [date, setDate] = useState<Date>(() => new Date(`${TODAY}T00:00:00`));
const [dateOpen, setDateOpen] = useState(false);
const [time, setTime] = useState("09:00");
const [type, setType] = useState(TYPES[0]);
const [provider, setProvider] = useState("");
@@ -96,6 +108,8 @@ export function AddAppointmentDialog({
const reset = () => {
setQuery("");
setSelected(null);
setDate(new Date(`${TODAY}T00:00:00`));
setDateOpen(false);
setTime("09:00");
setType(TYPES[0]);
setProvider("");
@@ -111,6 +125,7 @@ export function AddAppointmentDialog({
fileNumber: selected.fileNumber,
name: selected.name,
initials: selected.initials,
date: keyOf(date),
time,
type,
provider: provider.trim() || selected.pcp,
@@ -206,6 +221,39 @@ export function AddAppointmentDialog({
</Field>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs">Date</span>
<Popover onOpenChange={setDateOpen} open={dateOpen}>
<PopoverTrigger
render={
<Button
className="w-full justify-start font-normal"
type="button"
variant="outline"
>
<CalendarDays className="size-4" />
{date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</Button>
}
/>
<PopoverPopup>
<Calendar
mode="single"
onSelect={(d) => {
if (d) {
setDate(d);
setDateOpen(false);
}
}}
selected={date}
/>
</PopoverPopup>
</Popover>
</div>
<Field label="Time">
<Input
onChange={(event) => setTime(event.target.value)}
@@ -213,6 +261,9 @@ export function AddAppointmentDialog({
value={time}
/>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Type">
<select
className={controlClass}
@@ -226,15 +277,14 @@ export function AddAppointmentDialog({
))}
</select>
</Field>
<Field label="Provider">
<Input
onChange={(event) => setProvider(event.target.value)}
placeholder="e.g. Dr. Okafor"
value={provider}
/>
</Field>
</div>
<Field label="Provider">
<Input
onChange={(event) => setProvider(event.target.value)}
placeholder="e.g. Dr. Okafor"
value={provider}
/>
</Field>
</DialogPanel>
<DialogFooter>
@@ -8,7 +8,7 @@ import {
Stethoscope,
Users,
} from "lucide-react";
import { type ReactNode, useState } from "react";
import { type ReactNode, useMemo, useState } from "react";
import {
AddAppointmentDialog,
@@ -23,9 +23,14 @@ import { Card } from "@/components/ui/card";
// All figures here are mock/placeholder data — there is no scheduling backend.
// They illustrate the Appointments & Schedule layout.
// Anchor "today" to a fixed date so the mock copy ("Wednesday, June 5") lines up
// across the page, the calendar dialog, and the add dialog. ISO YYYY-MM-DD.
export const TODAY = "2026-06-05";
type ApptStatus = "confirmed" | "checked-in" | "completed" | "cancelled";
export type Appointment = {
date: string; // ISO YYYY-MM-DD
time: string;
name: string;
initials: string;
@@ -58,8 +63,10 @@ const kpis = [
{ label: "Utilization", value: "87%", icon: Stethoscope },
];
const today: Appointment[] = [
// Mock schedule spread across June 2026 so the month calendar looks populated.
const seed: Appointment[] = [
{
date: TODAY,
time: "09:00",
name: "Amina Yusuf",
initials: "AY",
@@ -68,6 +75,7 @@ const today: Appointment[] = [
status: "completed",
},
{
date: TODAY,
time: "09:30",
name: "Daniel Mensah",
initials: "DM",
@@ -76,6 +84,7 @@ const today: Appointment[] = [
status: "checked-in",
},
{
date: TODAY,
time: "10:15",
name: "Leila Haddad",
initials: "LH",
@@ -84,6 +93,7 @@ const today: Appointment[] = [
status: "confirmed",
},
{
date: TODAY,
time: "11:00",
name: "Carlos Rivera",
initials: "CR",
@@ -92,6 +102,7 @@ const today: Appointment[] = [
status: "confirmed",
},
{
date: TODAY,
time: "13:30",
name: "Priya Nair",
initials: "PN",
@@ -100,6 +111,7 @@ const today: Appointment[] = [
status: "cancelled",
},
{
date: TODAY,
time: "14:45",
name: "Tom Becker",
initials: "TB",
@@ -107,32 +119,66 @@ const today: Appointment[] = [
provider: "Dr. Okafor",
status: "confirmed",
},
];
const upcoming: { day: string; items: Appointment[] }[] = [
{
day: "Tomorrow",
items: [
{
time: "08:45",
name: "Grace Lin",
initials: "GL",
type: "Follow-up",
provider: "Dr. Stein",
status: "confirmed",
},
{
time: "10:00",
name: "Omar Farouk",
initials: "OF",
type: "New patient",
provider: "Dr. Okafor",
status: "confirmed",
},
],
date: "2026-06-06",
time: "08:45",
name: "Grace Lin",
initials: "GL",
type: "Follow-up",
provider: "Dr. Stein",
status: "confirmed",
},
{
date: "2026-06-06",
time: "10:00",
name: "Omar Farouk",
initials: "OF",
type: "New patient",
provider: "Dr. Okafor",
status: "confirmed",
},
{
date: "2026-06-09",
time: "11:30",
name: "Sofia Marin",
initials: "SM",
type: "Consultation",
provider: "Dr. Stein",
status: "confirmed",
},
{
date: "2026-06-12",
time: "15:00",
name: "Henry Adeyemi",
initials: "HA",
type: "Lab review",
provider: "Dr. Okafor",
status: "confirmed",
},
{
date: "2026-06-18",
time: "09:15",
name: "Nadia Petrova",
initials: "NP",
type: "Follow-up",
provider: "Dr. Stein",
status: "confirmed",
},
];
// "2026-06-05" -> "Wednesday, June 5"
function formatDayKey(key: string): string {
return new Date(`${key}T00:00:00`).toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
});
}
function byTime(a: Appointment, b: Appointment) {
return a.time.localeCompare(b.time);
}
function Kpi({
label,
value,
@@ -214,17 +260,40 @@ function Section({
export function AppointmentsView() {
const [addOpen, setAddOpen] = useState(false);
const [calendarOpen, setCalendarOpen] = useState(false);
const [schedule, setSchedule] = useState<Appointment[]>(today);
const [appointments, setAppointments] = useState<Appointment[]>(seed);
// Insert a new (mock) appointment into today's schedule, kept time-sorted.
// Insert a new (mock) appointment at the date/time chosen in the dialog.
const addAppointment = (appt: NewAppointment) => {
setSchedule((prev) =>
[...prev, { ...appt, status: "confirmed" as const }].sort((a, b) =>
a.time.localeCompare(b.time),
),
);
setAppointments((prev) => [
...prev,
{
date: appt.date,
time: appt.time,
name: appt.name,
initials: appt.initials,
type: appt.type,
provider: appt.provider,
status: "confirmed" as const,
},
]);
};
const todayItems = useMemo(
() => appointments.filter((a) => a.date === TODAY).sort(byTime),
[appointments],
);
// Group future dates (after TODAY) into day sections, soonest first.
const upcoming = useMemo(() => {
const keys = [
...new Set(appointments.map((a) => a.date).filter((d) => d > TODAY)),
].sort();
return keys.map((key) => ({
key,
items: appointments.filter((a) => a.date === key).sort(byTime),
}));
}, [appointments]);
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">
@@ -263,12 +332,18 @@ export function AppointmentsView() {
))}
</div>
<Section description="Wednesday, June 5" title="Today">
<ScheduleList items={schedule} />
<Section description={formatDayKey(TODAY)} title="Today">
{todayItems.length > 0 ? (
<ScheduleList items={todayItems} />
) : (
<p className="rounded-2xl border border-dashed bg-card/20 px-4 py-8 text-center text-muted-foreground text-sm">
Nothing scheduled today.
</p>
)}
</Section>
{upcoming.map((group) => (
<Section key={group.day} title={group.day}>
<Section key={group.key} title={formatDayKey(group.key)}>
<ScheduleList items={group.items} />
</Section>
))}
@@ -280,9 +355,9 @@ export function AppointmentsView() {
/>
<CalendarDialog
appointments={appointments}
onOpenChange={setCalendarOpen}
open={calendarOpen}
schedule={schedule}
/>
</div>
);
@@ -1,93 +1,228 @@
"use client";
import { useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useMemo, useState } from "react";
import {
type Appointment,
ScheduleList,
TODAY,
} from "@/components/appointments/appointments-view";
import { Calendar } from "@/components/ui/calendar";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogDescription,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
// 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 WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const sameDay = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
// Local-date ISO key, e.g. "2026-06-05" (avoids UTC drift from toISOString).
const keyOf = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(
d.getDate(),
).padStart(2, "0")}`;
const fullDate = (d: Date) =>
d.toLocaleDateString("en-US", {
const parseKey = (key: string) => new Date(`${key}T00:00:00`);
const formatDayKey = (key: string) =>
parseKey(key).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.
const byTime = (a: Appointment, b: Appointment) => a.time.localeCompare(b.time);
// Event chip color by status, using semantic tokens.
const chipClass: Record<Appointment["status"], string> = {
confirmed: "bg-secondary text-secondary-foreground",
"checked-in": "bg-success/15 text-success",
completed: "bg-muted text-muted-foreground",
cancelled: "bg-destructive/15 text-destructive line-through",
};
// A Google-Calendar-style month grid in a dialog: a 6×7 grid of day cells with
// color-coded event chips; navigate months and click a day to list its
// appointments. Mock-only — there's no per-date scheduling backend.
export function CalendarDialog({
open,
onOpenChange,
schedule,
appointments,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
schedule: Appointment[];
appointments: Appointment[];
}) {
const [selected, setSelected] = useState<Date>(SCHEDULE_DATE);
// First-of-month for the displayed month; defaults to TODAY's month.
const [viewMonth, setViewMonth] = useState<Date>(() => {
const t = parseKey(TODAY);
return new Date(t.getFullYear(), t.getMonth(), 1);
});
const [selectedKey, setSelectedKey] = useState<string>(TODAY);
const dayItems = sameDay(selected, SCHEDULE_DATE) ? schedule : [];
const byDate = useMemo(() => {
const map = new Map<string, Appointment[]>();
for (const a of appointments) {
const list = map.get(a.date) ?? [];
list.push(a);
map.set(a.date, list);
}
return map;
}, [appointments]);
// 42 cells (6 weeks) starting from the Sunday on/before the 1st.
const cells = useMemo(() => {
const first = new Date(viewMonth.getFullYear(), viewMonth.getMonth(), 1);
const start = new Date(
first.getFullYear(),
first.getMonth(),
1 - first.getDay(),
);
return Array.from(
{ length: 42 },
(_, i) =>
new Date(start.getFullYear(), start.getMonth(), start.getDate() + i),
);
}, [viewMonth]);
const monthLabel = viewMonth.toLocaleDateString("en-US", {
month: "long",
year: "numeric",
});
const selectedItems = (byDate.get(selectedKey) ?? []).slice().sort(byTime);
const shiftMonth = (delta: number) =>
setViewMonth(
(m) => new Date(m.getFullYear(), m.getMonth() + delta, 1),
);
const goToday = () => {
const t = parseKey(TODAY);
setViewMonth(new Date(t.getFullYear(), t.getMonth(), 1));
setSelectedKey(TODAY);
};
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogPopup className="sm:max-w-2xl">
<DialogPopup className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Calendar</DialogTitle>
<DialogDescription>
Browse the schedule by date. Sample data.
</DialogDescription>
<div className="flex items-center justify-between gap-3 pe-8">
<DialogTitle>{monthLabel}</DialogTitle>
<div className="flex items-center gap-1">
<Button
onClick={goToday}
size="sm"
type="button"
variant="outline"
>
Today
</Button>
<Button
aria-label="Previous month"
onClick={() => shiftMonth(-1)}
size="icon-sm"
type="button"
variant="ghost"
>
<ChevronLeft />
</Button>
<Button
aria-label="Next month"
onClick={() => shiftMonth(1)}
size="icon-sm"
type="button"
variant="ghost"
>
<ChevronRight />
</Button>
</div>
</div>
</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}
/>
<DialogPanel className="flex flex-col gap-4">
<div>
<div className="grid grid-cols-7 gap-1 pb-1">
{WEEKDAYS.map((d) => (
<div
className="px-1 text-center font-medium text-muted-foreground text-xs"
key={d}
>
{d}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{cells.map((date) => {
const key = keyOf(date);
const inMonth = date.getMonth() === viewMonth.getMonth();
const isToday = key === TODAY;
const isSelected = key === selectedKey;
const items = (byDate.get(key) ?? []).slice().sort(byTime);
return (
<button
className={cn(
"flex min-h-22 flex-col gap-1 rounded-lg border p-1.5 text-left align-top transition-colors hover:bg-accent/50",
inMonth
? "bg-card/30"
: "bg-transparent text-muted-foreground/40",
isSelected && "ring-2 ring-primary",
)}
key={key}
onClick={() => setSelectedKey(key)}
type="button"
>
<span
className={cn(
"flex size-6 items-center justify-center rounded-full text-xs",
isToday && "bg-primary font-semibold text-primary-foreground",
)}
>
{date.getDate()}
</span>
<div className="flex flex-col gap-0.5 overflow-hidden">
{items.slice(0, 3).map((a) => (
<span
className={cn(
"truncate rounded px-1 py-0.5 text-[10px] leading-tight",
chipClass[a.status],
)}
key={a.time + a.name}
>
{a.time} {a.name}
</span>
))}
{items.length > 3 && (
<span className="px-1 text-[10px] text-muted-foreground">
+{items.length - 3} more
</span>
)}
</div>
</button>
);
})}
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-3">
<div className="flex flex-col gap-2">
<div>
<h3 className="font-medium text-foreground text-sm">
{fullDate(selected)}
{formatDayKey(selectedKey)}
</h3>
<p className="text-muted-foreground text-xs">
{dayItems.length === 1
{selectedItems.length === 1
? "1 appointment"
: `${dayItems.length} appointments`}
: `${selectedItems.length} appointments`}
</p>
</div>
{dayItems.length > 0 ? (
<ScheduleList items={dayItems} />
{selectedItems.length > 0 ? (
<ScheduleList items={selectedItems} />
) : (
<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">
<div className="rounded-2xl border border-dashed bg-card/20 px-4 py-8 text-center text-muted-foreground text-sm">
No appointments on this day.
</div>
)}