Files
temetro/frontend/components/tasks/tasks-view.tsx
T
Khalid Abdi e8f3ed9ffe frontend: clear the 99 lint errors
`npm run lint` reported 99 errors and 26 warnings across 65 files and
had presumably been failing for a while — next.config.ts sets
eslint.ignoreDuringBuilds, so the build never surfaced it.

68 were in vendored code: components/charts and components/ai-elements,
pulled from upstream registries. Re-linting those reports upstream's
style back at us, and "fixing" them means diverging and eating conflicts
on every update — ai-elements already has exactly this carve-out on the
TypeScript side via ignoreBuildErrors. Ignore both, plus the two registry
files in components/ui (carousel publishes its api from an effect; the
sidebar skeleton picks a random width).

The other 31 were ours, nearly all react-hooks/set-state-in-effect on
the same shape: an effect that re-seeds form state when a dialog opens or
a selection changes. Moved to render-phase adjustment, which is both what
React recommends and a real fix — the effect version paints one frame of
the *previous* record's values before correcting itself. Two carried
sharper bugs: the employee dialog could keep a typed password across a
switch to another member, and use-wallet-sync could carry `linked` over
to a newly-selected patient, briefly offering to push a record to
someone else's wallet.

The rest: useIsMobile and speech-support detection become
useSyncExternalStore (correct on first paint, no mount flash); refs
mirroring state are written in effects rather than during render; the
care-team fetch moves into its effect behind a reload key, dropping an
exhaustive-deps suppression.

Two effects in chat-panel keep the rule disabled with a reason. Both are
what effects are for: draining the queued-message buffer when the
transport goes idle, and resolving ?thread from the URL — the latter
mints an id with nanoid(), so moving it into render would just trade this
error for a purity one.

Also removes a dead /explore-era import and unused directives found on
the way. lint now exits 0, with the ai-elements tsc carve-out unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:54:37 +03:00

591 lines
19 KiB
TypeScript

"use client";
import { CalendarClock, Plus } from "lucide-react";
import {
type DragEvent,
type FormEvent,
type ReactNode,
useEffect,
useMemo,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { TaskDetailSheet } from "@/components/tasks/task-detail-sheet";
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 { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsPanel, TabsTab } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { ROLE_LABELS } from "@/lib/access";
import { DEPARTMENTS } from "@/lib/roles";
import { listProviders, type Provider, specialtyLabel } from "@/lib/staff";
import {
type Priority,
type Task,
type TaskInput,
type TaskStatus,
createTask,
deleteTask,
listTasks,
updateTask,
} from "@/lib/tasks";
import { notify } from "@/lib/toast";
import { cn } from "@/lib/utils";
type AssigneeMode = "self" | "department" | "person";
function deptLabel(role: string): string {
return (ROLE_LABELS as Record<string, string>)[role] ?? role;
}
export type { Priority, Task } from "@/lib/tasks";
// The board columns, left → right.
const COLUMNS: TaskStatus[] = ["todo", "in_progress", "done"];
const priorityVariant: Record<Priority, "destructive" | "secondary" | "outline"> =
{
high: "destructive",
medium: "secondary",
low: "outline",
};
// A subtle accent dot per column so the three are quick to tell apart.
const columnDot: Record<TaskStatus, string> = {
todo: "bg-muted-foreground",
in_progress: "bg-primary",
done: "bg-success",
};
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,
initialStatus,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onAdd: (task: TaskInput) => void;
initialStatus: TaskStatus;
}) {
const { t } = useTranslation();
const [title, setTitle] = useState("");
const [notes, setNotes] = useState("");
const [assigneeMode, setAssigneeMode] = useState<AssigneeMode>("self");
const [department, setDepartment] = useState<string>("reception");
const [providers, setProviders] = useState<Provider[]>([]);
const [providerId, setProviderId] = useState<string>("");
const [due, setDue] = useState("");
const [priority, setPriority] = useState<Priority>("medium");
const [status, setStatus] = useState<TaskStatus>(initialStatus);
// Re-seed the column when the dialog is opened from a specific column.
// Adjusted during render rather than in an effect, so the select never paints
// with the previous column's value.
const [prevSeed, setPrevSeed] = useState<string | null>(null);
const seed = open ? initialStatus : null;
if (prevSeed !== seed) {
setPrevSeed(seed);
if (seed) setStatus(seed);
}
// Load the clinic's providers so a task can be assigned to a specific person.
useEffect(() => {
if (!open) return;
listProviders()
.then((list) => {
setProviders(list);
setProviderId((id) => id || (list[0]?.userId ?? ""));
})
.catch(() => setProviders([]));
}, [open]);
const reset = () => {
setTitle("");
setNotes("");
setAssigneeMode("self");
setDepartment("reception");
setProviderId("");
setDue("");
setPriority("medium");
setStatus(initialStatus);
};
const submit = (event: FormEvent) => {
event.preventDefault();
if (!title.trim()) {
notify.error(
t("tasks.toast.needSubjectTitle"),
t("tasks.toast.needSubjectBody"),
);
return;
}
if (assigneeMode === "person" && !providerId) {
notify.error(
t("tasks.toast.needPersonTitle"),
t("tasks.toast.needPersonBody"),
);
return;
}
const person =
assigneeMode === "person"
? providers.find((p) => p.userId === providerId)
: undefined;
onAdd({
title: title.trim(),
notes: notes.trim() || undefined,
// Myself → personal task; Department → a member role; Person → a specific
// clinician (their name shows as the assignee, the task surfaces for them).
assigneeRole: assigneeMode === "department" ? department : null,
assigneeUserId: assigneeMode === "person" ? providerId || null : null,
assignee: person?.name,
due: due.trim() || "No due date",
priority,
status,
});
notify.success(t("tasks.toast.addedTitle"), title.trim());
reset();
onOpenChange(false);
};
return (
<Dialog
onOpenChange={(o) => {
onOpenChange(o);
if (!o) reset();
}}
open={open}
>
<DialogPopup className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("tasks.dialog.title")}</DialogTitle>
<DialogDescription>{t("tasks.dialog.description")}</DialogDescription>
</DialogHeader>
<form className="contents" onSubmit={submit}>
<DialogPanel className="flex flex-col gap-4">
<Field label={t("tasks.dialog.subject")}>
<Input
autoFocus
onChange={(e) => setTitle(e.target.value)}
placeholder={t("tasks.dialog.subjectPlaceholder")}
value={title}
/>
</Field>
<Field label={t("tasks.dialog.details")}>
<Textarea
onChange={(e) => setNotes(e.target.value)}
placeholder={t("tasks.dialog.detailsPlaceholder")}
rows={4}
value={notes}
/>
</Field>
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs">
{t("tasks.dialog.assignee")}
</span>
<Tabs
onValueChange={(value) =>
setAssigneeMode(value as AssigneeMode)
}
value={assigneeMode}
>
<TabsList className="w-full">
<TabsTab value="self">{t("tasks.dialog.assigneeSelf")}</TabsTab>
<TabsTab value="department">
{t("tasks.dialog.assigneeDepartment")}
</TabsTab>
<TabsTab value="person">
{t("tasks.dialog.assigneePerson")}
</TabsTab>
</TabsList>
<TabsPanel className="pt-2" value="department">
<Field label={t("tasks.dialog.department")}>
<select
className={controlClass}
onChange={(e) => setDepartment(e.target.value)}
value={department}
>
{DEPARTMENTS.map((d) => (
<option key={d} value={d}>
{ROLE_LABELS[d]}
</option>
))}
</select>
</Field>
</TabsPanel>
<TabsPanel className="pt-2" value="person">
<Field label={t("tasks.dialog.person")}>
{providers.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("tasks.dialog.personEmpty")}
</p>
) : (
<select
className={controlClass}
onChange={(e) => setProviderId(e.target.value)}
value={providerId}
>
{providers.map((p) => {
const spec = specialtyLabel(t, p.specialty);
return (
<option key={p.userId} value={p.userId}>
{spec ? `${p.name} · ${spec}` : p.name}
</option>
);
})}
</select>
)}
</Field>
</TabsPanel>
</Tabs>
</div>
<div className="grid grid-cols-3 gap-3">
<Field label={t("tasks.dialog.due")}>
<Input
onChange={(e) => setDue(e.target.value)}
placeholder={t("tasks.dialog.duePlaceholder")}
value={due}
/>
</Field>
<Field label={t("tasks.dialog.priorityLabel")}>
<select
className={controlClass}
onChange={(e) => setPriority(e.target.value as Priority)}
value={priority}
>
<option value="high">{t("tasks.priority.high")}</option>
<option value="medium">{t("tasks.priority.medium")}</option>
<option value="low">{t("tasks.priority.low")}</option>
</select>
</Field>
<Field label={t("tasks.dialog.statusLabel")}>
<select
className={controlClass}
onChange={(e) => setStatus(e.target.value as TaskStatus)}
value={status}
>
{COLUMNS.map((s) => (
<option key={s} value={s}>
{t(`tasks.status.${s}`)}
</option>
))}
</select>
</Field>
</div>
</DialogPanel>
<DialogFooter>
<DialogClose render={<Button type="button" variant="outline" />}>
{t("tasks.dialog.cancel")}
</DialogClose>
<Button type="submit">{t("tasks.dialog.add")}</Button>
</DialogFooter>
</form>
</DialogPopup>
</Dialog>
);
}
// One draggable task card on the board.
function TaskCard({
task,
onOpen,
onDragStart,
}: {
task: Task;
onOpen: () => void;
onDragStart: () => void;
}) {
const { t } = useTranslation();
return (
<div
className="group flex cursor-pointer flex-col gap-2 rounded-2xl border bg-card p-3 transition-colors hover:border-ring"
draggable
onClick={onOpen}
onDragStart={(e) => {
e.dataTransfer.setData("text/plain", task.id);
e.dataTransfer.effectAllowed = "move";
onDragStart();
}}
>
<span
className={cn(
"text-sm",
task.done
? "text-muted-foreground line-through"
: "font-medium text-foreground",
)}
>
{task.title}
</span>
<span className="truncate text-muted-foreground text-xs">
{task.assigneeUserId
? t("tasks.list.forPerson", { name: task.assignee })
: task.assigneeRole
? t("tasks.list.forDept", { dept: deptLabel(task.assigneeRole) })
: t("tasks.list.personal")}
{task.createdByName
? ` · ${t("tasks.list.byCreator", { name: task.createdByName })}`
: ""}
</span>
<div className="flex items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-1 text-muted-foreground text-xs">
<CalendarClock className="size-3.5 shrink-0" />
<span className="truncate">{task.due}</span>
</span>
<Badge className="shrink-0" variant={priorityVariant[task.priority]}>
{t(`tasks.priority.${task.priority}`)}
</Badge>
</div>
</div>
);
}
export function TasksView() {
const { t } = useTranslation();
const [tasks, setTasks] = useState<Task[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [sheetOpen, setSheetOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const [addStatus, setAddStatus] = useState<TaskStatus>("todo");
const [dragId, setDragId] = useState<string | null>(null);
const [dragOver, setDragOver] = useState<TaskStatus | null>(null);
useEffect(() => {
let active = true;
listTasks()
.then((data) => {
if (active) setTasks(data);
})
.catch(() => {
/* api-client redirects on 401; otherwise leave the list empty */
});
return () => {
active = false;
};
}, []);
const selected = tasks.find((task) => task.id === selectedId) ?? null;
const byStatus = useMemo(() => {
const groups: Record<TaskStatus, Task[]> = {
todo: [],
in_progress: [],
done: [],
};
for (const task of tasks) groups[task.status]?.push(task);
return groups;
}, [tasks]);
// Optimistically move a task to a new column, then persist; roll back on fail.
const moveTask = async (id: string, status: TaskStatus) => {
const current = tasks.find((task) => task.id === id);
if (!current || current.status === status) return;
setTasks((prev) =>
prev.map((row) =>
row.id === id ? { ...row, status, done: status === "done" } : row,
),
);
try {
await updateTask(id, { status });
} catch {
setTasks((prev) => prev.map((row) => (row.id === id ? current : row)));
notify.error(
t("tasks.toast.updateFailedTitle"),
t("tasks.toast.updateFailedBody"),
);
}
};
const openTask = (id: string) => {
setSelectedId(id);
setSheetOpen(true);
};
const removeTask = async () => {
if (!selected) return;
const id = selected.id;
try {
await deleteTask(id);
setTasks((prev) => prev.filter((task) => task.id !== id));
setSheetOpen(false);
notify.success(t("tasks.delete.doneTitle"), selected.title);
} catch {
notify.error(
t("tasks.delete.failedTitle"),
t("tasks.delete.failedBody"),
);
} finally {
setConfirmOpen(false);
}
};
const addTask = async (task: TaskInput) => {
try {
const created = await createTask(task);
setTasks((prev) => [created, ...prev]);
} catch {
notify.error(
t("tasks.toast.addFailedTitle"),
t("tasks.toast.addFailedBody"),
);
}
};
const openAdd = (status: TaskStatus) => {
setAddStatus(status);
setAddOpen(true);
};
const handleDrop = (event: DragEvent, status: TaskStatus) => {
event.preventDefault();
setDragOver(null);
const id = dragId ?? event.dataTransfer.getData("text/plain");
setDragId(null);
if (id) void moveTask(id, status);
};
return (
<div className="flex h-full w-full flex-col gap-6 px-6 py-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-1">
<h1 className="font-semibold text-2xl tracking-tight">
{t("tasks.title")}
</h1>
<p className="text-muted-foreground text-sm">{t("tasks.subtitle")}</p>
</div>
<Button onClick={() => openAdd("todo")} type="button">
<Plus className="size-4" />
{t("tasks.board.newTask")}
</Button>
</div>
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 md:grid-cols-3">
{COLUMNS.map((status) => {
const column = byStatus[status];
return (
<section
className={cn(
"flex min-h-0 flex-col gap-3 rounded-2xl border bg-card/30 p-3 transition-colors",
dragOver === status && "border-ring bg-accent/40",
)}
key={status}
onDragLeave={() => setDragOver((s) => (s === status ? null : s))}
onDragOver={(e) => {
e.preventDefault();
setDragOver(status);
}}
onDrop={(e) => handleDrop(e, status)}
>
<div className="flex items-center justify-between gap-2 px-1">
<div className="flex items-center gap-2">
<span
className={cn("size-2 rounded-full", columnDot[status])}
/>
<span className="font-medium text-sm">
{t(`tasks.status.${status}`)}
</span>
<span className="text-muted-foreground text-xs">
{column.length}
</span>
</div>
<Button
aria-label={t("tasks.board.addTask")}
onClick={() => openAdd(status)}
size="icon-sm"
type="button"
variant="ghost"
>
<Plus className="size-4" />
</Button>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
{column.length === 0 ? (
<p className="px-1 py-6 text-center text-muted-foreground text-xs">
{t("tasks.board.emptyColumn")}
</p>
) : (
column.map((task) => (
<TaskCard
key={task.id}
onDragStart={() => setDragId(task.id)}
onOpen={() => openTask(task.id)}
task={task}
/>
))
)}
<Button
className="justify-start text-muted-foreground"
onClick={() => openAdd(status)}
size="sm"
type="button"
variant="ghost"
>
<Plus className="size-4" />
{t("tasks.board.addTask")}
</Button>
</div>
</section>
);
})}
</div>
<AddTaskDialog
initialStatus={addStatus}
onAdd={addTask}
onOpenChange={setAddOpen}
open={addOpen}
/>
<TaskDetailSheet
onDelete={() => setConfirmOpen(true)}
onMove={moveTask}
onOpenChange={setSheetOpen}
open={sheetOpen}
task={selected}
/>
<ConfirmDialog
cancelLabel={t("tasks.delete.cancel")}
confirmLabel={t("tasks.delete.confirm")}
description={
selected
? t("tasks.delete.body", { title: selected.title })
: undefined
}
onConfirm={removeTask}
onOpenChange={setConfirmOpen}
open={confirmOpen}
title={t("tasks.delete.title")}
/>
</div>
);
}