365 lines
14 KiB
TypeScript
365 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { FormEvent, useMemo, useState } from "react";
|
|
import { Edit3, Plus, Trash2, X } from "lucide-react";
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import type { ApiResult } from "@/modules/core/server/api";
|
|
import type { Permission } from "@/modules/core/types";
|
|
import type { Elder, ElderInput } 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: "",
|
|
};
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
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 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;
|
|
return { activeCount, pendingCount, intensiveCount };
|
|
}, [elders]);
|
|
|
|
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("");
|
|
}
|
|
|
|
function beginEdit(elder: Elder): void {
|
|
setEditingId(elder.id);
|
|
setForm(elderToInput(elder));
|
|
setMessage("");
|
|
}
|
|
|
|
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);
|
|
setMessage(result.reason);
|
|
}
|
|
|
|
async function handleDelete(elder: Elder): Promise<void> {
|
|
if (!canDelete) {
|
|
setMessage("当前角色无权删除老人档案");
|
|
return;
|
|
}
|
|
|
|
setIsPending(true);
|
|
setMessage("");
|
|
|
|
const response = await fetch(`/api/elders/${elder.id}`, { method: "DELETE" });
|
|
const result = (await response.json()) as ApiResult<{ elder: Elder }>;
|
|
setIsPending(false);
|
|
|
|
if (!result.success) {
|
|
setMessage(result.reason);
|
|
return;
|
|
}
|
|
|
|
await refreshElders();
|
|
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="grid gap-2 text-sm sm:grid-cols-3">
|
|
<div className="rounded-md border bg-white/72 px-4 py-3">
|
|
<p className="text-muted-foreground">在院老人</p>
|
|
<p className="mt-1 text-2xl font-semibold">{metrics.activeCount}</p>
|
|
</div>
|
|
<div className="rounded-md border bg-white/72 px-4 py-3">
|
|
<p className="text-muted-foreground">待入住</p>
|
|
<p className="mt-1 text-2xl font-semibold">{metrics.pendingCount}</p>
|
|
</div>
|
|
<div className="rounded-md border bg-white/72 px-4 py-3">
|
|
<p className="text-muted-foreground">重点照护</p>
|
|
<p className="mt-1 text-2xl font-semibold">{metrics.intensiveCount}</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="grid gap-4 xl:grid-cols-[0.82fr_1.18fr]">
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<CardTitle>{isEditing ? "编辑档案" : "新增档案"}</CardTitle>
|
|
{isEditing ? (
|
|
<Button type="button" variant="ghost" size="sm" onClick={beginCreate}>
|
|
<X className="size-4" aria-hidden="true" />
|
|
取消
|
|
</Button>
|
|
) : null}
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form className="grid gap-3" 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">
|
|
<label className="grid gap-1.5 text-sm font-medium">
|
|
性别
|
|
<select
|
|
className="h-11 rounded-md border bg-background px-3 text-sm"
|
|
value={form.gender}
|
|
onChange={(event) => updateField("gender", event.target.value as ElderInput["gender"])}
|
|
>
|
|
{Object.entries(GENDER_LABELS).map(([value, label]) => (
|
|
<option key={value} value={value}>
|
|
{label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="grid gap-1.5 text-sm font-medium">
|
|
年龄
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
max={130}
|
|
value={form.age}
|
|
onChange={(event) => updateField("age", Number(event.target.value))}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<label className="grid gap-1.5 text-sm font-medium">
|
|
护理等级
|
|
<select
|
|
className="h-11 rounded-md border bg-background px-3 text-sm"
|
|
value={form.careLevel}
|
|
onChange={(event) => updateField("careLevel", event.target.value as ElderInput["careLevel"])}
|
|
>
|
|
{Object.entries(CARE_LEVEL_LABELS).map(([value, label]) => (
|
|
<option key={value} value={value}>
|
|
{label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="grid gap-1.5 text-sm font-medium">
|
|
状态
|
|
<select
|
|
className="h-11 rounded-md border bg-background px-3 text-sm"
|
|
value={form.status}
|
|
onChange={(event) => updateField("status", event.target.value as ElderInput["status"])}
|
|
>
|
|
{Object.entries(ELDER_STATUS_LABELS).map(([value, label]) => (
|
|
<option key={value} value={value}>
|
|
{label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</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 rounded-md border bg-background px-3 py-2 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
|
|
value={form.medicalNotes}
|
|
onChange={(event) => updateField("medicalNotes", event.target.value)}
|
|
/>
|
|
</label>
|
|
|
|
{message ? (
|
|
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
|
|
{message}
|
|
</p>
|
|
) : null}
|
|
|
|
<Button type="submit" disabled={isPending || !canSubmit}>
|
|
<Plus className="size-4" aria-hidden="true" />
|
|
{isEditing ? "保存修改" : "新增老人"}
|
|
</Button>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>档案列表</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="overflow-x-auto rounded-md border">
|
|
<table className="w-full min-w-[820px] text-sm">
|
|
<thead className="bg-secondary text-left text-xs text-muted-foreground">
|
|
<tr>
|
|
<th className="px-4 py-3 font-medium">姓名</th>
|
|
<th className="px-4 py-3 font-medium">照护</th>
|
|
<th className="px-4 py-3 font-medium">床位</th>
|
|
<th className="px-4 py-3 font-medium">联系人</th>
|
|
<th className="px-4 py-3 font-medium">状态</th>
|
|
<th className="px-4 py-3 text-right font-medium">操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y bg-card">
|
|
{elders.map((elder) => (
|
|
<tr key={elder.id}>
|
|
<td 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>
|
|
</td>
|
|
<td className="px-4 py-3">{CARE_LEVEL_LABELS[elder.careLevel]}</td>
|
|
<td className="px-4 py-3 text-muted-foreground">
|
|
{elder.room}-{elder.bed}
|
|
</td>
|
|
<td className="px-4 py-3 text-muted-foreground">
|
|
{elder.primaryContact} / {elder.phone}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<Badge variant={elder.status === "active" ? "success" : "secondary"}>
|
|
{ELDER_STATUS_LABELS[elder.status]}
|
|
</Badge>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => beginEdit(elder)}
|
|
disabled={!canUpdate}
|
|
>
|
|
<Edit3 className="size-4" aria-hidden="true" />
|
|
编辑
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => void handleDelete(elder)}
|
|
disabled={!canDelete || isPending}
|
|
>
|
|
<Trash2 className="size-4" aria-hidden="true" />
|
|
删除
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{elders.length === 0 ? (
|
|
<tr>
|
|
<td className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
|
|
暂无老人档案
|
|
</td>
|
|
</tr>
|
|
) : null}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|