Files
teatea-pension/modules/care/components/CareWorkspaceClient.tsx

288 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}