288 lines
12 KiB
TypeScript
288 lines
12 KiB
TypeScript
"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>
|
||
);
|
||
}
|