feat: add care execution workspace

This commit is contained in:
2026-07-03 00:35:04 -07:00
parent df7c2efcc5
commit b6b15acc69
20 changed files with 4270 additions and 18 deletions

View File

@@ -0,0 +1,287 @@
"use client";
import { FormEvent, useMemo, useState } from "react";
import { CheckCircle2, Clock3, Play, Search, Siren } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog } from "@/components/ui/dialog";
import { Input, Textarea } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Table } from "@/components/ui/table";
import type { ApiResult } from "@/modules/core/server/api";
import type { CareExecutionData, CareTask, CareTaskPriority, CareTaskStatus, CareTaskType } from "@/modules/care/types";
import { CARE_TASK_PRIORITY_LABELS, CARE_TASK_STATUS_LABELS, CARE_TASK_TYPE_LABELS } from "@/modules/care/types";
type CareWorkspaceClientProps = {
canManage: boolean;
initialData: CareExecutionData;
};
function formatDateTime(value: string | undefined): string {
return value ? new Date(value).toLocaleString("zh-CN") : "-";
}
function statusVariant(status: CareTaskStatus): "success" | "secondary" | "warning" {
if (status === "completed") {
return "success";
}
return status === "pending" ? "warning" : "secondary";
}
function priorityVariant(priority: CareTaskPriority): "danger" | "secondary" | "warning" {
if (priority === "urgent") {
return "danger";
}
return priority === "high" ? "warning" : "secondary";
}
export function CareWorkspaceClient({ canManage, initialData }: CareWorkspaceClientProps): React.ReactElement {
const [data, setData] = useState(initialData);
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | CareTaskStatus>("all");
const [priorityFilter, setPriorityFilter] = useState<"all" | CareTaskPriority>("all");
const [typeFilter, setTypeFilter] = useState<"all" | CareTaskType>("all");
const [message, setMessage] = useState("");
const [isPending, setIsPending] = useState(false);
const [completeTarget, setCompleteTarget] = useState<CareTask | undefined>();
const [executionNotes, setExecutionNotes] = useState("");
const normalizedQuery = query.trim().toLowerCase();
const filteredTasks = useMemo(
() =>
data.tasks.filter((task) => {
const matchesQuery =
!normalizedQuery ||
[task.title, task.elderName, task.assigneeLabel, task.executionNotes].join(" ").toLowerCase().includes(normalizedQuery);
const matchesStatus = statusFilter === "all" || task.status === statusFilter;
const matchesPriority = priorityFilter === "all" || task.priority === priorityFilter;
const matchesType = typeFilter === "all" || task.careType === typeFilter;
return matchesQuery && matchesStatus && matchesPriority && matchesType;
}),
[data.tasks, normalizedQuery, priorityFilter, statusFilter, typeFilter],
);
async function refreshData(): Promise<void> {
const response = await fetch("/api/care/tasks");
const result = (await response.json()) as ApiResult<{ data: CareExecutionData }>;
if (result.success) {
setData(result.data);
}
}
async function updateTask(task: CareTask, status: CareTaskStatus, notes = task.executionNotes): Promise<void> {
if (!canManage) {
setMessage("当前角色无权处理护理任务");
return;
}
setIsPending(true);
setMessage("");
const response = await fetch(`/api/care/tasks/${task.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ status, executionNotes: notes }),
});
const result = (await response.json()) as ApiResult<{ task: CareTask }>;
setIsPending(false);
setMessage(result.reason);
if (result.success) {
setCompleteTarget(undefined);
setExecutionNotes("");
await refreshData();
}
}
function openCompleteDialog(task: CareTask): void {
setCompleteTarget(task);
setExecutionNotes(task.executionNotes);
setMessage("");
}
function closeCompleteDialog(): void {
if (isPending) {
return;
}
setCompleteTarget(undefined);
setExecutionNotes("");
}
async function submitComplete(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
if (!completeTarget) {
return;
}
await updateTask(completeTarget, "completed", executionNotes);
}
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard icon={Clock3} label="待处理" value={data.metrics.pending} />
<MetricCard icon={Play} label="进行中" value={data.metrics.inProgress} />
<MetricCard icon={CheckCircle2} label="今日完成" value={data.metrics.completedToday} />
<MetricCard icon={Siren} label="高优先级" value={data.metrics.highPriority} />
</section>
<section className="flex flex-col gap-3 border-b pb-4 xl:flex-row xl:items-center xl:justify-between">
<div>
<h1 className="text-3xl font-semibold tracking-normal"></h1>
<p className="mt-2 text-sm text-muted-foreground"></p>
</div>
<div className="grid gap-2 sm:grid-cols-2 xl:flex xl:items-center">
<label className="relative block sm:col-span-2 xl:col-span-1">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="w-full pl-9 xl:w-72"
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索老人、任务或执行人"
value={query}
/>
</label>
<Select
aria-label="任务状态"
onValueChange={(value) => setStatusFilter(value as "all" | CareTaskStatus)}
options={[
{ value: "all", label: "全部状态" },
...Object.entries(CARE_TASK_STATUS_LABELS).map(([value, label]) => ({ value, label })),
]}
value={statusFilter}
/>
<Select
aria-label="优先级"
onValueChange={(value) => setPriorityFilter(value as "all" | CareTaskPriority)}
options={[
{ value: "all", label: "全部优先级" },
...Object.entries(CARE_TASK_PRIORITY_LABELS).map(([value, label]) => ({ value, label })),
]}
value={priorityFilter}
/>
<Select
aria-label="护理类型"
onValueChange={(value) => setTypeFilter(value as "all" | CareTaskType)}
options={[
{ value: "all", label: "全部类型" },
...Object.entries(CARE_TASK_TYPE_LABELS).map(([value, label]) => ({ value, label })),
]}
value={typeFilter}
/>
</div>
</section>
{message ? (
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
{message}
</p>
) : null}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="overflow-x-auto">
<Table className="w-full min-w-[980px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 text-right font-medium"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{filteredTasks.map((task) => (
<Table.Row key={task.id}>
<Table.Cell className="px-4 py-3">
<p className="font-medium">{task.title}</p>
<p className="text-xs text-muted-foreground">{task.executionNotes || "-"}</p>
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{task.elderName || "公共区域"}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{CARE_TASK_TYPE_LABELS[task.careType]}</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={priorityVariant(task.priority)}>{CARE_TASK_PRIORITY_LABELS[task.priority]}</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={statusVariant(task.status)}>{CARE_TASK_STATUS_LABELS[task.status]}</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDateTime(task.scheduledAt)}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{task.assigneeLabel || "-"}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDateTime(task.completedAt)}</Table.Cell>
<Table.Cell className="px-4 py-3">
<div className="flex justify-end gap-2">
{task.status === "pending" ? (
<Button disabled={!canManage || isPending} onClick={() => updateTask(task, "in_progress")} size="sm" type="button" variant="outline">
</Button>
) : null}
{task.status === "pending" || task.status === "in_progress" ? (
<Button disabled={!canManage || isPending} onClick={() => openCompleteDialog(task)} size="sm" type="button">
</Button>
) : null}
</div>
</Table.Cell>
</Table.Row>
))}
{filteredTasks.length === 0 ? (
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={9}>
</Table.Cell>
</Table.Row>
) : null}
</Table.Body>
</Table>
</CardContent>
</Card>
<Dialog className="max-w-lg" description={completeTarget?.title} onClose={closeCompleteDialog} open={completeTarget !== undefined} title="完成护理任务">
<form className="grid gap-3" onSubmit={submitComplete}>
<Textarea label="执行备注" onChange={(event) => setExecutionNotes(event.target.value)} value={executionNotes} />
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button disabled={isPending} onClick={closeCompleteDialog} type="button" variant="outline">
</Button>
<Button disabled={isPending} type="submit">
</Button>
</div>
</form>
</Dialog>
</div>
);
}
function MetricCard({
icon: Icon,
label,
value,
}: {
icon: React.ComponentType<{ "aria-hidden"?: boolean; className?: string }>;
label: string;
value: number;
}): React.ReactElement {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between p-4">
<div>
<CardDescription>{label}</CardDescription>
<CardTitle className="mt-2 text-3xl">{value}</CardTitle>
</div>
<Icon className="size-5 text-primary" aria-hidden={true} />
</CardHeader>
</Card>
);
}