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
+10
View File
@@ -0,0 +1,10 @@
import { ActivityView } from "@/components/activity/activity-view";
import { SidebarInset } from "@/components/ui/sidebar";
export default function ActivityPage() {
return (
<SidebarInset className="flex flex-1 flex-col overflow-y-auto">
<ActivityView />
</SidebarInset>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { TasksView } from "@/components/tasks/tasks-view";
import { SidebarInset } from "@/components/ui/sidebar";
export default function TasksPage() {
return (
<SidebarInset className="flex flex-1 flex-col overflow-hidden">
<TasksView />
</SidebarInset>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+1 -1
View File
@@ -15,7 +15,7 @@ export const metadata: Metadata = {
title: "temetro — AI assistant for clinicians",
description:
"Retrieve patient information by simply asking. The open-source AI assistant for clinicians.",
icons: { icon: "/temetro-logo.png", apple: "/temetro-logo.png" },
// Icons come from the app/icon.png + app/apple-icon.png file conventions.
};
export default function RootLayout({
@@ -0,0 +1,211 @@
"use client";
import {
Clock,
FileText,
Hash,
type LucideIcon,
NotebookPen,
Pill,
ShieldCheck,
Stethoscope,
TriangleAlert,
} from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils";
// All entries here are mock/placeholder data — there is no signing/ledger
// backend yet. They illustrate temetro's patient-owned, signed-change vision:
// every record change is signed (blockchain-style) and awaits patient approval.
type ActivityStatus = "signed" | "pending";
type ActivityEntry = {
id: string;
actor: string;
initials: string;
action: string;
patient: string;
fileNumber: string;
time: string;
hash: string;
status: ActivityStatus;
icon: LucideIcon;
};
const entries: ActivityEntry[] = [
{
id: "1",
actor: "Dr. Okafor",
initials: "DO",
action: "Updated vitals",
patient: "Amina Yusuf",
fileNumber: "10293",
time: "Today, 10:24",
hash: "0x9f3a…c21",
status: "pending",
icon: Stethoscope,
},
{
id: "2",
actor: "Dr. Okafor",
initials: "DO",
action: "Added prescription — Lisinopril 10mg",
patient: "Amina Yusuf",
fileNumber: "10293",
time: "Today, 10:21",
hash: "0x4b8e…7df",
status: "pending",
icon: Pill,
},
{
id: "3",
actor: "Dr. Stein",
initials: "DS",
action: "Created note — Lab review",
patient: "Leila Haddad",
fileNumber: "10342",
time: "Today, 09:48",
hash: "0x1c07…a90",
status: "signed",
icon: NotebookPen,
},
{
id: "4",
actor: "Dr. Stein",
initials: "DS",
action: "Edited allergies — added Penicillin",
patient: "Daniel Mensah",
fileNumber: "10311",
time: "Yesterday, 16:05",
hash: "0xab12…44e",
status: "signed",
icon: TriangleAlert,
},
{
id: "5",
actor: "Dr. Okafor",
initials: "DO",
action: "Imported prior records",
patient: "Carlos Rivera",
fileNumber: "10358",
time: "Yesterday, 14:30",
hash: "0x77f0…b3c",
status: "signed",
icon: FileText,
},
];
const kpis = [
{ label: "Pending approvals", value: "2", icon: Clock },
{ label: "Signed today", value: "1", icon: ShieldCheck },
{ label: "Changes this week", value: "37", icon: Hash },
];
function Kpi({
label,
value,
icon: Icon,
}: {
label: string;
value: string;
icon: LucideIcon;
}) {
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>
);
}
export function ActivityView() {
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-10 px-6 py-10">
<div>
<h1 className="font-semibold text-2xl tracking-tight">Activity</h1>
<p className="text-muted-foreground text-sm">
A signed, tamper-evident log of record changes awaiting patient
approval. Sample data.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{kpis.map((k) => (
<Kpi key={k.label} {...k} />
))}
</div>
<ol className="flex flex-col">
{entries.map((entry, i) => {
const Icon = entry.icon;
const isLast = i === entries.length - 1;
return (
<li className="flex gap-4" key={entry.id}>
<div className="flex flex-col items-center">
<div className="flex size-9 shrink-0 items-center justify-center rounded-full border bg-card text-muted-foreground">
<Icon className="size-4" />
</div>
{!isLast && <div className="mt-1 w-px flex-1 bg-border" />}
</div>
<div className={cn("flex-1", isLast ? "pb-0" : "pb-6")}>
<div className="flex items-start justify-between gap-2">
<span className="font-medium text-foreground text-sm">
{entry.action}
</span>
{entry.status === "signed" ? (
<Badge
className="shrink-0 border-success/40 text-success"
variant="outline"
>
<ShieldCheck className="size-3" />
Signed
</Badge>
) : (
<Badge
className="shrink-0 border-warning/40 text-warning"
variant="outline"
>
<Clock className="size-3" />
Pending approval
</Badge>
)}
</div>
<div className="mt-1 flex items-center gap-2">
<Avatar className="size-5">
<AvatarFallback className="text-[10px]">
{entry.initials}
</AvatarFallback>
</Avatar>
<span className="text-muted-foreground text-xs">
{entry.actor} · {entry.patient} (#{entry.fileNumber})
</span>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
<span className="text-muted-foreground">{entry.time}</span>
<span className="inline-flex items-center gap-1 rounded-md border bg-muted/50 px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground">
<Hash className="size-3" />
{entry.hash}
</span>
</div>
</div>
</li>
);
})}
</ol>
</div>
);
}
@@ -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>
)}
+221 -132
View File
@@ -1,7 +1,7 @@
"use client";
import { Mail, SendHorizonal } from "lucide-react";
import { useState } from "react";
import { type FormEvent, useMemo, useState } from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
@@ -12,198 +12,287 @@ import {
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { Textarea } from "@/components/ui/textarea";
import { notify } from "@/lib/toast";
import { Input } from "@/components/ui/input";
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).
// All conversations here are mock/placeholder data — there is no messaging
// backend. They illustrate the inbox + chat-thread timeline layout.
type Message = {
type ChatMessage = {
id: string;
sender: string;
initials: string;
subject: string;
preview: string;
body: string[];
direction: "in" | "out";
text: string;
time: string;
read: boolean;
};
const seed: Message[] = [
type Conversation = {
id: string;
name: string;
initials: string;
role: string;
unread: boolean;
messages: ChatMessage[];
};
const seed: Conversation[] = [
{
id: "1",
sender: "Dr. Stein",
name: "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",
role: "Endocrinology",
unread: true,
messages: [
{
id: "1a",
direction: "in",
text: "The lab results for Amina Yusuf are back — lipid panel and HbA1c are both in range.",
time: "10:24",
},
{
id: "1b",
direction: "in",
text: "Want me to adjust her plan before the follow-up?",
time: "10:25",
},
],
time: "10:24",
read: false,
},
{
id: "2",
sender: "Dr. Okafor",
name: "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",
role: "Family medicine",
unread: true,
messages: [
{
id: "2a",
direction: "out",
text: "Sent over Daniel Mensah's intake notes — can you take a look?",
time: "08:50",
},
{
id: "2b",
direction: "in",
text: "Thanks! Added him to tomorrow at 10:00. Were his prior records imported?",
time: "09:12",
},
],
time: "09:12",
read: false,
},
{
id: "3",
sender: "Care team",
name: "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.",
role: "Clinic-wide",
unread: false,
messages: [
{
id: "3a",
direction: "in",
text: "Seasonal vaccine stock has been replenished — slots are open all week.",
time: "Yesterday",
},
{
id: "3b",
direction: "out",
text: "Great, I'll direct eligible patients to the front desk.",
time: "Yesterday",
},
],
time: "Yesterday",
read: true,
},
{
id: "4",
sender: "Reception",
name: "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.",
role: "Front desk",
unread: false,
messages: [
{
id: "4a",
direction: "in",
text: "Two Friday afternoon appointments were moved to next Monday at the patients' request.",
time: "Mon",
},
],
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 now = () =>
new Date().toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const selected = messages.find((m) => m.id === selectedId) ?? null;
export function MessagesView() {
const [conversations, setConversations] = useState<Conversation[]>(seed);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [showUnreadOnly, setShowUnreadOnly] = useState(false);
const [draft, setDraft] = useState("");
const unreadCount = conversations.filter((c) => c.unread).length;
const selected = conversations.find((c) => c.id === selectedId) ?? null;
const visible = useMemo(
() => (showUnreadOnly ? conversations.filter((c) => c.unread) : conversations),
[conversations, showUnreadOnly],
);
const open = (id: string) => {
setSelectedId(id);
setReply("");
setMessages((prev) =>
prev.map((m) => (m.id === id ? { ...m, read: true } : m)),
setDraft("");
setConversations((prev) =>
prev.map((c) => (c.id === id ? { ...c, unread: false } : c)),
);
};
const send = () => {
if (!(reply.trim() && selected)) return;
notify.success("Reply sent", `To ${selected.sender}`);
setReply("");
const send = (event: FormEvent) => {
event.preventDefault();
const text = draft.trim();
if (!(text && selected)) return;
const message: ChatMessage = {
id: `${selected.id}-${Date.now()}`,
direction: "out",
text,
time: now(),
};
setConversations((prev) =>
prev.map((c) =>
c.id === selected.id
? { ...c, messages: [...c.messages, message] }
: c,
),
);
setDraft("");
};
return (
<div className="flex h-full w-full gap-4 p-4">
{/* Left: inbox list */}
{/* Left: conversation 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>
<Button
aria-pressed={showUnreadOnly}
onClick={() => setShowUnreadOnly((v) => !v)}
size="sm"
type="button"
variant={showUnreadOnly ? "secondary" : "ghost"}
>
Unread · {unreadCount}
</Button>
</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
{visible.length === 0 ? (
<p className="px-2 py-1.5 text-muted-foreground text-sm">
No unread messages.
</p>
) : (
visible.map((c) => {
const last = c.messages.at(-1);
return (
<button
className={cn(
"min-w-0 flex-1 truncate text-sm",
m.read
? "font-medium text-foreground"
: "font-semibold text-foreground",
"flex w-full flex-col gap-0.5 rounded-lg px-2 py-2 text-left transition-colors hover:bg-accent",
selected?.id === c.id && "bg-accent",
)}
key={c.id}
onClick={() => open(c.id)}
type="button"
>
{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 className="flex w-full items-center gap-2">
{c.unread && (
<span className="size-2 shrink-0 rounded-full bg-primary" />
)}
<span
className={cn(
"min-w-0 flex-1 truncate text-sm",
c.unread
? "font-semibold text-foreground"
: "font-medium text-foreground",
)}
>
{c.name}
</span>
<span className="shrink-0 text-muted-foreground text-xs">
{last?.time}
</span>
</div>
<span className="w-full truncate text-muted-foreground text-xs">
{last?.direction === "out" && "You: "}
{last?.text}
</span>
</button>
);
})
)}
</div>
</aside>
{/* Right: reading pane or empty state */}
{/* Right: conversation timeline 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 className="flex items-center gap-3 rounded-2xl border bg-card/30 p-4">
<Avatar className="size-9">
<AvatarFallback>{selected.initials}</AvatarFallback>
</Avatar>
<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">
{selected.role}
</span>
</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 className="flex flex-col gap-3">
{selected.messages.map((m) => (
<div
className={cn(
"flex flex-col gap-1",
m.direction === "out" ? "items-end" : "items-start",
)}
key={m.id}
>
<div
className={cn(
"max-w-[75%] rounded-2xl px-3 py-2 text-sm",
m.direction === "out"
? "bg-primary text-primary-foreground"
: "bg-muted text-foreground",
)}
>
{m.text}
</div>
<span className="px-1 text-muted-foreground text-[11px]">
{m.time}
</span>
</div>
))}
</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}
<form
className="flex items-center gap-2 rounded-2xl border bg-card/30 p-2"
onSubmit={send}
>
<Input
aria-label="Message"
className="border-0 bg-transparent shadow-none before:hidden"
onChange={(e) => setDraft(e.target.value)}
placeholder={`Message ${selected.name}`}
value={draft}
/>
<div className="flex justify-end">
<Button disabled={!reply.trim()} onClick={send} type="button">
<SendHorizonal className="size-4" />
Send
</Button>
</div>
</div>
<Button
aria-label="Send"
disabled={!draft.trim()}
size="icon"
type="submit"
>
<SendHorizonal className="size-4" />
</Button>
</form>
</div>
) : (
<div className="flex flex-1 items-center justify-center rounded-2xl border bg-card/30">
@@ -212,9 +301,9 @@ export function MessagesView() {
<EmptyMedia variant="icon">
<Mail />
</EmptyMedia>
<EmptyTitle>No message selected</EmptyTitle>
<EmptyTitle>No conversation selected</EmptyTitle>
<EmptyDescription>
Choose a message from the inbox to read it here.
Choose a conversation from the inbox to read and reply.
</EmptyDescription>
</EmptyHeader>
</Empty>
@@ -1,8 +1,9 @@
"use client";
import { Search } from "lucide-react";
import { AlertTriangle, Search } from "lucide-react";
import { type FormEvent, type ReactNode, useEffect, useMemo, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -39,6 +40,50 @@ const FREQUENCIES = [
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";
// Mock pharmacology: pairs of drug keywords known to interact. Checked against
// the patient's current medications. Not clinical advice — illustrative only.
const INTERACTIONS: [string, string][] = [
["warfarin", "aspirin"],
["warfarin", "ibuprofen"],
["lisinopril", "potassium"],
["lisinopril", "spironolactone"],
["simvastatin", "clarithromycin"],
["metformin", "contrast"],
["amoxicillin", "methotrexate"],
];
const has = (haystack: string, needle: string) =>
haystack.toLowerCase().includes(needle.toLowerCase());
// Find interaction/allergy conflicts between a new medication and the patient's
// existing record. Returns human-readable warning lines.
function findConflicts(medication: string, patient: Patient): string[] {
const med = medication.trim();
if (med.length < 3) return [];
const conflicts: string[] = [];
for (const allergy of patient.allergies) {
if (has(med, allergy.substance) || has(allergy.substance, med)) {
conflicts.push(
`Allergy: patient is allergic to ${allergy.substance} (${allergy.reaction}).`,
);
}
}
for (const current of patient.medications) {
for (const [a, b] of INTERACTIONS) {
const hit =
(has(med, a) && has(current.name, b)) ||
(has(med, b) && has(current.name, a));
if (hit) {
conflicts.push(`May interact with ${current.name} (current medication).`);
}
}
}
return [...new Set(conflicts)];
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="flex flex-col gap-1.5">
@@ -95,6 +140,11 @@ export function AddPrescriptionDialog({
.slice(0, 6);
}, [patients, query]);
const conflicts = useMemo(
() => (selected ? findConflicts(medication, selected) : []),
[medication, selected],
);
const reset = () => {
setQuery("");
setSelected(null);
@@ -251,6 +301,20 @@ export function AddPrescriptionDialog({
value={duration}
/>
</Field>
{conflicts.length > 0 && (
<Alert variant="warning">
<AlertTriangle />
<AlertTitle>Possible interaction</AlertTitle>
<AlertDescription>
<ul className="list-disc ps-4">
{conflicts.map((c) => (
<li key={c}>{c}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
</DialogPanel>
<DialogFooter>
+413
View File
@@ -0,0 +1,413 @@
"use client";
import { Check, ListTodo, Plus } from "lucide-react";
import { type FormEvent, type ReactNode, useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { Input } from "@/components/ui/input";
import { notify } from "@/lib/toast";
import { cn } from "@/lib/utils";
// All tasks here are mock/placeholder data — there is no tasks backend. They
// illustrate a care-team to-do board.
type Priority = "high" | "medium" | "low";
type Task = {
id: string;
title: string;
assignee: string;
due: string;
priority: Priority;
patient?: string;
notes?: string;
done: boolean;
};
type Filter = "all" | "open" | "done";
const priorityVariant: Record<Priority, "destructive" | "secondary" | "outline"> =
{
high: "destructive",
medium: "secondary",
low: "outline",
};
const priorityLabel: Record<Priority, string> = {
high: "High",
medium: "Medium",
low: "Low",
};
const seed: Task[] = [
{
id: "1",
title: "Review Amina Yusuf's lab results",
assignee: "Dr. Okafor",
due: "Today",
priority: "high",
patient: "Amina Yusuf · #10293",
notes: "Lipid panel + HbA1c back. Decide whether to adjust the plan before her follow-up.",
done: false,
},
{
id: "2",
title: "Confirm Daniel Mensah's prior records import",
assignee: "Reception",
due: "Today",
priority: "medium",
patient: "Daniel Mensah · #10311",
notes: "Check the import completed before tomorrow's 10:00 appointment.",
done: false,
},
{
id: "3",
title: "Call Carlos Rivera about expired statin",
assignee: "Dr. Okafor",
due: "Tomorrow",
priority: "medium",
patient: "Carlos Rivera · #10358",
done: false,
},
{
id: "4",
title: "Restock vaccination fridge log",
assignee: "Care team",
due: "Jun 8",
priority: "low",
done: true,
},
];
function CheckButton({
done,
onClick,
}: {
done: boolean;
onClick: () => void;
}) {
return (
<button
aria-label={done ? "Mark as not done" : "Mark as done"}
aria-pressed={done}
className={cn(
"mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-md border transition-colors",
done
? "border-primary bg-primary text-primary-foreground"
: "border-input hover:border-ring",
)}
onClick={onClick}
type="button"
>
{done && <Check className="size-3.5" />}
</button>
);
}
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>
);
}
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 AddTaskDialog({
open,
onOpenChange,
onAdd,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onAdd: (task: Omit<Task, "id" | "done">) => void;
}) {
const [title, setTitle] = useState("");
const [assignee, setAssignee] = useState("");
const [due, setDue] = useState("");
const [priority, setPriority] = useState<Priority>("medium");
const reset = () => {
setTitle("");
setAssignee("");
setDue("");
setPriority("medium");
};
const submit = (event: FormEvent) => {
event.preventDefault();
if (!title.trim()) {
notify.error("Add a title", "Describe the task first.");
return;
}
onAdd({
title: title.trim(),
assignee: assignee.trim() || "Unassigned",
due: due.trim() || "No due date",
priority,
});
notify.success("Task added", title.trim());
reset();
onOpenChange(false);
};
return (
<Dialog
onOpenChange={(o) => {
onOpenChange(o);
if (!o) reset();
}}
open={open}
>
<DialogPopup className="sm:max-w-md">
<DialogHeader>
<DialogTitle>New task</DialogTitle>
<DialogDescription>
Assign a follow-up to the care team.
</DialogDescription>
</DialogHeader>
<form className="contents" onSubmit={submit}>
<DialogPanel className="flex flex-col gap-4">
<Field label="Title">
<Input
autoFocus
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Review lab results"
value={title}
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Assignee">
<Input
onChange={(e) => setAssignee(e.target.value)}
placeholder="e.g. Dr. Okafor"
value={assignee}
/>
</Field>
<Field label="Due">
<Input
onChange={(e) => setDue(e.target.value)}
placeholder="e.g. Today"
value={due}
/>
</Field>
</div>
<Field label="Priority">
<select
className={controlClass}
onChange={(e) => setPriority(e.target.value as Priority)}
value={priority}
>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</Field>
</DialogPanel>
<DialogFooter>
<DialogClose render={<Button type="button" variant="outline" />}>
Cancel
</DialogClose>
<Button type="submit">Add task</Button>
</DialogFooter>
</form>
</DialogPopup>
</Dialog>
);
}
export function TasksView() {
const [tasks, setTasks] = useState<Task[]>(seed);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [filter, setFilter] = useState<Filter>("all");
const [addOpen, setAddOpen] = useState(false);
const selected = tasks.find((t) => t.id === selectedId) ?? null;
const visible = useMemo(() => {
if (filter === "open") return tasks.filter((t) => !t.done);
if (filter === "done") return tasks.filter((t) => t.done);
return tasks;
}, [tasks, filter]);
const toggle = (id: string) =>
setTasks((prev) =>
prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
);
const addTask = (task: Omit<Task, "id" | "done">) =>
setTasks((prev) => [
{ ...task, id: `t-${Date.now()}`, done: false },
...prev,
]);
return (
<div className="flex h-full w-full gap-4 p-4">
{/* Left: task list */}
<aside className="flex w-80 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">Tasks</h1>
<Button
aria-label="New task"
onClick={() => setAddOpen(true)}
size="icon-sm"
type="button"
variant="secondary"
>
<Plus className="size-4" />
</Button>
</div>
<div className="flex items-center gap-1 border-border border-b px-2 py-2">
{(["all", "open", "done"] as Filter[]).map((f) => (
<Button
className="flex-1 capitalize"
key={f}
onClick={() => setFilter(f)}
size="sm"
type="button"
variant={filter === f ? "secondary" : "ghost"}
>
{f}
</Button>
))}
</div>
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-2">
{visible.length === 0 ? (
<p className="px-2 py-1.5 text-muted-foreground text-sm">
No tasks here.
</p>
) : (
visible.map((t) => (
<div
className={cn(
"flex items-start gap-2 rounded-lg px-2 py-2 transition-colors hover:bg-accent/50",
selected?.id === t.id && "bg-accent",
)}
key={t.id}
>
<CheckButton done={t.done} onClick={() => toggle(t.id)} />
<button
className="flex min-w-0 flex-1 flex-col text-left"
onClick={() => setSelectedId(t.id)}
type="button"
>
<span
className={cn(
"truncate text-sm",
t.done
? "text-muted-foreground line-through"
: "font-medium text-foreground",
)}
>
{t.title}
</span>
<span className="truncate text-muted-foreground text-xs">
{t.assignee} · {t.due}
</span>
</button>
<Badge className="shrink-0" variant={priorityVariant[t.priority]}>
{priorityLabel[t.priority]}
</Badge>
</div>
))
)}
</div>
</aside>
{/* Right: task detail 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 overflow-y-auto rounded-2xl border bg-card/30 p-6">
<div className="flex items-start justify-between gap-3">
<h2
className={cn(
"font-semibold text-foreground text-xl tracking-tight",
selected.done && "text-muted-foreground line-through",
)}
>
{selected.title}
</h2>
<Badge variant={priorityVariant[selected.priority]}>
{priorityLabel[selected.priority]}
</Badge>
</div>
<dl className="grid grid-cols-[6rem_1fr] gap-x-3 gap-y-2 text-sm">
<dt className="text-muted-foreground">Status</dt>
<dd className="text-foreground">
{selected.done ? "Completed" : "Open"}
</dd>
<dt className="text-muted-foreground">Assignee</dt>
<dd className="text-foreground">{selected.assignee}</dd>
<dt className="text-muted-foreground">Due</dt>
<dd className="text-foreground">{selected.due}</dd>
{selected.patient && (
<>
<dt className="text-muted-foreground">Patient</dt>
<dd className="text-foreground">{selected.patient}</dd>
</>
)}
</dl>
{selected.notes && (
<p className="text-foreground text-sm leading-relaxed">
{selected.notes}
</p>
)}
<div>
<Button
onClick={() => toggle(selected.id)}
type="button"
variant={selected.done ? "outline" : "default"}
>
{selected.done ? "Reopen task" : "Mark complete"}
</Button>
</div>
</div>
) : (
<div className="flex flex-1 items-center justify-center rounded-2xl border bg-card/30">
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<ListTodo />
</EmptyMedia>
<EmptyTitle>No task selected</EmptyTitle>
<EmptyDescription>
Select a task to see its details, or create a new one.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
)}
</div>
<AddTaskDialog onAdd={addTask} onOpenChange={setAddOpen} open={addOpen} />
</div>
);
}
@@ -52,6 +52,8 @@
"analysis": "Analysis",
"notes": "Notes",
"messages": "Messages",
"tasks": "Tasks",
"activity": "Activity",
"settings": "Settings",
"notifications": "Notifications",
"viewAllNotifications": "View all notifications",
+5 -1
View File
@@ -1,6 +1,8 @@
import {
BarChart3,
CalendarClock,
History,
ListTodo,
type LucideIcon,
Mail,
NotebookPen,
@@ -60,7 +62,9 @@ export const navItems: NavItem[] = [
icon: BarChart3,
link: "/analysis",
},
{ id: "notes", labelKey: "nav.notes", icon: NotebookPen, link: "/notes" },
{ id: "messages", labelKey: "nav.messages", icon: Mail, link: "/messages" },
{ id: "notes", labelKey: "nav.notes", icon: NotebookPen, link: "/notes" },
{ id: "tasks", labelKey: "nav.tasks", icon: ListTodo, link: "/tasks" },
{ id: "activity", labelKey: "nav.activity", icon: History, link: "/activity" },
{ id: "settings", labelKey: "nav.settings", icon: Settings, link: "/settings" },
];