Files
teatea-pension/modules/facilities/components/BedsWorkspace.tsx

1220 lines
44 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 { BedDouble, DoorOpen, History, MoveRight, UserCheck, UserMinus, Wrench } 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 { Textarea } from "@/components/ui/input";
import { Select, type SelectOption } from "@/components/ui/select";
import { Table } from "@/components/ui/table";
import { cn } from "@/lib/utils";
import type { ApiResult } from "@/modules/core/server/api";
import type { Admission, BedStatus, FacilityBed, Permission, Room } from "@/modules/core/types";
import type { Elder } from "@/modules/elders/types";
import type { BedOperationalContext, OperationalContextLine } from "@/modules/operations/types";
type BedsWorkspaceProps = {
bedContexts?: Record<string, BedOperationalContext>;
initialRooms: Room[];
initialBeds: FacilityBed[];
initialAdmissions: Admission[];
initialElders: Elder[];
permissions: Permission[];
};
type WorkspaceTab = "overview" | "operations" | "history";
type BedDetailTab = "context" | "records" | "actions";
type RoomView = {
room: Room;
beds: FacilityBed[];
};
const workspaceTabs: Array<{ value: WorkspaceTab; label: string }> = [
{ value: "overview", label: "概览" },
{ value: "operations", label: "办理" },
{ value: "history", label: "记录" },
];
const bedDetailTabs: Array<{ value: BedDetailTab; label: string }> = [
{ value: "context", label: "上下文" },
{ value: "records", label: "流水记录" },
{ value: "actions", label: "专项办理" },
];
const bedStatusLabels: Record<BedStatus, string> = {
available: "空闲",
occupied: "占用",
maintenance: "维修",
disabled: "停用",
};
const admissionStatusLabels: Record<Admission["status"], string> = {
active: "在住",
transferred: "已换床",
discharged: "已退住",
};
const contextKindLabels: Record<OperationalContextLine["kind"], string> = {
admission: "入住",
care: "照护",
health: "健康",
incident: "预警",
};
function formatDateTime(value: string): string {
return new Date(value).toLocaleString("zh-CN");
}
function bedBadgeVariant(status: BedStatus): "success" | "secondary" | "warning" | "danger" {
switch (status) {
case "available":
return "success";
case "occupied":
return "secondary";
case "maintenance":
return "warning";
case "disabled":
return "danger";
}
}
function admissionBedLabel(admission: Admission): string {
return `${admission.roomName}-${admission.bedCode}`;
}
function buildRoomViews(rooms: Room[], beds: FacilityBed[]): RoomView[] {
const bedsByRoomId = new Map<string, FacilityBed[]>();
for (const bed of beds) {
const roomBeds = bedsByRoomId.get(bed.roomId) ?? [];
roomBeds.push(bed);
bedsByRoomId.set(bed.roomId, roomBeds);
}
return [...rooms]
.sort((first, second) => first.code.localeCompare(second.code, "zh-CN", { numeric: true }))
.map((room) => ({
room,
beds: (bedsByRoomId.get(room.id) ?? []).sort((first, second) =>
first.code.localeCompare(second.code, "zh-CN", { numeric: true }),
),
}));
}
function makeSelectOptions(items: Array<{ id: string; label: string }>, emptyLabel: string): SelectOption[] {
return [
{ value: "", label: emptyLabel, disabled: true },
...items.map((item) => ({ value: item.id, label: item.label })),
];
}
function bedTileClass(status: BedStatus): string {
switch (status) {
case "available":
return "border-emerald-200 bg-emerald-50 text-emerald-900 hover:border-emerald-400";
case "occupied":
return "border-zinc-200 bg-zinc-50 text-zinc-900 hover:border-zinc-400";
case "maintenance":
return "border-amber-200 bg-amber-50 text-amber-950 hover:border-amber-400";
case "disabled":
return "border-rose-200 bg-rose-50 text-rose-950 hover:border-rose-400";
}
}
function statusDotClass(status: BedStatus): string {
switch (status) {
case "available":
return "bg-emerald-500";
case "occupied":
return "bg-zinc-500";
case "maintenance":
return "bg-amber-500";
case "disabled":
return "bg-rose-500";
}
}
export function BedsWorkspace({
bedContexts = {},
initialRooms,
initialBeds,
initialAdmissions,
initialElders,
permissions,
}: BedsWorkspaceProps): React.ReactElement {
const [rooms, setRooms] = useState(initialRooms);
const [beds, setBeds] = useState(initialBeds);
const [admissions, setAdmissions] = useState(initialAdmissions);
const [elders, setElders] = useState(initialElders);
const [activeTab, setActiveTab] = useState<WorkspaceTab>("overview");
const [message, setMessage] = useState("");
const [isPending, setIsPending] = useState(false);
const [admitElderId, setAdmitElderId] = useState("");
const [admitBedId, setAdmitBedId] = useState("");
const [admitNotes, setAdmitNotes] = useState("");
const [transferAdmissionId, setTransferAdmissionId] = useState("");
const [transferBedId, setTransferBedId] = useState("");
const [transferNotes, setTransferNotes] = useState("");
const [dischargeAdmissionId, setDischargeAdmissionId] = useState("");
const [dischargeNotes, setDischargeNotes] = useState("");
const [selectedBedId, setSelectedBedId] = useState("");
const [bedDetailTab, setBedDetailTab] = useState<BedDetailTab>("context");
const canManageAdmissions = permissions.includes("admission:manage");
const activeAdmissions = useMemo(() => admissions.filter((admission) => admission.status === "active"), [admissions]);
const activeElderIds = useMemo(() => new Set(activeAdmissions.map((admission) => admission.elderId)), [activeAdmissions]);
const availableBeds = useMemo(() => beds.filter((bed) => bed.status === "available"), [beds]);
const occupiedBeds = beds.filter((bed) => bed.status === "occupied").length;
const maintenanceBeds = beds.filter((bed) => bed.status === "maintenance").length;
const occupancyRate = beds.length > 0 ? Math.round((occupiedBeds / beds.length) * 100) : 0;
const admissionEligibleElders = elders.filter((elder) => !activeElderIds.has(elder.id));
const roomViews = useMemo(() => buildRoomViews(rooms, beds), [rooms, beds]);
const selectedBed = useMemo(() => beds.find((bed) => bed.id === selectedBedId), [beds, selectedBedId]);
const selectedAdmission = selectedBed
? activeAdmissions.find((admission) => admission.bedId === selectedBed.id)
: undefined;
const selectedBedAdmissions = selectedBed
? admissions.filter((admission) => admission.bedId === selectedBed.id)
: [];
const selectedBedContext = selectedBed ? bedContexts[selectedBed.id] : undefined;
const elderOptions = makeSelectOptions(
admissionEligibleElders.map((elder) => ({ id: elder.id, label: `${elder.name} / ${elder.phone}` })),
"选择老人",
);
const bedOptions = makeSelectOptions(
availableBeds.map((bed) => ({ id: bed.id, label: `${bed.roomName}-${bed.code}` })),
"选择空闲床位",
);
const activeAdmissionOptions = makeSelectOptions(
activeAdmissions.map((admission) => ({
id: admission.id,
label: `${admission.elderName} / ${admissionBedLabel(admission)}`,
})),
"选择在住记录",
);
function openBedDetail(bed: FacilityBed, initialTab: BedDetailTab = "context"): void {
setSelectedBedId(bed.id);
setBedDetailTab(initialTab);
}
function startAdmissionFromBed(bed: FacilityBed): void {
setAdmitBedId(bed.id);
setSelectedBedId(bed.id);
setBedDetailTab("actions");
}
async function refreshData(): Promise<void> {
const [eldersResponse, roomsResponse, bedsResponse, admissionsResponse] = await Promise.all([
fetch("/api/elders"),
fetch("/api/facilities/rooms"),
fetch("/api/facilities/beds"),
fetch("/api/admissions"),
]);
const [eldersResult, roomsResult, bedsResult, admissionsResult] = (await Promise.all([
eldersResponse.json(),
roomsResponse.json(),
bedsResponse.json(),
admissionsResponse.json(),
])) as [
ApiResult<{ elders: Elder[] }>,
ApiResult<{ rooms: Room[] }>,
ApiResult<{ beds: FacilityBed[] }>,
ApiResult<{ admissions: Admission[] }>,
];
if (eldersResult.success) {
setElders(eldersResult.elders);
}
if (roomsResult.success) {
setRooms(roomsResult.rooms);
}
if (bedsResult.success) {
setBeds(bedsResult.beds);
}
if (admissionsResult.success) {
setAdmissions(admissionsResult.admissions);
}
}
async function submitAdmission(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
if (!canManageAdmissions) {
setMessage("当前角色无权办理入住");
return;
}
if (!admitElderId || !admitBedId) {
setMessage("请选择老人和空闲床位");
return;
}
setIsPending(true);
setMessage("");
const response = await fetch("/api/admissions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ elderId: admitElderId, bedId: admitBedId, notes: admitNotes }),
});
const result = (await response.json()) as ApiResult<{ admission: Admission }>;
setIsPending(false);
if (!result.success) {
setMessage(result.reason);
return;
}
setAdmitElderId("");
setAdmitBedId("");
setAdmitNotes("");
await refreshData();
setMessage(result.reason);
}
async function submitAdmissionForBed(event: FormEvent<HTMLFormElement>, bed: FacilityBed): Promise<void> {
event.preventDefault();
if (!canManageAdmissions) {
setMessage("当前角色无权办理入住");
return;
}
if (bed.status !== "available") {
setMessage("当前床位不可办理入住");
return;
}
if (!admitElderId) {
setMessage("请选择老人");
return;
}
setIsPending(true);
setMessage("");
const response = await fetch("/api/admissions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ elderId: admitElderId, bedId: bed.id, notes: admitNotes }),
});
const result = (await response.json()) as ApiResult<{ admission: Admission }>;
setIsPending(false);
if (!result.success) {
setMessage(result.reason);
return;
}
setAdmitElderId("");
setAdmitBedId("");
setAdmitNotes("");
await refreshData();
setMessage(result.reason);
}
async function submitTransfer(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
const admission = activeAdmissions.find((item) => item.id === transferAdmissionId);
if (!canManageAdmissions) {
setMessage("当前角色无权办理换床");
return;
}
if (!admission || !transferBedId) {
setMessage("请选择在住记录和新床位");
return;
}
setIsPending(true);
setMessage("");
const response = await fetch("/api/admissions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ elderId: admission.elderId, bedId: transferBedId, notes: transferNotes || "办理换床" }),
});
const result = (await response.json()) as ApiResult<{ admission: Admission }>;
setIsPending(false);
if (!result.success) {
setMessage(result.reason);
return;
}
setTransferAdmissionId("");
setTransferBedId("");
setTransferNotes("");
await refreshData();
setMessage("换床已办理");
}
async function submitTransferForAdmission(event: FormEvent<HTMLFormElement>, admission: Admission): Promise<void> {
event.preventDefault();
if (!canManageAdmissions) {
setMessage("当前角色无权办理换床");
return;
}
if (!transferBedId) {
setMessage("请选择新床位");
return;
}
setIsPending(true);
setMessage("");
const response = await fetch("/api/admissions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ elderId: admission.elderId, bedId: transferBedId, notes: transferNotes || "办理换床" }),
});
const result = (await response.json()) as ApiResult<{ admission: Admission }>;
setIsPending(false);
if (!result.success) {
setMessage(result.reason);
return;
}
setTransferAdmissionId("");
setTransferBedId("");
setTransferNotes("");
await refreshData();
setMessage("换床已办理");
}
async function submitDischarge(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
if (!canManageAdmissions) {
setMessage("当前角色无权办理退住");
return;
}
if (!dischargeAdmissionId) {
setMessage("请选择在住记录");
return;
}
setIsPending(true);
setMessage("");
const response = await fetch(`/api/admissions/${dischargeAdmissionId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ notes: dischargeNotes }),
});
const result = (await response.json()) as ApiResult<{ admission: Admission }>;
setIsPending(false);
if (!result.success) {
setMessage(result.reason);
return;
}
setDischargeAdmissionId("");
setDischargeNotes("");
await refreshData();
setMessage(result.reason);
}
async function submitDischargeForAdmission(event: FormEvent<HTMLFormElement>, admission: Admission): Promise<void> {
event.preventDefault();
if (!canManageAdmissions) {
setMessage("当前角色无权办理退住");
return;
}
setIsPending(true);
setMessage("");
const response = await fetch(`/api/admissions/${admission.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ notes: dischargeNotes }),
});
const result = (await response.json()) as ApiResult<{ admission: Admission }>;
setIsPending(false);
if (!result.success) {
setMessage(result.reason);
return;
}
setDischargeAdmissionId("");
setDischargeNotes("");
await refreshData();
setMessage(result.reason);
}
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<section className="flex border-b pb-4">
<div className="flex flex-wrap gap-2" role="tablist" aria-label="床位工作区">
{workspaceTabs.map((tab) => (
<Button
aria-selected={activeTab === tab.value}
key={tab.value}
onClick={() => setActiveTab(tab.value)}
role="tab"
type="button"
variant={activeTab === tab.value ? "default" : "outline"}
>
{tab.label}
</Button>
))}
</div>
</section>
{message ? (
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
{message}
</p>
) : null}
{activeTab === "overview" ? (
<>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardDescription></CardDescription>
<CardTitle className="mt-2 text-3xl">{rooms.length}</CardTitle>
</div>
<DoorOpen className="size-5 text-primary" aria-hidden="true" />
</CardHeader>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardDescription></CardDescription>
<CardTitle className="mt-2 text-3xl">{beds.length}</CardTitle>
</div>
<BedDouble className="size-5 text-primary" aria-hidden="true" />
</CardHeader>
<CardContent className="pt-0">
<p className="text-sm text-muted-foreground"> {occupancyRate}%</p>
</CardContent>
</Card>
<Card className={maintenanceBeds > 0 ? "border-amber-200 bg-amber-50/70" : undefined}>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardDescription className={maintenanceBeds > 0 ? "text-amber-700" : undefined}></CardDescription>
<CardTitle className={maintenanceBeds > 0 ? "mt-2 text-3xl text-amber-700" : "mt-2 text-3xl"}>
{maintenanceBeds}
</CardTitle>
</div>
<Wrench className={maintenanceBeds > 0 ? "size-5 text-amber-700" : "size-5 text-primary"} aria-hidden="true" />
</CardHeader>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardDescription></CardDescription>
<CardTitle className="mt-2 text-3xl">{activeAdmissions.length}</CardTitle>
</div>
<History className="size-5 text-primary" aria-hidden="true" />
</CardHeader>
</Card>
</section>
<section className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_20rem]">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
<StatusLegend />
{roomViews.length > 0 ? (
<div className="grid gap-4 lg:grid-cols-2">
{roomViews.map((view) => (
<RoomGrid
bedContexts={bedContexts}
key={view.room.id}
onSelectBed={openBedDetail}
onStartAdmission={startAdmissionFromBed}
roomView={view}
/>
))}
</div>
) : (
<p className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground"></p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<form className="grid gap-3" onSubmit={(event) => void submitAdmission(event)}>
<Select
aria-label="入住老人"
disabled={!canManageAdmissions || isPending}
onValueChange={setAdmitElderId}
options={elderOptions}
value={admitElderId}
/>
<Select
aria-label="入住房间床位"
disabled={!canManageAdmissions || isPending}
onValueChange={setAdmitBedId}
options={bedOptions}
value={admitBedId}
/>
<Textarea
aria-label="入住备注"
disabled={!canManageAdmissions || isPending}
onChange={(event) => setAdmitNotes(event.target.value)}
placeholder="备注"
value={admitNotes}
/>
<Button disabled={!canManageAdmissions || isPending} type="submit">
<UserCheck className="size-4" aria-hidden="true" />
</Button>
</form>
</CardContent>
</Card>
</section>
</>
) : null}
{activeTab === "operations" ? (
<section className="grid gap-4 xl:grid-cols-3">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserCheck className="size-5 text-primary" aria-hidden="true" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<form className="grid gap-3" onSubmit={(event) => void submitAdmission(event)}>
<Select
aria-label="入住老人"
disabled={!canManageAdmissions || isPending}
onValueChange={setAdmitElderId}
options={elderOptions}
value={admitElderId}
/>
<Select
aria-label="入住房间床位"
disabled={!canManageAdmissions || isPending}
onValueChange={setAdmitBedId}
options={bedOptions}
value={admitBedId}
/>
<Textarea
aria-label="入住备注"
disabled={!canManageAdmissions || isPending}
onChange={(event) => setAdmitNotes(event.target.value)}
placeholder="备注"
value={admitNotes}
/>
<Button disabled={!canManageAdmissions || isPending} type="submit">
<UserCheck className="size-4" aria-hidden="true" />
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MoveRight className="size-5 text-primary" aria-hidden="true" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<form className="grid gap-3" onSubmit={(event) => void submitTransfer(event)}>
<Select
aria-label="换床入住记录"
disabled={!canManageAdmissions || isPending}
onValueChange={setTransferAdmissionId}
options={activeAdmissionOptions}
value={transferAdmissionId}
/>
<Select
aria-label="换床新床位"
disabled={!canManageAdmissions || isPending}
onValueChange={setTransferBedId}
options={bedOptions}
value={transferBedId}
/>
<Textarea
aria-label="换床备注"
disabled={!canManageAdmissions || isPending}
onChange={(event) => setTransferNotes(event.target.value)}
placeholder="备注"
value={transferNotes}
/>
<Button disabled={!canManageAdmissions || isPending} type="submit" variant="outline">
<MoveRight className="size-4" aria-hidden="true" />
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserMinus className="size-5 text-primary" aria-hidden="true" />
退
</CardTitle>
<CardDescription>退</CardDescription>
</CardHeader>
<CardContent>
<form className="grid gap-3" onSubmit={(event) => void submitDischarge(event)}>
<Select
aria-label="退住入住记录"
disabled={!canManageAdmissions || isPending}
onValueChange={setDischargeAdmissionId}
options={activeAdmissionOptions}
value={dischargeAdmissionId}
/>
<Textarea
aria-label="退住备注"
disabled={!canManageAdmissions || isPending}
onChange={(event) => setDischargeNotes(event.target.value)}
placeholder="备注"
value={dischargeNotes}
/>
<Button disabled={!canManageAdmissions || isPending} type="submit" variant="destructive">
<UserMinus className="size-4" aria-hidden="true" />
退
</Button>
</form>
</CardContent>
</Card>
</section>
) : null}
{activeTab === "history" ? (
<Card>
<CardHeader>
<CardTitle>退</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-md border">
<Table className="w-full min-w-[820px] 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.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{admissions.map((admission) => (
<Table.Row key={admission.id}>
<Table.Cell className="px-4 py-3 font-medium">{admission.elderName}</Table.Cell>
<Table.Cell className="px-4 py-3">{admissionBedLabel(admission)}</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={admission.status === "active" ? "success" : "secondary"}>
{admissionStatusLabels[admission.status]}
</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDateTime(admission.admittedAt)}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">
{admission.dischargedAt ? formatDateTime(admission.dischargedAt) : "-"}
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{admission.notes || "-"}</Table.Cell>
</Table.Row>
))}
{admissions.length === 0 ? (
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
</Table.Cell>
</Table.Row>
) : null}
</Table.Body>
</Table>
</div>
</CardContent>
</Card>
) : null}
<BedDetailDialog
activeAdmission={selectedAdmission}
activeTab={bedDetailTab}
admissions={selectedBedAdmissions}
bed={selectedBed}
bedOptions={bedOptions}
canManageAdmissions={canManageAdmissions}
context={selectedBedContext}
dischargeNotes={dischargeNotes}
elderOptions={elderOptions}
isPending={isPending}
notes={admitNotes}
onClose={() => setSelectedBedId("")}
onDischargeNotesChange={setDischargeNotes}
onElderChange={setAdmitElderId}
onNotesChange={setAdmitNotes}
onSubmitAdmission={submitAdmissionForBed}
onSubmitDischarge={submitDischargeForAdmission}
onSubmitTransfer={submitTransferForAdmission}
onTabChange={setBedDetailTab}
onTransferBedChange={setTransferBedId}
onTransferNotesChange={setTransferNotes}
selectedElderId={admitElderId}
selectedTransferBedId={transferBedId}
transferNotes={transferNotes}
/>
</div>
);
}
function StatusLegend(): React.ReactElement {
return (
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground" aria-label="床位状态图例">
{(["available", "occupied", "maintenance", "disabled"] as BedStatus[]).map((status) => (
<span className="inline-flex items-center gap-2" key={status}>
<span className={cn("size-2 rounded-full", statusDotClass(status))} aria-hidden="true" />
{bedStatusLabels[status]}
</span>
))}
</div>
);
}
function RoomGrid({
bedContexts,
onSelectBed,
onStartAdmission,
roomView,
}: {
bedContexts: Record<string, BedOperationalContext>;
onSelectBed: (bed: FacilityBed, initialTab?: BedDetailTab) => void;
onStartAdmission: (bed: FacilityBed) => void;
roomView: RoomView;
}): React.ReactElement {
const availableCount = roomView.beds.filter((bed) => bed.status === "available").length;
const maintenanceCount = roomView.beds.filter((bed) => bed.status === "maintenance").length;
return (
<article className="rounded-lg border border-kumo-line bg-kumo-base p-4">
<header className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="truncate text-sm font-semibold text-kumo-strong">{roomView.room.name}</h3>
<p className="mt-1 text-xs text-muted-foreground">
{roomView.room.code} · {roomView.room.occupiedBedCount}/{roomView.room.bedCount} · {roomView.room.capacity}
</p>
</div>
<Badge variant={roomView.room.status === "active" ? "success" : "danger"}>
{roomView.room.status === "active" ? "启用" : "停用"}
</Badge>
</header>
<div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3">
{roomView.beds.map((bed) => (
<BedTile
bed={bed}
context={bedContexts[bed.id]}
key={bed.id}
onSelect={() => onSelectBed(bed)}
onStartAdmission={() => onStartAdmission(bed)}
/>
))}
{roomView.beds.length === 0 ? (
<p className="col-span-full rounded-md border border-dashed px-3 py-5 text-center text-xs text-muted-foreground"></p>
) : null}
</div>
<footer className="mt-4 flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>{availableCount} </span>
<span>{maintenanceCount} </span>
</footer>
</article>
);
}
function BedTile({
bed,
context,
onSelect,
onStartAdmission,
}: {
bed: FacilityBed;
context: BedOperationalContext | undefined;
onSelect: () => void;
onStartAdmission: () => void;
}): React.ReactElement {
const occupantName = context?.occupant?.elderName ?? bed.currentElderName;
const bedActionLabel = bed.status === "available" ? "可办理入住" : bedStatusLabels[bed.status];
return (
<button
className={cn(
"grid min-h-28 content-between rounded-md border p-3 text-left transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary",
bedTileClass(bed.status),
)}
onClick={bed.status === "available" ? onStartAdmission : onSelect}
type="button"
>
<span className="flex items-start justify-between gap-2">
<span className="min-w-0">
<span className="block truncate text-sm font-semibold">{bed.code}</span>
<span className="mt-1 block text-xs opacity-75">{bedStatusLabels[bed.status]}</span>
</span>
<span className={cn("mt-1 size-2 shrink-0 rounded-full", statusDotClass(bed.status))} aria-hidden="true" />
</span>
<span className="min-w-0">
<span className="block truncate text-xs font-medium">{occupantName ?? bedActionLabel}</span>
<span className="mt-1 block truncate text-[11px] opacity-75">{bed.notes || "无备注"}</span>
</span>
</button>
);
}
function BedDetailDialog({
activeAdmission,
activeTab,
admissions,
bed,
bedOptions,
canManageAdmissions,
context,
dischargeNotes,
elderOptions,
isPending,
notes,
onClose,
onDischargeNotesChange,
onElderChange,
onNotesChange,
onSubmitAdmission,
onSubmitDischarge,
onSubmitTransfer,
onTabChange,
onTransferBedChange,
onTransferNotesChange,
selectedElderId,
selectedTransferBedId,
transferNotes,
}: {
activeAdmission: Admission | undefined;
activeTab: BedDetailTab;
admissions: Admission[];
bed: FacilityBed | undefined;
bedOptions: SelectOption[];
canManageAdmissions: boolean;
context: BedOperationalContext | undefined;
dischargeNotes: string;
elderOptions: SelectOption[];
isPending: boolean;
notes: string;
onClose: () => void;
onDischargeNotesChange: (value: string) => void;
onElderChange: (value: string) => void;
onNotesChange: (value: string) => void;
onSubmitAdmission: (event: FormEvent<HTMLFormElement>, bed: FacilityBed) => Promise<void>;
onSubmitDischarge: (event: FormEvent<HTMLFormElement>, admission: Admission) => Promise<void>;
onSubmitTransfer: (event: FormEvent<HTMLFormElement>, admission: Admission) => Promise<void>;
onTabChange: (value: BedDetailTab) => void;
onTransferBedChange: (value: string) => void;
onTransferNotesChange: (value: string) => void;
selectedElderId: string;
selectedTransferBedId: string;
transferNotes: string;
}): React.ReactElement {
const title = bed ? `${bed.roomName} · ${bed.code}` : "床位详情";
const description = bed ? `${bedStatusLabels[bed.status]} · ${bed.notes || "无备注"}` : undefined;
return (
<Dialog description={description} onClose={onClose} open={Boolean(bed)} title={title} width="wide">
{bed ? (
<div className="grid gap-5">
<div className="flex flex-wrap gap-2" role="tablist" aria-label="床位详情">
{bedDetailTabs.map((tab) => (
<Button
aria-selected={activeTab === tab.value}
key={tab.value}
onClick={() => onTabChange(tab.value)}
role="tab"
size="sm"
type="button"
variant={activeTab === tab.value ? "default" : "outline"}
>
{tab.label}
</Button>
))}
</div>
{activeTab === "context" ? <BedContextPanel bed={bed} context={context} /> : null}
{activeTab === "records" ? <AdmissionRecords admissions={admissions} /> : null}
{activeTab === "actions" ? (
<BedActionsPanel
activeAdmission={activeAdmission}
bed={bed}
bedOptions={bedOptions}
canManageAdmissions={canManageAdmissions}
dischargeNotes={dischargeNotes}
elderOptions={elderOptions}
isPending={isPending}
notes={notes}
onDischargeNotesChange={onDischargeNotesChange}
onElderChange={onElderChange}
onNotesChange={onNotesChange}
onSubmitAdmission={onSubmitAdmission}
onSubmitDischarge={onSubmitDischarge}
onSubmitTransfer={onSubmitTransfer}
onTransferBedChange={onTransferBedChange}
onTransferNotesChange={onTransferNotesChange}
selectedElderId={selectedElderId}
selectedTransferBedId={selectedTransferBedId}
transferNotes={transferNotes}
/>
) : null}
</div>
) : null}
</Dialog>
);
}
function BedContextPanel({
bed,
context,
}: {
bed: FacilityBed;
context: BedOperationalContext | undefined;
}): React.ReactElement {
return (
<div className="grid gap-4 lg:grid-cols-[18rem_minmax(0,1fr)]">
<section className="rounded-lg border border-kumo-line p-4">
<p className="text-xs text-muted-foreground"></p>
<div className="mt-3">
<OccupantSummary context={context} fallbackName={bed.currentElderName} />
</div>
<dl className="mt-4 grid gap-3 text-sm">
<div>
<dt className="text-xs text-muted-foreground"></dt>
<dd className="mt-1">
<Badge variant={bedBadgeVariant(bed.status)}>{bedStatusLabels[bed.status]}</Badge>
</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground"></dt>
<dd className="mt-1 text-kumo-strong">{bed.notes || "-"}</dd>
</div>
</dl>
</section>
<section className="rounded-lg border border-kumo-line p-4">
<h3 className="text-sm font-semibold text-kumo-strong"></h3>
<div className="mt-3">
<ContextLines lines={context?.lines ?? []} expanded />
</div>
</section>
</div>
);
}
function AdmissionRecords({ admissions }: { admissions: Admission[] }): React.ReactElement {
return (
<div className="overflow-x-auto rounded-md border">
<Table className="w-full min-w-[720px] 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.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{admissions.map((admission) => (
<Table.Row key={admission.id}>
<Table.Cell className="px-4 py-3 font-medium">{admission.elderName}</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={admission.status === "active" ? "success" : "secondary"}>{admissionStatusLabels[admission.status]}</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDateTime(admission.admittedAt)}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">
{admission.dischargedAt ? formatDateTime(admission.dischargedAt) : "-"}
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{admission.notes || "-"}</Table.Cell>
</Table.Row>
))}
{admissions.length === 0 ? (
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={5}>
</Table.Cell>
</Table.Row>
) : null}
</Table.Body>
</Table>
</div>
);
}
function BedActionsPanel({
activeAdmission,
bed,
bedOptions,
canManageAdmissions,
dischargeNotes,
elderOptions,
isPending,
notes,
onDischargeNotesChange,
onElderChange,
onNotesChange,
onSubmitAdmission,
onSubmitDischarge,
onSubmitTransfer,
onTransferBedChange,
onTransferNotesChange,
selectedElderId,
selectedTransferBedId,
transferNotes,
}: {
activeAdmission: Admission | undefined;
bed: FacilityBed;
bedOptions: SelectOption[];
canManageAdmissions: boolean;
dischargeNotes: string;
elderOptions: SelectOption[];
isPending: boolean;
notes: string;
onDischargeNotesChange: (value: string) => void;
onElderChange: (value: string) => void;
onNotesChange: (value: string) => void;
onSubmitAdmission: (event: FormEvent<HTMLFormElement>, bed: FacilityBed) => Promise<void>;
onSubmitDischarge: (event: FormEvent<HTMLFormElement>, admission: Admission) => Promise<void>;
onSubmitTransfer: (event: FormEvent<HTMLFormElement>, admission: Admission) => Promise<void>;
onTransferBedChange: (value: string) => void;
onTransferNotesChange: (value: string) => void;
selectedElderId: string;
selectedTransferBedId: string;
transferNotes: string;
}): React.ReactElement {
if (bed.status === "available") {
return (
<form className="grid gap-3 rounded-lg border border-kumo-line p-4" onSubmit={(event) => void onSubmitAdmission(event, bed)}>
<h3 className="text-sm font-semibold text-kumo-strong"></h3>
<Select
aria-label="入住老人"
disabled={!canManageAdmissions || isPending}
onValueChange={onElderChange}
options={elderOptions}
value={selectedElderId}
/>
<Textarea
aria-label="入住备注"
disabled={!canManageAdmissions || isPending}
onChange={(event) => onNotesChange(event.target.value)}
placeholder="备注"
value={notes}
/>
<Button disabled={!canManageAdmissions || isPending} type="submit">
<UserCheck className="size-4" aria-hidden="true" />
</Button>
</form>
);
}
if (!activeAdmission) {
return <p className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground"></p>;
}
return (
<div className="grid gap-4 lg:grid-cols-2">
<form className="grid gap-3 rounded-lg border border-kumo-line p-4" onSubmit={(event) => void onSubmitTransfer(event, activeAdmission)}>
<h3 className="text-sm font-semibold text-kumo-strong"></h3>
<p className="text-xs text-muted-foreground">
{activeAdmission.elderName} / {admissionBedLabel(activeAdmission)}
</p>
<Select
aria-label="换床新床位"
disabled={!canManageAdmissions || isPending}
onValueChange={onTransferBedChange}
options={bedOptions}
value={selectedTransferBedId}
/>
<Textarea
aria-label="换床备注"
disabled={!canManageAdmissions || isPending}
onChange={(event) => onTransferNotesChange(event.target.value)}
placeholder="备注"
value={transferNotes}
/>
<Button disabled={!canManageAdmissions || isPending} type="submit" variant="outline">
<MoveRight className="size-4" aria-hidden="true" />
</Button>
</form>
<form className="grid gap-3 rounded-lg border border-kumo-line p-4" onSubmit={(event) => void onSubmitDischarge(event, activeAdmission)}>
<h3 className="text-sm font-semibold text-kumo-strong">退</h3>
<p className="text-xs text-muted-foreground">退</p>
<Textarea
aria-label="退住备注"
disabled={!canManageAdmissions || isPending}
onChange={(event) => onDischargeNotesChange(event.target.value)}
placeholder="备注"
value={dischargeNotes}
/>
<Button disabled={!canManageAdmissions || isPending} type="submit" variant="destructive">
<UserMinus className="size-4" aria-hidden="true" />
退
</Button>
</form>
</div>
);
}
function contextToneClass(tone: OperationalContextLine["tone"]): string {
if (tone === "danger") {
return "text-destructive";
}
if (tone === "warning") {
return "text-amber-700";
}
return "text-muted-foreground";
}
function ContextLines({ expanded = false, lines }: { expanded?: boolean; lines: OperationalContextLine[] }): React.ReactElement {
if (lines.length === 0) {
return <span className="text-muted-foreground">-</span>;
}
return (
<div className="grid gap-1">
{lines.map((line) => (
<p
className={cn(expanded ? "text-sm" : "max-w-[260px] truncate text-xs", contextToneClass(line.tone))}
key={`${line.kind}-${line.label}-${line.value}`}
>
<span className="font-medium">{contextKindLabels[line.kind]} · {line.label}</span> {line.value}
</p>
))}
</div>
);
}
function OccupantSummary({
context,
fallbackName,
}: {
context: BedOperationalContext | undefined;
fallbackName: string | undefined;
}): React.ReactElement {
if (!context?.occupant) {
return <span className="text-muted-foreground">{fallbackName ?? "-"}</span>;
}
return (
<div>
<p className="font-medium">{context.occupant.elderName}</p>
<p className="text-xs text-muted-foreground">
{context.occupant.careLevel} / {context.occupant.status}
</p>
</div>
);
}