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

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>
);
}