1220 lines
44 KiB
TypeScript
1220 lines
44 KiB
TypeScript
"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>
|
||
);
|
||
}
|