feat: wire auth and CRUD UI to APIs
This commit is contained in:
364
modules/elders/components/EldersClient.tsx
Normal file
364
modules/elders/components/EldersClient.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user