Files
teatea-pension/modules/elders/components/EldersClient.tsx

563 lines
21 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 { Edit3, Filter, Plus, Search, Trash2 } 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 { Permission } from "@/modules/core/types";
import type { CareLevel, Elder, ElderInput, ElderStatus } from "@/modules/elders/types";
import { CARE_LEVEL_LABELS, ELDER_STATUS_LABELS, GENDER_LABELS } from "@/modules/elders/types";
type EldersClientProps = {
initialElders: Elder[];
permissions: Permission[];
};
const emptyInput: ElderInput = {
name: "",
gender: "male",
age: 75,
careLevel: "self-care",
room: "",
bed: "",
status: "active",
primaryContact: "",
phone: "",
medicalNotes: "",
};
const PAGE_SIZE = 8;
const ALL_STATUS = "all";
const ALL_CARE_LEVELS = "all";
function elderToInput(elder: Elder): ElderInput {
return {
name: elder.name,
gender: elder.gender,
age: elder.age,
careLevel: elder.careLevel,
room: elder.room,
bed: elder.bed,
status: elder.status,
primaryContact: elder.primaryContact,
phone: elder.phone,
medicalNotes: elder.medicalNotes,
};
}
function formatDateTime(value: string): string {
return new Date(value).toLocaleString("zh-CN");
}
function getRiskLabel(elder: Elder): { label: string; variant: "danger" | "warning" | "success" } {
if (elder.careLevel === "intensive") {
return { label: "红色重点", variant: "danger" };
}
if (elder.status === "pending" || elder.careLevel === "assisted") {
return { label: "黄色关注", variant: "warning" };
}
return { label: "绿色稳定", variant: "success" };
}
const statusOptions = [
{ value: ALL_STATUS, label: "全部状态" },
...Object.entries(ELDER_STATUS_LABELS).map(([value, label]) => ({ value, label })),
];
const careLevelOptions = [
{ value: ALL_CARE_LEVELS, label: "全部照护" },
...Object.entries(CARE_LEVEL_LABELS).map(([value, label]) => ({ value, label })),
];
const genderOptions = Object.entries(GENDER_LABELS).map(([value, label]) => ({ value, label }));
const formCareLevelOptions = Object.entries(CARE_LEVEL_LABELS).map(([value, label]) => ({ value, label }));
const formStatusOptions = Object.entries(ELDER_STATUS_LABELS).map(([value, label]) => ({ value, label }));
function matchesFilters(
elder: Elder,
query: string,
statusFilter: ElderStatus | typeof ALL_STATUS,
careLevelFilter: CareLevel | typeof ALL_CARE_LEVELS,
): boolean {
const normalizedQuery = query.trim().toLowerCase();
const matchesSearch =
normalizedQuery.length === 0 ||
[elder.name, elder.room, elder.bed, elder.primaryContact, elder.phone, elder.medicalNotes]
.join(" ")
.toLowerCase()
.includes(normalizedQuery);
const matchesStatus = statusFilter === ALL_STATUS || elder.status === statusFilter;
const matchesCareLevel = careLevelFilter === ALL_CARE_LEVELS || elder.careLevel === careLevelFilter;
return matchesSearch && matchesStatus && matchesCareLevel;
}
export function EldersClient({ initialElders, permissions }: EldersClientProps): React.ReactElement {
const [elders, setElders] = useState(initialElders);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<ElderInput>(emptyInput);
const [message, setMessage] = useState("");
const [isPending, setIsPending] = useState(false);
const [isFormOpen, setIsFormOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Elder | null>(null);
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<ElderStatus | typeof ALL_STATUS>(ALL_STATUS);
const [careLevelFilter, setCareLevelFilter] = useState<CareLevel | typeof ALL_CARE_LEVELS>(ALL_CARE_LEVELS);
const [page, setPage] = useState(1);
const canCreate = permissions.includes("elder:create");
const canUpdate = permissions.includes("elder:update");
const canDelete = permissions.includes("elder:delete");
const isEditing = editingId !== null;
const canSubmit = isEditing ? canUpdate : canCreate;
const metrics = useMemo(() => {
const activeCount = elders.filter((elder) => elder.status === "active").length;
const pendingCount = elders.filter((elder) => elder.status === "pending").length;
const intensiveCount = elders.filter((elder) => elder.careLevel === "intensive").length;
const warningCount = elders.filter((elder) => getRiskLabel(elder).variant !== "success").length;
return { activeCount, pendingCount, intensiveCount, warningCount };
}, [elders]);
const filteredElders = useMemo(
() => elders.filter((elder) => matchesFilters(elder, query, statusFilter, careLevelFilter)),
[careLevelFilter, elders, query, statusFilter],
);
const pageCount = Math.max(1, Math.ceil(filteredElders.length / PAGE_SIZE));
const safePage = Math.min(page, pageCount);
const pageStart = (safePage - 1) * PAGE_SIZE;
const visibleElders = filteredElders.slice(pageStart, pageStart + PAGE_SIZE);
function updateField<K extends keyof ElderInput>(key: K, value: ElderInput[K]): void {
setForm((current) => ({ ...current, [key]: value }));
}
function beginCreate(): void {
setEditingId(null);
setForm(emptyInput);
setMessage("");
setIsFormOpen(true);
}
function beginEdit(elder: Elder): void {
setEditingId(elder.id);
setForm(elderToInput(elder));
setMessage("");
setIsFormOpen(true);
}
function closeForm(): void {
if (isPending) {
return;
}
setIsFormOpen(false);
setEditingId(null);
setForm(emptyInput);
}
function updateQuery(value: string): void {
setQuery(value);
setPage(1);
}
function updateStatusFilter(value: string): void {
setStatusFilter(value as ElderStatus | typeof ALL_STATUS);
setPage(1);
}
function updateCareLevelFilter(value: string): void {
setCareLevelFilter(value as CareLevel | typeof ALL_CARE_LEVELS);
setPage(1);
}
async function refreshElders(): Promise<void> {
const response = await fetch("/api/elders");
const result = (await response.json()) as ApiResult<{ elders: Elder[] }>;
if (result.success) {
setElders(result.elders);
}
}
async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
if (!canSubmit) {
setMessage("当前角色无权保存老人档案");
return;
}
setIsPending(true);
setMessage("");
const response = await fetch(isEditing ? `/api/elders/${editingId}` : "/api/elders", {
method: isEditing ? "PATCH" : "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(form),
});
const result = (await response.json()) as ApiResult<{ elder: Elder }>;
setIsPending(false);
if (!result.success) {
setMessage(result.reason);
return;
}
await refreshElders();
setEditingId(null);
setForm(emptyInput);
setIsFormOpen(false);
setMessage(result.reason);
}
async function handleDelete(): Promise<void> {
if (!deleteTarget) {
return;
}
if (!canDelete) {
setMessage("当前角色无权删除老人档案");
return;
}
setIsPending(true);
setMessage("");
const response = await fetch(`/api/elders/${deleteTarget.id}`, { method: "DELETE" });
const result = (await response.json()) as ApiResult<{ elder: Elder }>;
setIsPending(false);
if (!result.success) {
setMessage(result.reason);
return;
}
await refreshElders();
setDeleteTarget(null);
setMessage(result.reason);
}
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<Badge variant="success"> CRUD</Badge>
<h1 className="mt-3 text-3xl font-semibold tracking-normal"></h1>
<p className="mt-2 text-sm text-muted-foreground"></p>
</div>
<div className="flex flex-wrap gap-2">
<Button disabled={!canCreate} onClick={beginCreate} type="button">
<Plus className="size-4" aria-hidden="true" />
</Button>
</div>
</section>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card>
<CardHeader className="p-4">
<CardDescription></CardDescription>
<CardTitle className="text-3xl">{metrics.activeCount}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="p-4">
<CardDescription></CardDescription>
<CardTitle className="text-3xl">{metrics.pendingCount}</CardTitle>
</CardHeader>
</Card>
<Card className="border-red-200 bg-red-50/70">
<CardHeader className="p-4">
<CardDescription className="text-red-700"></CardDescription>
<CardTitle className="text-3xl text-red-700">{metrics.intensiveCount}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="p-4">
<CardDescription></CardDescription>
<CardTitle className="text-3xl">{metrics.warningCount}</CardTitle>
</CardHeader>
</Card>
</section>
<Card>
<CardHeader className="gap-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<CardTitle></CardTitle>
<CardDescription className="mt-1">
{filteredElders.length}
</CardDescription>
</div>
<div className="grid gap-2 sm:grid-cols-[minmax(220px,1fr)_150px_150px]">
<label className="relative block">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
onChange={(event) => updateQuery(event.target.value)}
placeholder="搜索姓名、床位、联系人"
value={query}
/>
</label>
<div className="relative block">
<Filter className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Select
aria-label="按老人状态筛选"
className="w-full pl-9"
onValueChange={updateStatusFilter}
options={statusOptions}
value={statusFilter}
/>
</div>
<Select
aria-label="按照护等级筛选"
className="w-full"
onValueChange={updateCareLevelFilter}
options={careLevelOptions}
value={careLevelFilter}
/>
</div>
</div>
{message ? (
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
{message}
</p>
) : null}
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-md border">
<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 text-right font-medium"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{visibleElders.map((elder) => {
const risk = getRiskLabel(elder);
return (
<Table.Row key={elder.id}>
<Table.Cell className="px-4 py-3">
<p className="font-medium">{elder.name}</p>
<p className="text-xs text-muted-foreground">
{GENDER_LABELS[elder.gender]} · {elder.age}
</p>
</Table.Cell>
<Table.Cell className="px-4 py-3">{CARE_LEVEL_LABELS[elder.careLevel]}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">
{elder.room}-{elder.bed}
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">
{elder.primaryContact} / {elder.phone}
</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={elder.status === "active" ? "success" : "secondary"}>
{ELDER_STATUS_LABELS[elder.status]}
</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={risk.variant}>{risk.label}</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3">
<div className="flex justify-end gap-2">
<Button
disabled={!canUpdate}
onClick={() => beginEdit(elder)}
size="sm"
type="button"
variant="outline"
>
<Edit3 className="size-4" aria-hidden="true" />
</Button>
<Button
disabled={!canDelete || isPending}
onClick={() => setDeleteTarget(elder)}
size="sm"
type="button"
variant="destructive"
>
<Trash2 className="size-4" aria-hidden="true" />
</Button>
</div>
</Table.Cell>
</Table.Row>
);
})}
{visibleElders.length === 0 ? (
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={7}>
</Table.Cell>
</Table.Row>
) : null}
</Table.Body>
</Table>
</div>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
{safePage} / {pageCount}
</p>
<div className="flex gap-2">
<Button
disabled={safePage === 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
size="sm"
type="button"
variant="outline"
>
</Button>
<Button
disabled={safePage === pageCount}
onClick={() => setPage((current) => Math.min(pageCount, current + 1))}
size="sm"
type="button"
variant="outline"
>
</Button>
</div>
</div>
</CardContent>
</Card>
<Dialog
description="保存后会写入 PostgreSQL并生成审计记录。"
onClose={closeForm}
open={isFormOpen}
title={isEditing ? "编辑老人档案" : "新增老人档案"}
>
<form className="grid gap-3" id="elder-form" onSubmit={handleSubmit}>
<label className="grid gap-1.5 text-sm font-medium">
<Input value={form.name} onChange={(event) => updateField("name", event.target.value)} />
</label>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-1.5 text-sm font-medium">
<Select
aria-label="性别"
onValueChange={(value) => updateField("gender", value as ElderInput["gender"])}
options={genderOptions}
value={form.gender}
/>
</div>
<label className="grid gap-1.5 text-sm font-medium">
<Input
max={130}
min={0}
onChange={(event) => updateField("age", Number(event.target.value))}
type="number"
value={form.age}
/>
</label>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-1.5 text-sm font-medium">
<Select
aria-label="护理等级"
onValueChange={(value) => updateField("careLevel", value as ElderInput["careLevel"])}
options={formCareLevelOptions}
value={form.careLevel}
/>
</div>
<div className="grid gap-1.5 text-sm font-medium">
<Select
aria-label="状态"
onValueChange={(value) => updateField("status", value as ElderInput["status"])}
options={formStatusOptions}
value={form.status}
/>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="grid gap-1.5 text-sm font-medium">
<Input value={form.room} onChange={(event) => updateField("room", event.target.value)} />
</label>
<label className="grid gap-1.5 text-sm font-medium">
<Input value={form.bed} onChange={(event) => updateField("bed", event.target.value)} />
</label>
</div>
<label className="grid gap-1.5 text-sm font-medium">
<Input value={form.primaryContact} onChange={(event) => updateField("primaryContact", event.target.value)} />
</label>
<label className="grid gap-1.5 text-sm font-medium">
<Input value={form.phone} onChange={(event) => updateField("phone", event.target.value)} />
</label>
<label className="grid gap-1.5 text-sm font-medium">
<Textarea
className="min-h-24"
onChange={(event) => updateField("medicalNotes", event.target.value)}
value={form.medicalNotes}
/>
</label>
{message ? (
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
{message}
</p>
) : null}
<div className="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
<Button disabled={isPending} onClick={closeForm} type="button" variant="outline">
</Button>
<Button disabled={isPending || !canSubmit} type="submit">
<Plus className="size-4" aria-hidden="true" />
{isEditing ? "保存修改" : "新增老人"}
</Button>
</div>
</form>
</Dialog>
<Dialog
className="max-w-md"
description={deleteTarget ? `确认删除 ${deleteTarget.name} 的档案?该操作会写入审计日志。` : undefined}
onClose={() => setDeleteTarget(null)}
open={deleteTarget !== null}
title="删除确认"
>
<div className="flex flex-col gap-4">
{deleteTarget ? (
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{deleteTarget.name}{deleteTarget.room}-{deleteTarget.bed}
{formatDateTime(deleteTarget.updatedAt)}
</div>
) : null}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button disabled={isPending} onClick={() => setDeleteTarget(null)} type="button" variant="outline">
</Button>
<Button disabled={isPending || !canDelete} onClick={() => void handleDelete()} type="button" variant="destructive">
</Button>
</div>
</div>
</Dialog>
</div>
);
}