feat: improve bed workspace layout

This commit is contained in:
2026-07-03 03:19:05 -07:00
parent 41eb3b73a9
commit 901e36b7a7
11 changed files with 703 additions and 100 deletions

View File

@@ -6,9 +6,11 @@ import { BedDouble, DoorOpen, History, MoveRight, UserCheck, UserMinus, Wrench }
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";
@@ -24,6 +26,11 @@ type BedsWorkspaceProps = {
};
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: "概览" },
@@ -31,6 +38,12 @@ const workspaceTabs: Array<{ value: WorkspaceTab; label: string }> = [
{ 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: "占用",
@@ -44,6 +57,13 @@ const admissionStatusLabels: Record<Admission["status"], string> = {
discharged: "已退住",
};
const contextKindLabels: Record<OperationalContextLine["kind"], string> = {
admission: "入住",
care: "照护",
health: "健康",
incident: "预警",
};
function formatDateTime(value: string): string {
return new Date(value).toLocaleString("zh-CN");
}
@@ -65,6 +85,24 @@ 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 },
@@ -72,6 +110,32 @@ function makeSelectOptions(items: Array<{ id: string; label: string }>, emptyLab
];
}
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,
@@ -95,6 +159,8 @@ export function BedsWorkspace({
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]);
@@ -104,6 +170,15 @@ export function BedsWorkspace({
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}` })),
@@ -121,6 +196,17 @@ export function BedsWorkspace({
"选择在住记录",
);
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"),
@@ -187,6 +273,43 @@ export function BedsWorkspace({
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);
@@ -221,6 +344,39 @@ export function BedsWorkspace({
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) {
@@ -253,6 +409,34 @@ export function BedsWorkspace({
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">
@@ -324,97 +508,65 @@ export function BedsWorkspace({
</Card>
</section>
<section className="grid gap-4 xl:grid-cols-[0.9fr_1.1fr]">
<section className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_20rem]">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-md border">
<Table className="w-full min-w-[620px] 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">
{rooms.map((room) => (
<Table.Row key={room.id}>
<Table.Cell className="px-4 py-3 font-medium">{room.name}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{room.code}</Table.Cell>
<Table.Cell className="px-4 py-3">{room.capacity}</Table.Cell>
<Table.Cell className="px-4 py-3">
{room.occupiedBedCount}/{room.bedCount}
</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={room.status === "active" ? "success" : "danger"}>{room.status}</Badge>
</Table.Cell>
</Table.Row>
))}
{rooms.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>
<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>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-md border">
<Table className="w-full min-w-[700px] 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">
{beds.map((bed) => {
const context = bedContexts[bed.id];
return (
<Table.Row key={bed.id}>
<Table.Cell className="px-4 py-3">{bed.roomName}</Table.Cell>
<Table.Cell className="px-4 py-3 font-medium">{bed.code}</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={bedBadgeVariant(bed.status)}>{bedStatusLabels[bed.status]}</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3">
<OccupantSummary context={context} fallbackName={bed.currentElderName} />
</Table.Cell>
<Table.Cell className="px-4 py-3">
<ContextLines lines={context?.lines ?? []} />
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{bed.notes || "-"}</Table.Cell>
</Table.Row>
);
})}
{beds.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>
<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>
@@ -583,6 +735,435 @@ export function BedsWorkspace({
</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>
);
}
@@ -597,7 +1178,7 @@ function contextToneClass(tone: OperationalContextLine["tone"]): string {
return "text-muted-foreground";
}
function ContextLines({ lines }: { lines: OperationalContextLine[] }): React.ReactElement {
function ContextLines({ expanded = false, lines }: { expanded?: boolean; lines: OperationalContextLine[] }): React.ReactElement {
if (lines.length === 0) {
return <span className="text-muted-foreground">-</span>;
}
@@ -605,8 +1186,11 @@ function ContextLines({ lines }: { lines: OperationalContextLine[] }): React.Rea
return (
<div className="grid gap-1">
{lines.map((line) => (
<p className={`max-w-[260px] truncate text-xs ${contextToneClass(line.tone)}`} key={`${line.kind}-${line.label}-${line.value}`}>
<span className="font-medium">{line.label}</span> {line.value}
<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>