Tasks/notes/prescriptions/appointments/patients UX fixes

- Task dialog: rename Title→Subject, add a "Details" textarea (notes),
  widen to sm:max-w-lg.
- Tasks & Notes: replace split-panes with full-width lists that open a
  right-side Sheet on click (TaskDetailSheet, NoteDetailSheet), mirroring
  the Patients table → detail Sheet pattern.
- Prescriptions: rows are now clickable and open PrescriptionDetailSheet
  with the full Rx details (incl. duration + notes).
- New prescription dialog: Duration is a dropdown of presets with an
  "Other" → custom input, plus a Notes textarea; NewPrescription gains
  notes and threads duration/notes through to the list.
- Appointments: header search filtering by patient/type/provider,
  grouped by date, with an empty state.
- Patients: pressing Enter in the search box opens the top match's record.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-06 18:59:19 +03:00
parent ceff822cd7
commit 49c39bd853
9 changed files with 595 additions and 252 deletions
@@ -5,6 +5,7 @@ import {
CalendarDays,
Clock,
Plus,
Search,
Stethoscope,
Users,
} from "lucide-react";
@@ -19,6 +20,7 @@ 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";
import { Input } from "@/components/ui/input";
// All figures here are mock/placeholder data — there is no scheduling backend.
// They illustrate the Appointments & Schedule layout.
@@ -261,6 +263,7 @@ export function AppointmentsView() {
const [addOpen, setAddOpen] = useState(false);
const [calendarOpen, setCalendarOpen] = useState(false);
const [appointments, setAppointments] = useState<Appointment[]>(seed);
const [query, setQuery] = useState("");
// Insert a new (mock) appointment at the date/time chosen in the dialog.
const addAppointment = (appt: NewAppointment) => {
@@ -294,6 +297,25 @@ export function AppointmentsView() {
}));
}, [appointments]);
const search = query.trim().toLowerCase();
// While searching, match name/type/provider across every date and group the
// hits by date (soonest first) so each section keeps its day header.
const results = useMemo(() => {
if (!search) return [];
const hits = appointments.filter(
(a) =>
a.name.toLowerCase().includes(search) ||
a.type.toLowerCase().includes(search) ||
a.provider.toLowerCase().includes(search),
);
const keys = [...new Set(hits.map((a) => a.date))].sort();
return keys.map((key) => ({
key,
items: hits.filter((a) => a.date === key).sort(byTime),
}));
}, [appointments, search]);
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">
@@ -306,6 +328,15 @@ export function AppointmentsView() {
</p>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
<Input
className="w-full pl-9 sm:w-64"
onChange={(event) => setQuery(event.target.value)}
placeholder="Search patient, type, provider"
value={query}
/>
</div>
<Button
className="rounded-3xl"
onClick={() => setCalendarOpen(true)}
@@ -326,27 +357,43 @@ export function AppointmentsView() {
</div>
</div>
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
{kpis.map((k) => (
<Kpi key={k.label} {...k} />
))}
</div>
<Section description={formatDayKey(TODAY)} title="Today">
{todayItems.length > 0 ? (
<ScheduleList items={todayItems} />
{search ? (
results.length > 0 ? (
results.map((group) => (
<Section key={group.key} title={formatDayKey(group.key)}>
<ScheduleList items={group.items} />
</Section>
))
) : (
<p className="rounded-2xl border border-dashed bg-card/20 px-4 py-8 text-center text-muted-foreground text-sm">
Nothing scheduled today.
No matching appointments.
</p>
)}
</Section>
)
) : (
<>
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
{kpis.map((k) => (
<Kpi key={k.label} {...k} />
))}
</div>
{upcoming.map((group) => (
<Section key={group.key} title={formatDayKey(group.key)}>
<ScheduleList items={group.items} />
</Section>
))}
<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.key} title={formatDayKey(group.key)}>
<ScheduleList items={group.items} />
</Section>
))}
</>
)}
<AddAppointmentDialog
onAdd={addAppointment}
@@ -0,0 +1,55 @@
"use client";
import { NotesEditor } from "@/components/notes/notes-editor";
import {
Sheet,
SheetHeader,
SheetPopup,
SheetTitle,
} from "@/components/ui/sheet";
import type { Note } from "@/lib/notes";
// Right-side Sheet that holds the rich-text NotesEditor, opened from the Notes
// list (mirrors the Patients table → side Sheet pattern). The save/delete logic
// stays in NotesView and is passed down. `editorKey` remounts the editor when a
// different note (or a fresh draft) is opened.
export function NoteDetailSheet({
note,
editorKey,
open,
onOpenChange,
saving,
onSave,
onDelete,
}: {
note: Note | null;
editorKey: string;
open: boolean;
onOpenChange: (open: boolean) => void;
saving: boolean;
onSave: (data: { title: string; content: string }) => void;
onDelete?: () => void;
}) {
return (
<Sheet onOpenChange={onOpenChange} open={open}>
<SheetPopup className="sm:max-w-2xl" side="right">
<SheetHeader>
<SheetTitle>{note?.id ? "Edit note" : "New note"}</SheetTitle>
</SheetHeader>
{/* Plain flex container (not SheetPanel) so the editor gets a bounded
height and scrolls internally rather than nesting two scroll areas. */}
<div className="flex min-h-0 flex-1 flex-col px-6 pt-1 pb-6">
{note && (
<NotesEditor
key={editorKey}
note={note}
onDelete={onDelete}
onSave={onSave}
saving={saving}
/>
)}
</div>
</SheetPopup>
</Sheet>
);
}
+78 -79
View File
@@ -3,7 +3,7 @@
import { NotebookPen, Plus } from "lucide-react";
import { useEffect, useState } from "react";
import { NotesEditor } from "@/components/notes/notes-editor";
import { NoteDetailSheet } from "@/components/notes/note-detail-sheet";
import { Button } from "@/components/ui/button";
import {
Empty,
@@ -21,7 +21,6 @@ import {
updateNote,
} from "@/lib/notes";
import { notify } from "@/lib/toast";
import { cn } from "@/lib/utils";
const newDraft = (): Note => ({
id: "",
@@ -33,8 +32,9 @@ const newDraft = (): Note => ({
export function NotesView() {
const [notes, setNotes] = useState<Note[]>([]);
// No auto-selection: with nothing chosen the right pane shows the Empty state.
// The note shown in the editor Sheet; null when the Sheet is closed.
const [selected, setSelected] = useState<Note | null>(null);
const [sheetOpen, setSheetOpen] = useState(false);
const [draftKey, setDraftKey] = useState(0);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -64,6 +64,12 @@ export function NotesView() {
const startNew = () => {
setSelected(newDraft());
setDraftKey((k) => k + 1);
setSheetOpen(true);
};
const openNote = (note: Note) => {
setSelected(note);
setSheetOpen(true);
};
const save = async (data: { title: string; content: string }) => {
@@ -92,6 +98,7 @@ export function NotesView() {
const list = await listNotes();
setNotes(list);
setSelected(null);
setSheetOpen(false);
notify.success("Note deleted");
} catch (err) {
notify.error(
@@ -102,84 +109,76 @@ export function NotesView() {
};
return (
<div className="flex h-full w-full gap-4 p-4">
{/* Left: note 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">Notes</h1>
<Button
aria-label="New note"
onClick={startNew}
size="icon-sm"
type="button"
variant="secondary"
>
<Plus className="size-4" />
</Button>
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6 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">Notes</h1>
<p className="text-muted-foreground text-sm">
Clinical notes. Click a note to open it.
</p>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-2">
{loading ? (
<p className="px-2 py-1.5 text-muted-foreground text-sm">Loading</p>
) : notes.length === 0 ? (
<p className="px-2 py-1.5 text-muted-foreground text-sm">
No notes yet.
</p>
) : (
notes.map((n) => (
<button
className={cn(
"flex w-full flex-col items-start gap-0.5 rounded-lg px-2 py-2 text-left transition-colors hover:bg-accent",
selected?.id === n.id && "bg-accent",
)}
key={n.id}
onClick={() => setSelected(n)}
type="button"
>
<span className="w-full truncate font-medium text-foreground text-sm">
{n.title || "Untitled note"}
</span>
<span className="text-muted-foreground text-xs">
{new Date(n.updatedAt).toLocaleDateString()}
</span>
</button>
))
)}
</div>
</aside>
{/* Right: editor or empty state */}
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{selected ? (
<NotesEditor
key={selected.id || `draft-${draftKey}`}
note={selected}
onDelete={selected.id ? () => remove(selected.id) : undefined}
onSave={save}
saving={saving}
/>
) : (
<div className="flex flex-1 items-center justify-center rounded-2xl border bg-card/30">
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<NotebookPen />
</EmptyMedia>
<EmptyTitle>No note selected</EmptyTitle>
<EmptyDescription>
Select a note from the list, or create a new one to start
writing.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button onClick={startNew} type="button">
<Plus className="size-4" />
New note
</Button>
</EmptyContent>
</Empty>
</div>
)}
<Button className="rounded-3xl" onClick={startNew} type="button">
<Plus className="size-4" />
New note
</Button>
</div>
{loading ? (
<div className="rounded-2xl border bg-card/30 px-4 py-10 text-center text-muted-foreground text-sm">
Loading
</div>
) : notes.length === 0 ? (
<div className="flex flex-1 items-center justify-center rounded-2xl border bg-card/30 py-16">
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<NotebookPen />
</EmptyMedia>
<EmptyTitle>No notes yet</EmptyTitle>
<EmptyDescription>
Create a note to start writing.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button onClick={startNew} type="button">
<Plus className="size-4" />
New note
</Button>
</EmptyContent>
</Empty>
</div>
) : (
<div className="divide-y divide-border overflow-hidden rounded-2xl border bg-card/30">
{notes.map((n) => (
<button
className="flex w-full flex-col items-start gap-0.5 px-4 py-3 text-left transition-colors hover:bg-accent/50"
key={n.id}
onClick={() => openNote(n)}
type="button"
>
<span className="w-full truncate font-medium text-foreground text-sm">
{n.title || "Untitled note"}
</span>
<span className="text-muted-foreground text-xs">
Updated {new Date(n.updatedAt).toLocaleDateString()}
</span>
</button>
))}
</div>
)}
<NoteDetailSheet
editorKey={selected?.id || `draft-${draftKey}`}
note={selected}
onDelete={selected?.id ? () => remove(selected.id) : undefined}
onOpenChange={(o) => {
setSheetOpen(o);
if (!o) setSelected(null);
}}
onSave={save}
open={sheetOpen}
saving={saving}
/>
</div>
);
}
@@ -83,6 +83,13 @@ export function PatientsView() {
<Input
className="w-full pl-9 sm:w-64"
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
// Enter opens the top match's record, like picking it from the table.
if (event.key === "Enter" && patients.length > 0) {
event.preventDefault();
open(patients[0].fileNumber);
}
}}
placeholder="Search name or MRN"
value={query}
/>
@@ -16,6 +16,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { listPatients, type Patient } from "@/lib/patients";
import { notify } from "@/lib/toast";
@@ -27,6 +28,7 @@ export type NewPrescription = {
dose: string;
frequency: string;
duration: string;
notes: string;
};
const FREQUENCIES = [
@@ -37,6 +39,20 @@ const FREQUENCIES = [
"As needed",
];
// Preset courses for the Duration dropdown; "Other" reveals a free-text input.
const DURATIONS = [
"3 days",
"5 days",
"7 days",
"10 days",
"14 days",
"1 month",
"3 months",
"Ongoing",
"Other",
];
const OTHER_DURATION = "Other";
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";
@@ -112,7 +128,9 @@ export function AddPrescriptionDialog({
const [medication, setMedication] = useState("");
const [dose, setDose] = useState("");
const [frequency, setFrequency] = useState(FREQUENCIES[0]);
const [duration, setDuration] = useState("");
const [durationChoice, setDurationChoice] = useState(DURATIONS[0]);
const [durationCustom, setDurationCustom] = useState("");
const [notes, setNotes] = useState("");
// Load patients lazily when the dialog opens (for the quick search).
useEffect(() => {
@@ -151,7 +169,9 @@ export function AddPrescriptionDialog({
setMedication("");
setDose("");
setFrequency(FREQUENCIES[0]);
setDuration("");
setDurationChoice(DURATIONS[0]);
setDurationCustom("");
setNotes("");
};
const submit = (event: FormEvent) => {
@@ -164,6 +184,12 @@ export function AddPrescriptionDialog({
notify.error("Add a medication", "Enter the medication name.");
return;
}
const duration =
durationChoice === OTHER_DURATION ? durationCustom.trim() : durationChoice;
if (durationChoice === OTHER_DURATION && !duration) {
notify.error("Add a duration", "Enter the custom duration.");
return;
}
onAdd({
fileNumber: selected.fileNumber,
name: selected.name,
@@ -171,7 +197,8 @@ export function AddPrescriptionDialog({
medication: medication.trim(),
dose: dose.trim(),
frequency,
duration: duration.trim(),
duration,
notes: notes.trim(),
});
notify.success("Prescription added", `${medication.trim()} for ${selected.name}`);
reset();
@@ -295,10 +322,33 @@ export function AddPrescriptionDialog({
</div>
<Field label="Duration">
<Input
onChange={(event) => setDuration(event.target.value)}
placeholder="e.g. 7 days"
value={duration}
<select
className={controlClass}
onChange={(event) => setDurationChoice(event.target.value)}
value={durationChoice}
>
{DURATIONS.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
{durationChoice === OTHER_DURATION && (
<Input
autoFocus
onChange={(event) => setDurationCustom(event.target.value)}
placeholder="e.g. 21 days"
value={durationCustom}
/>
)}
</Field>
<Field label="Notes">
<Textarea
onChange={(event) => setNotes(event.target.value)}
placeholder="Instructions, e.g. take with food in the morning"
rows={3}
value={notes}
/>
</Field>
@@ -0,0 +1,104 @@
"use client";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import {
Sheet,
SheetHeader,
SheetPanel,
SheetPopup,
SheetTitle,
} from "@/components/ui/sheet";
import type { Prescription, RxStatus } from "@/components/prescriptions/prescriptions-view";
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",
};
// Right-side Sheet showing one prescription's full detail, opened by clicking a
// row in the Prescriptions list (mirrors the Patients table → side Sheet
// pattern). Prescriptions live in local state, so the record is passed in.
export function PrescriptionDetailSheet({
rx,
open,
onOpenChange,
}: {
rx: Prescription | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
return (
<Sheet onOpenChange={onOpenChange} open={open}>
<SheetPopup className="sm:max-w-xl" side="right">
<SheetHeader>
<SheetTitle>
{rx ? `${rx.medication}${rx.dose ? ` · ${rx.dose}` : ""}` : "Prescription"}
</SheetTitle>
</SheetHeader>
<SheetPanel className="min-h-0 flex-1">
{rx && (
<div className="flex flex-col gap-5">
<div className="flex items-center gap-3">
<Avatar className="size-10">
<AvatarFallback>{rx.initials}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col">
<span className="truncate font-medium text-foreground text-sm">
{rx.name}
</span>
<span className="text-muted-foreground text-xs">
File #{rx.fileNumber}
</span>
</div>
<Badge className="ms-auto" variant={statusVariant[rx.status]}>
{statusLabel[rx.status]}
</Badge>
</div>
<dl className="grid grid-cols-[7rem_1fr] gap-x-3 gap-y-2 text-sm">
<dt className="text-muted-foreground">Medication</dt>
<dd className="text-foreground">{rx.medication}</dd>
{rx.dose && (
<>
<dt className="text-muted-foreground">Dose</dt>
<dd className="text-foreground">{rx.dose}</dd>
</>
)}
<dt className="text-muted-foreground">Frequency</dt>
<dd className="text-foreground">{rx.frequency}</dd>
<dt className="text-muted-foreground">Duration</dt>
<dd className="text-foreground">{rx.duration || "—"}</dd>
<dt className="text-muted-foreground">Prescriber</dt>
<dd className="text-foreground">{rx.prescriber}</dd>
<dt className="text-muted-foreground">Date</dt>
<dd className="text-foreground">{rx.date}</dd>
<dt className="text-muted-foreground">Status</dt>
<dd className="text-foreground">{statusLabel[rx.status]}</dd>
</dl>
{rx.notes && (
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs">Notes</span>
<p className="whitespace-pre-wrap text-foreground text-sm leading-relaxed">
{rx.notes}
</p>
</div>
)}
</div>
)}
</SheetPanel>
</SheetPopup>
</Sheet>
);
}
@@ -7,6 +7,7 @@ import {
AddPrescriptionDialog,
type NewPrescription,
} from "@/components/prescriptions/add-prescription-dialog";
import { PrescriptionDetailSheet } from "@/components/prescriptions/prescription-detail-sheet";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -15,9 +16,9 @@ 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";
export type RxStatus = "active" | "completed" | "expired";
type Prescription = {
export type Prescription = {
fileNumber: string;
name: string;
initials: string;
@@ -27,6 +28,8 @@ type Prescription = {
prescriber: string;
date: string;
status: RxStatus;
duration?: string;
notes?: string;
};
const statusVariant: Record<
@@ -132,9 +135,20 @@ function Kpi({
);
}
function RxRow({ rx }: { rx: Prescription }) {
function RxRow({ rx, onOpen }: { rx: Prescription; onOpen: () => void }) {
return (
<div className="flex items-center gap-3 px-4 py-3">
<div
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50"
onClick={onOpen}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpen();
}
}}
role="button"
tabIndex={0}
>
<Avatar className="size-8">
<AvatarFallback>{rx.initials}</AvatarFallback>
</Avatar>
@@ -183,6 +197,13 @@ function Section({
export function PrescriptionsView() {
const [addOpen, setAddOpen] = useState(false);
const [list, setList] = useState<Prescription[]>(initial);
const [selected, setSelected] = useState<Prescription | null>(null);
const [sheetOpen, setSheetOpen] = useState(false);
const openRx = (rx: Prescription) => {
setSelected(rx);
setSheetOpen(true);
};
// Insert a new (mock) prescription at the top of the list, marked active.
const addPrescription = (rx: NewPrescription) => {
@@ -194,6 +215,8 @@ export function PrescriptionsView() {
medication: rx.medication,
dose: rx.dose,
frequency: rx.frequency,
duration: rx.duration || undefined,
notes: rx.notes || undefined,
prescriber: "Dr. Okafor",
date: new Date().toLocaleDateString("en-US", {
month: "short",
@@ -234,7 +257,11 @@ export function PrescriptionsView() {
<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} />
<RxRow
key={rx.fileNumber + rx.medication + rx.date}
onOpen={() => openRx(rx)}
rx={rx}
/>
))}
</div>
</Section>
@@ -244,6 +271,12 @@ export function PrescriptionsView() {
onOpenChange={setAddOpen}
open={addOpen}
/>
<PrescriptionDetailSheet
onOpenChange={setSheetOpen}
open={sheetOpen}
rx={selected}
/>
</div>
);
}
@@ -0,0 +1,102 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetHeader,
SheetPanel,
SheetPopup,
SheetTitle,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import type { Priority, Task } from "@/components/tasks/tasks-view";
const priorityVariant: Record<Priority, "destructive" | "secondary" | "outline"> =
{
high: "destructive",
medium: "secondary",
low: "outline",
};
const priorityLabel: Record<Priority, string> = {
high: "High",
medium: "Medium",
low: "Low",
};
// Right-side Sheet showing a single task's full detail, opened from the Tasks
// list (mirrors the Patients table → PatientDetailSheet pattern). The task is
// passed in directly since tasks live in local state — no async fetch.
export function TaskDetailSheet({
task,
open,
onOpenChange,
onToggle,
}: {
task: Task | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onToggle: (id: string) => void;
}) {
return (
<Sheet onOpenChange={onOpenChange} open={open}>
<SheetPopup className="sm:max-w-xl" side="right">
<SheetHeader>
<SheetTitle
className={cn(task?.done && "text-muted-foreground line-through")}
>
{task?.title ?? "Task"}
</SheetTitle>
</SheetHeader>
<SheetPanel className="min-h-0 flex-1">
{task && (
<div className="flex flex-col gap-5">
<div>
<Badge variant={priorityVariant[task.priority]}>
{priorityLabel[task.priority]} 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">
{task.done ? "Completed" : "Open"}
</dd>
<dt className="text-muted-foreground">Assignee</dt>
<dd className="text-foreground">{task.assignee}</dd>
<dt className="text-muted-foreground">Due</dt>
<dd className="text-foreground">{task.due}</dd>
{task.patient && (
<>
<dt className="text-muted-foreground">Patient</dt>
<dd className="text-foreground">{task.patient}</dd>
</>
)}
</dl>
{task.notes && (
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs">Details</span>
<p className="whitespace-pre-wrap text-foreground text-sm leading-relaxed">
{task.notes}
</p>
</div>
)}
<div>
<Button
onClick={() => onToggle(task.id)}
type="button"
variant={task.done ? "outline" : "default"}
>
{task.done ? "Reopen task" : "Mark complete"}
</Button>
</div>
</div>
)}
</SheetPanel>
</SheetPopup>
</Sheet>
);
}
+90 -144
View File
@@ -1,8 +1,9 @@
"use client";
import { Check, ListTodo, Plus } from "lucide-react";
import { Check, Plus } from "lucide-react";
import { type FormEvent, type ReactNode, useMemo, useState } from "react";
import { TaskDetailSheet } from "@/components/tasks/task-detail-sheet";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -15,23 +16,17 @@ import {
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
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";
export type Priority = "high" | "medium" | "low";
type Task = {
export type Task = {
id: string;
title: string;
assignee: string;
@@ -109,7 +104,7 @@ function CheckButton({
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",
"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",
@@ -144,12 +139,14 @@ function AddTaskDialog({
onAdd: (task: Omit<Task, "id" | "done">) => void;
}) {
const [title, setTitle] = useState("");
const [notes, setNotes] = useState("");
const [assignee, setAssignee] = useState("");
const [due, setDue] = useState("");
const [priority, setPriority] = useState<Priority>("medium");
const reset = () => {
setTitle("");
setNotes("");
setAssignee("");
setDue("");
setPriority("medium");
@@ -158,11 +155,12 @@ function AddTaskDialog({
const submit = (event: FormEvent) => {
event.preventDefault();
if (!title.trim()) {
notify.error("Add a title", "Describe the task first.");
notify.error("Add a subject", "Describe the task first.");
return;
}
onAdd({
title: title.trim(),
notes: notes.trim() || undefined,
assignee: assignee.trim() || "Unassigned",
due: due.trim() || "No due date",
priority,
@@ -180,7 +178,7 @@ function AddTaskDialog({
}}
open={open}
>
<DialogPopup className="sm:max-w-md">
<DialogPopup className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>New task</DialogTitle>
<DialogDescription>
@@ -190,7 +188,7 @@ function AddTaskDialog({
<form className="contents" onSubmit={submit}>
<DialogPanel className="flex flex-col gap-4">
<Field label="Title">
<Field label="Subject">
<Input
autoFocus
onChange={(e) => setTitle(e.target.value)}
@@ -198,6 +196,14 @@ function AddTaskDialog({
value={title}
/>
</Field>
<Field label="Details — what needs to be done">
<Textarea
onChange={(e) => setNotes(e.target.value)}
placeholder="Describe what needs to happen, any context, links…"
rows={4}
value={notes}
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Assignee">
<Input
@@ -242,6 +248,7 @@ function AddTaskDialog({
export function TasksView() {
const [tasks, setTasks] = useState<Task[]>(seed);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [sheetOpen, setSheetOpen] = useState(false);
const [filter, setFilter] = useState<Filter>("all");
const [addOpen, setAddOpen] = useState(false);
@@ -258,6 +265,11 @@ export function TasksView() {
prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
);
const openTask = (id: string) => {
setSelectedId(id);
setSheetOpen(true);
};
const addTask = (task: Omit<Task, "id" | "done">) =>
setTasks((prev) => [
{ ...task, id: `t-${Date.now()}`, done: false },
@@ -265,149 +277,83 @@ export function TasksView() {
]);
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>
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6 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">Tasks</h1>
<p className="text-muted-foreground text-sm">
Care-team to-dos. Click a task to see its details. Sample data.
</p>
</div>
<Button
className="rounded-3xl"
onClick={() => setAddOpen(true)}
type="button"
>
<Plus className="size-4" />
New task
</Button>
</div>
<div className="flex w-full items-center gap-1 rounded-2xl border bg-card/30 p-1 sm:w-fit">
{(["all", "open", "done"] as Filter[]).map((f) => (
<Button
aria-label="New task"
onClick={() => setAddOpen(true)}
size="icon-sm"
className="flex-1 capitalize sm:flex-none"
key={f}
onClick={() => setFilter(f)}
size="sm"
type="button"
variant="secondary"
variant={filter === f ? "secondary" : "ghost"}
>
<Plus className="size-4" />
{f}
</Button>
</div>
))}
</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}
<div className="divide-y divide-border overflow-hidden rounded-2xl border bg-card/30">
{visible.length === 0 ? (
<p className="px-4 py-10 text-center text-muted-foreground text-sm">
No tasks here.
</p>
) : (
visible.map((t) => (
<div className="flex items-center gap-3 px-4 py-3" key={t.id}>
<CheckButton done={t.done} onClick={() => toggle(t.id)} />
<button
className="flex min-w-0 flex-1 flex-col text-left"
onClick={() => openTask(t.id)}
type="button"
>
<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",
)}
>
<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]}
{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>
<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} />
<TaskDetailSheet
onOpenChange={setSheetOpen}
onToggle={toggle}
open={sheetOpen}
task={selected}
/>
</div>
);
}