feat: improve bed workspace layout
This commit is contained in:
@@ -437,6 +437,8 @@ a,
|
|||||||
inset: 0 !important;
|
inset: 0 !important;
|
||||||
height: fit-content;
|
height: fit-content;
|
||||||
margin: auto !important;
|
margin: auto !important;
|
||||||
|
width: min(calc(100vw - 1.5rem), var(--dialog-width, 36rem)) !important;
|
||||||
|
max-width: calc(100vw - 1.5rem) !important;
|
||||||
min-width: 0 !important;
|
min-width: 0 !important;
|
||||||
transform: none !important;
|
transform: none !important;
|
||||||
translate: 0 0 !important;
|
translate: 0 0 !important;
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ import { Dialog as KumoDialog } from "@cloudflare/kumo/components/dialog";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const dialogWidthValues = {
|
||||||
|
sm: "28rem",
|
||||||
|
md: "36rem",
|
||||||
|
lg: "42rem",
|
||||||
|
xl: "48rem",
|
||||||
|
wide: "64rem",
|
||||||
|
} as const;
|
||||||
|
|
||||||
type DialogProps = {
|
type DialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -15,11 +23,16 @@ type DialogProps = {
|
|||||||
footer?: React.ReactNode;
|
footer?: React.ReactNode;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
width?: keyof typeof dialogWidthValues;
|
||||||
};
|
};
|
||||||
|
|
||||||
const kumoBackdropClassNames =
|
const kumoBackdropClassNames =
|
||||||
"fixed inset-0 bg-kumo-recessed opacity-80 transition-all duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0";
|
"fixed inset-0 bg-kumo-recessed opacity-80 transition-all duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0";
|
||||||
|
|
||||||
|
type DialogStyle = React.CSSProperties & {
|
||||||
|
"--dialog-width": string;
|
||||||
|
};
|
||||||
|
|
||||||
export function Dialog({
|
export function Dialog({
|
||||||
open,
|
open,
|
||||||
title,
|
title,
|
||||||
@@ -28,7 +41,14 @@ export function Dialog({
|
|||||||
footer,
|
footer,
|
||||||
onClose,
|
onClose,
|
||||||
className,
|
className,
|
||||||
|
width = "md",
|
||||||
}: DialogProps): React.ReactElement {
|
}: DialogProps): React.ReactElement {
|
||||||
|
const dialogStyle: DialogStyle = {
|
||||||
|
"--dialog-width": dialogWidthValues[width],
|
||||||
|
transform: "none",
|
||||||
|
translate: "0 0",
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KumoDialog.Root
|
<KumoDialog.Root
|
||||||
onOpenChange={(nextOpen) => {
|
onOpenChange={(nextOpen) => {
|
||||||
@@ -40,13 +60,13 @@ export function Dialog({
|
|||||||
>
|
>
|
||||||
<KumoDialog
|
<KumoDialog
|
||||||
className={cn(
|
className={cn(
|
||||||
"teatea-dialog fixed inset-0 z-50 m-auto flex h-fit max-h-[min(calc(100svh-1.5rem),52rem)] w-[min(calc(100vw-1.5rem),42rem)] min-w-0 flex-col overflow-hidden rounded-lg border border-kumo-line bg-kumo-base p-0 shadow-xl outline-none ring-1 ring-kumo-line",
|
"teatea-dialog fixed inset-0 z-50 m-auto flex h-fit max-h-[min(calc(100svh-1.5rem),52rem)] w-[min(calc(100vw-1.5rem),var(--dialog-width))] min-w-0 flex-col overflow-hidden rounded-lg border border-kumo-line bg-kumo-base p-0 shadow-xl outline-none ring-1 ring-kumo-line",
|
||||||
"data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0",
|
"data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
data-kumo-backdrop-classes={kumoBackdropClassNames}
|
data-kumo-backdrop-classes={kumoBackdropClassNames}
|
||||||
size="xl"
|
size="xl"
|
||||||
style={{ transform: "none", translate: "0 0" }}
|
style={dialogStyle}
|
||||||
>
|
>
|
||||||
<header className="flex shrink-0 items-start justify-between gap-4 border-b border-kumo-line bg-kumo-base p-5">
|
<header className="flex shrink-0 items-start justify-between gap-4 border-b border-kumo-line bg-kumo-base p-5">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ export function CareWorkspaceClient({ canManage, initialData }: CareWorkspaceCli
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Dialog className="max-w-lg" description={completeTarget?.title} onClose={closeCompleteDialog} open={completeTarget !== undefined} title="完成护理任务">
|
<Dialog description={completeTarget?.title} onClose={closeCompleteDialog} open={completeTarget !== undefined} title="完成护理任务">
|
||||||
<form className="grid gap-3" onSubmit={submitComplete}>
|
<form className="grid gap-3" onSubmit={submitComplete}>
|
||||||
<Textarea label="执行备注" onChange={(event) => setExecutionNotes(event.target.value)} value={executionNotes} />
|
<Textarea label="执行备注" onChange={(event) => setExecutionNotes(event.target.value)} value={executionNotes} />
|
||||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
|
|||||||
@@ -558,11 +558,11 @@ export function EldersClient({ elderContexts = {}, initialElders, permissions }:
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
className="max-w-md"
|
|
||||||
description={deleteTarget ? `确认删除 ${deleteTarget.name} 的档案?该操作会写入审计日志。` : undefined}
|
description={deleteTarget ? `确认删除 ${deleteTarget.name} 的档案?该操作会写入审计日志。` : undefined}
|
||||||
onClose={() => setDeleteTarget(null)}
|
onClose={() => setDeleteTarget(null)}
|
||||||
open={deleteTarget !== null}
|
open={deleteTarget !== null}
|
||||||
title="删除确认"
|
title="删除确认"
|
||||||
|
width="sm"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{deleteTarget ? (
|
{deleteTarget ? (
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ export function EmergencyWorkspaceClient({ canManage, initialData }: EmergencyWo
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Dialog className="max-w-xl" description="手工登记安全、应急或巡检异常事件。" onClose={() => setIsCreateOpen(false)} open={isCreateOpen} title="新建应急事件">
|
<Dialog description="手工登记安全、应急或巡检异常事件。" onClose={() => setIsCreateOpen(false)} open={isCreateOpen} title="新建应急事件">
|
||||||
<form className="grid gap-3" onSubmit={createIncident}>
|
<form className="grid gap-3" onSubmit={createIncident}>
|
||||||
<Input label="事件标题" onChange={(event) => setCreateForm((current) => ({ ...current, title: event.target.value }))} required value={createForm.title} />
|
<Input label="事件标题" onChange={(event) => setCreateForm((current) => ({ ...current, title: event.target.value }))} required value={createForm.title} />
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import { BedDouble, DoorOpen, History, MoveRight, UserCheck, UserMinus, Wrench }
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Dialog } from "@/components/ui/dialog";
|
||||||
import { Textarea } from "@/components/ui/input";
|
import { Textarea } from "@/components/ui/input";
|
||||||
import { Select, type SelectOption } from "@/components/ui/select";
|
import { Select, type SelectOption } from "@/components/ui/select";
|
||||||
import { Table } from "@/components/ui/table";
|
import { Table } from "@/components/ui/table";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import type { ApiResult } from "@/modules/core/server/api";
|
import type { ApiResult } from "@/modules/core/server/api";
|
||||||
import type { Admission, BedStatus, FacilityBed, Permission, Room } from "@/modules/core/types";
|
import type { Admission, BedStatus, FacilityBed, Permission, Room } from "@/modules/core/types";
|
||||||
import type { Elder } from "@/modules/elders/types";
|
import type { Elder } from "@/modules/elders/types";
|
||||||
@@ -24,6 +26,11 @@ type BedsWorkspaceProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type WorkspaceTab = "overview" | "operations" | "history";
|
type WorkspaceTab = "overview" | "operations" | "history";
|
||||||
|
type BedDetailTab = "context" | "records" | "actions";
|
||||||
|
type RoomView = {
|
||||||
|
room: Room;
|
||||||
|
beds: FacilityBed[];
|
||||||
|
};
|
||||||
|
|
||||||
const workspaceTabs: Array<{ value: WorkspaceTab; label: string }> = [
|
const workspaceTabs: Array<{ value: WorkspaceTab; label: string }> = [
|
||||||
{ value: "overview", label: "概览" },
|
{ value: "overview", label: "概览" },
|
||||||
@@ -31,6 +38,12 @@ const workspaceTabs: Array<{ value: WorkspaceTab; label: string }> = [
|
|||||||
{ value: "history", 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> = {
|
const bedStatusLabels: Record<BedStatus, string> = {
|
||||||
available: "空闲",
|
available: "空闲",
|
||||||
occupied: "占用",
|
occupied: "占用",
|
||||||
@@ -44,6 +57,13 @@ const admissionStatusLabels: Record<Admission["status"], string> = {
|
|||||||
discharged: "已退住",
|
discharged: "已退住",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const contextKindLabels: Record<OperationalContextLine["kind"], string> = {
|
||||||
|
admission: "入住",
|
||||||
|
care: "照护",
|
||||||
|
health: "健康",
|
||||||
|
incident: "预警",
|
||||||
|
};
|
||||||
|
|
||||||
function formatDateTime(value: string): string {
|
function formatDateTime(value: string): string {
|
||||||
return new Date(value).toLocaleString("zh-CN");
|
return new Date(value).toLocaleString("zh-CN");
|
||||||
}
|
}
|
||||||
@@ -65,6 +85,24 @@ function admissionBedLabel(admission: Admission): string {
|
|||||||
return `${admission.roomName}-${admission.bedCode}`;
|
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[] {
|
function makeSelectOptions(items: Array<{ id: string; label: string }>, emptyLabel: string): SelectOption[] {
|
||||||
return [
|
return [
|
||||||
{ value: "", label: emptyLabel, disabled: true },
|
{ 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({
|
export function BedsWorkspace({
|
||||||
bedContexts = {},
|
bedContexts = {},
|
||||||
initialRooms,
|
initialRooms,
|
||||||
@@ -95,6 +159,8 @@ export function BedsWorkspace({
|
|||||||
const [transferNotes, setTransferNotes] = useState("");
|
const [transferNotes, setTransferNotes] = useState("");
|
||||||
const [dischargeAdmissionId, setDischargeAdmissionId] = useState("");
|
const [dischargeAdmissionId, setDischargeAdmissionId] = useState("");
|
||||||
const [dischargeNotes, setDischargeNotes] = useState("");
|
const [dischargeNotes, setDischargeNotes] = useState("");
|
||||||
|
const [selectedBedId, setSelectedBedId] = useState("");
|
||||||
|
const [bedDetailTab, setBedDetailTab] = useState<BedDetailTab>("context");
|
||||||
|
|
||||||
const canManageAdmissions = permissions.includes("admission:manage");
|
const canManageAdmissions = permissions.includes("admission:manage");
|
||||||
const activeAdmissions = useMemo(() => admissions.filter((admission) => admission.status === "active"), [admissions]);
|
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 maintenanceBeds = beds.filter((bed) => bed.status === "maintenance").length;
|
||||||
const occupancyRate = beds.length > 0 ? Math.round((occupiedBeds / beds.length) * 100) : 0;
|
const occupancyRate = beds.length > 0 ? Math.round((occupiedBeds / beds.length) * 100) : 0;
|
||||||
const admissionEligibleElders = elders.filter((elder) => !activeElderIds.has(elder.id));
|
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(
|
const elderOptions = makeSelectOptions(
|
||||||
admissionEligibleElders.map((elder) => ({ id: elder.id, label: `${elder.name} / ${elder.phone}` })),
|
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> {
|
async function refreshData(): Promise<void> {
|
||||||
const [eldersResponse, roomsResponse, bedsResponse, admissionsResponse] = await Promise.all([
|
const [eldersResponse, roomsResponse, bedsResponse, admissionsResponse] = await Promise.all([
|
||||||
fetch("/api/elders"),
|
fetch("/api/elders"),
|
||||||
@@ -187,6 +273,43 @@ export function BedsWorkspace({
|
|||||||
setMessage(result.reason);
|
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> {
|
async function submitTransfer(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const admission = activeAdmissions.find((item) => item.id === transferAdmissionId);
|
const admission = activeAdmissions.find((item) => item.id === transferAdmissionId);
|
||||||
@@ -221,6 +344,39 @@ export function BedsWorkspace({
|
|||||||
setMessage("换床已办理");
|
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> {
|
async function submitDischarge(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!canManageAdmissions) {
|
if (!canManageAdmissions) {
|
||||||
@@ -253,6 +409,34 @@ export function BedsWorkspace({
|
|||||||
setMessage(result.reason);
|
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 (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
|
||||||
<section className="flex border-b pb-4">
|
<section className="flex border-b pb-4">
|
||||||
@@ -324,97 +508,65 @@ export function BedsWorkspace({
|
|||||||
</Card>
|
</Card>
|
||||||
</section>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>房间台账</CardTitle>
|
<CardTitle>房间格子视图</CardTitle>
|
||||||
|
<CardDescription>按房间查看床位占用、维修、停用和待入住空床。</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="grid gap-4">
|
||||||
<div className="overflow-x-auto rounded-md border">
|
<StatusLegend />
|
||||||
<Table className="w-full min-w-[620px] text-sm">
|
{roomViews.length > 0 ? (
|
||||||
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
<Table.Row>
|
{roomViews.map((view) => (
|
||||||
<Table.Head className="px-4 py-3 font-medium">房间</Table.Head>
|
<RoomGrid
|
||||||
<Table.Head className="px-4 py-3 font-medium">编号</Table.Head>
|
bedContexts={bedContexts}
|
||||||
<Table.Head className="px-4 py-3 font-medium">容量</Table.Head>
|
key={view.room.id}
|
||||||
<Table.Head className="px-4 py-3 font-medium">床位</Table.Head>
|
onSelectBed={openBedDetail}
|
||||||
<Table.Head className="px-4 py-3 font-medium">状态</Table.Head>
|
onStartAdmission={startAdmissionFromBed}
|
||||||
</Table.Row>
|
roomView={view}
|
||||||
</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>
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">暂无房间主数据</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>床位状态</CardTitle>
|
<CardTitle>快速办理</CardTitle>
|
||||||
|
<CardDescription>空床格子可直接进入入住办理。</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="overflow-x-auto rounded-md border">
|
<form className="grid gap-3" onSubmit={(event) => void submitAdmission(event)}>
|
||||||
<Table className="w-full min-w-[700px] text-sm">
|
<Select
|
||||||
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
|
aria-label="入住老人"
|
||||||
<Table.Row>
|
disabled={!canManageAdmissions || isPending}
|
||||||
<Table.Head className="px-4 py-3 font-medium">房间</Table.Head>
|
onValueChange={setAdmitElderId}
|
||||||
<Table.Head className="px-4 py-3 font-medium">床位</Table.Head>
|
options={elderOptions}
|
||||||
<Table.Head className="px-4 py-3 font-medium">状态</Table.Head>
|
value={admitElderId}
|
||||||
<Table.Head className="px-4 py-3 font-medium">当前老人</Table.Head>
|
/>
|
||||||
<Table.Head className="px-4 py-3 font-medium">入住上下文</Table.Head>
|
<Select
|
||||||
<Table.Head className="px-4 py-3 font-medium">备注</Table.Head>
|
aria-label="入住房间床位"
|
||||||
</Table.Row>
|
disabled={!canManageAdmissions || isPending}
|
||||||
</Table.Header>
|
onValueChange={setAdmitBedId}
|
||||||
<Table.Body className="divide-y bg-card">
|
options={bedOptions}
|
||||||
{beds.map((bed) => {
|
value={admitBedId}
|
||||||
const context = bedContexts[bed.id];
|
/>
|
||||||
return (
|
<Textarea
|
||||||
<Table.Row key={bed.id}>
|
aria-label="入住备注"
|
||||||
<Table.Cell className="px-4 py-3">{bed.roomName}</Table.Cell>
|
disabled={!canManageAdmissions || isPending}
|
||||||
<Table.Cell className="px-4 py-3 font-medium">{bed.code}</Table.Cell>
|
onChange={(event) => setAdmitNotes(event.target.value)}
|
||||||
<Table.Cell className="px-4 py-3">
|
placeholder="备注"
|
||||||
<Badge variant={bedBadgeVariant(bed.status)}>{bedStatusLabels[bed.status]}</Badge>
|
value={admitNotes}
|
||||||
</Table.Cell>
|
/>
|
||||||
<Table.Cell className="px-4 py-3">
|
<Button disabled={!canManageAdmissions || isPending} type="submit">
|
||||||
<OccupantSummary context={context} fallbackName={bed.currentElderName} />
|
<UserCheck className="size-4" aria-hidden="true" />
|
||||||
</Table.Cell>
|
办理入住
|
||||||
<Table.Cell className="px-4 py-3">
|
</Button>
|
||||||
<ContextLines lines={context?.lines ?? []} />
|
</form>
|
||||||
</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>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
@@ -583,6 +735,435 @@ export function BedsWorkspace({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -597,7 +1178,7 @@ function contextToneClass(tone: OperationalContextLine["tone"]): string {
|
|||||||
return "text-muted-foreground";
|
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) {
|
if (lines.length === 0) {
|
||||||
return <span className="text-muted-foreground">-</span>;
|
return <span className="text-muted-foreground">-</span>;
|
||||||
}
|
}
|
||||||
@@ -605,8 +1186,11 @@ function ContextLines({ lines }: { lines: OperationalContextLine[] }): React.Rea
|
|||||||
return (
|
return (
|
||||||
<div className="grid gap-1">
|
<div className="grid gap-1">
|
||||||
{lines.map((line) => (
|
{lines.map((line) => (
|
||||||
<p className={`max-w-[260px] truncate text-xs ${contextToneClass(line.tone)}`} key={`${line.kind}-${line.label}-${line.value}`}>
|
<p
|
||||||
<span className="font-medium">{line.label}</span> {line.value}
|
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>
|
</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -610,11 +610,11 @@ export function HealthAdminClient({ canManage, initialData }: HealthAdminClientP
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
className="max-w-2xl"
|
|
||||||
description="维护老人健康档案基础信息。"
|
description="维护老人健康档案基础信息。"
|
||||||
onClose={closeProfileDialog}
|
onClose={closeProfileDialog}
|
||||||
open={profileElderId.length > 0}
|
open={profileElderId.length > 0}
|
||||||
title={editingProfile ? "编辑健康档案" : "新增健康档案"}
|
title={editingProfile ? "编辑健康档案" : "新增健康档案"}
|
||||||
|
width="lg"
|
||||||
>
|
>
|
||||||
<form className="grid gap-3" onSubmit={saveProfile}>
|
<form className="grid gap-3" onSubmit={saveProfile}>
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -646,7 +646,7 @@ export function HealthAdminClient({ canManage, initialData }: HealthAdminClientP
|
|||||||
</form>
|
</form>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog className="max-w-2xl" description="录入一条真实生命体征记录。" onClose={() => setIsVitalOpen(false)} open={isVitalOpen} title="录入生命体征">
|
<Dialog description="录入一条真实生命体征记录。" onClose={() => setIsVitalOpen(false)} open={isVitalOpen} title="录入生命体征" width="lg">
|
||||||
<form className="grid gap-3" onSubmit={saveVital}>
|
<form className="grid gap-3" onSubmit={saveVital}>
|
||||||
<Select
|
<Select
|
||||||
label="老人"
|
label="老人"
|
||||||
@@ -683,7 +683,7 @@ export function HealthAdminClient({ canManage, initialData }: HealthAdminClientP
|
|||||||
</form>
|
</form>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog className="max-w-xl" description="新增老人慢病管理记录。" onClose={() => setIsConditionOpen(false)} open={isConditionOpen} title="新增慢病记录">
|
<Dialog description="新增老人慢病管理记录。" onClose={() => setIsConditionOpen(false)} open={isConditionOpen} title="新增慢病记录">
|
||||||
<form className="grid gap-3" onSubmit={saveCondition}>
|
<form className="grid gap-3" onSubmit={saveCondition}>
|
||||||
<Select
|
<Select
|
||||||
label="老人"
|
label="老人"
|
||||||
@@ -711,7 +711,7 @@ export function HealthAdminClient({ canManage, initialData }: HealthAdminClientP
|
|||||||
</form>
|
</form>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog className="max-w-lg" description={reviewTarget?.description} onClose={closeReviewDialog} open={reviewTarget !== undefined} title={reviewTarget?.title ?? "处理异常复核"}>
|
<Dialog description={reviewTarget?.description} onClose={closeReviewDialog} open={reviewTarget !== undefined} title={reviewTarget?.title ?? "处理异常复核"}>
|
||||||
<form className="grid gap-3" onSubmit={saveReview}>
|
<form className="grid gap-3" onSubmit={saveReview}>
|
||||||
<Select
|
<Select
|
||||||
label="处理状态"
|
label="处理状态"
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ export function OrganizationInviteDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
className="max-w-xl"
|
|
||||||
description={`生成 ${organization.name} 的邀请链接,可限定邮箱并指定入组角色。`}
|
description={`生成 ${organization.name} 的邀请链接,可限定邮箱并指定入组角色。`}
|
||||||
onClose={closeDialog}
|
onClose={closeDialog}
|
||||||
open={open}
|
open={open}
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ export function OrganizationManagementClient(): React.ReactElement {
|
|||||||
新增机构
|
新增机构
|
||||||
</Button>
|
</Button>
|
||||||
<Dialog
|
<Dialog
|
||||||
className="max-w-xl"
|
|
||||||
description="创建后会自动初始化机构内置角色。"
|
description="创建后会自动初始化机构内置角色。"
|
||||||
onClose={closeCreateDialog}
|
onClose={closeCreateDialog}
|
||||||
open={isCreateOpen}
|
open={isCreateOpen}
|
||||||
@@ -195,7 +194,6 @@ export function OrganizationRowActions({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Dialog
|
<Dialog
|
||||||
className="max-w-lg"
|
|
||||||
description="编辑机构名称、唯一标识和启停状态。"
|
description="编辑机构名称、唯一标识和启停状态。"
|
||||||
onClose={() => setIsEditOpen(false)}
|
onClose={() => setIsEditOpen(false)}
|
||||||
open={isEditOpen}
|
open={isEditOpen}
|
||||||
|
|||||||
@@ -204,11 +204,11 @@ export function RoleManagementClient({ permissions }: RoleManagementClientProps)
|
|||||||
新增角色
|
新增角色
|
||||||
</Button>
|
</Button>
|
||||||
<Dialog
|
<Dialog
|
||||||
className="w-[min(calc(100vw-1.5rem),64rem)]"
|
|
||||||
description="角色由系统注册权限点组合而成。"
|
description="角色由系统注册权限点组合而成。"
|
||||||
onClose={closeCreateDialog}
|
onClose={closeCreateDialog}
|
||||||
open={isCreateOpen}
|
open={isCreateOpen}
|
||||||
title="新增角色"
|
title="新增角色"
|
||||||
|
width="wide"
|
||||||
>
|
>
|
||||||
<form className="grid gap-4" onSubmit={handleSubmit}>
|
<form className="grid gap-4" onSubmit={handleSubmit}>
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
@@ -345,11 +345,11 @@ export function RoleRowActions({ permissions, role }: RoleRowActionsProps): Reac
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Dialog
|
<Dialog
|
||||||
className="w-[min(calc(100vw-1.5rem),64rem)]"
|
|
||||||
description="编辑自定义角色的名称、说明、状态和权限点。"
|
description="编辑自定义角色的名称、说明、状态和权限点。"
|
||||||
onClose={closeEditDialog}
|
onClose={closeEditDialog}
|
||||||
open={isEditOpen}
|
open={isEditOpen}
|
||||||
title="编辑角色"
|
title="编辑角色"
|
||||||
|
width="wide"
|
||||||
>
|
>
|
||||||
<form className="grid gap-4" onSubmit={handleSubmit}>
|
<form className="grid gap-4" onSubmit={handleSubmit}>
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
|||||||
@@ -234,7 +234,6 @@ export function AccountMenu({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
className="max-w-2xl"
|
|
||||||
description="管理你的个人资料和第三方登录绑定状态。"
|
description="管理你的个人资料和第三方登录绑定状态。"
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
if (!isPending) {
|
if (!isPending) {
|
||||||
@@ -244,6 +243,7 @@ export function AccountMenu({
|
|||||||
}}
|
}}
|
||||||
open={isSettingsOpen}
|
open={isSettingsOpen}
|
||||||
title="用户设置"
|
title="用户设置"
|
||||||
|
width="lg"
|
||||||
>
|
>
|
||||||
<form className="grid gap-5" onSubmit={handleProfileSubmit}>
|
<form className="grid gap-5" onSubmit={handleProfileSubmit}>
|
||||||
<div className="grid gap-4 md:grid-cols-[auto_1fr]">
|
<div className="grid gap-4 md:grid-cols-[auto_1fr]">
|
||||||
@@ -292,7 +292,6 @@ export function AccountMenu({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
className="max-w-md"
|
|
||||||
description="退出后需要重新登录才能继续访问工作台。"
|
description="退出后需要重新登录才能继续访问工作台。"
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
if (!isPending) {
|
if (!isPending) {
|
||||||
@@ -301,6 +300,7 @@ export function AccountMenu({
|
|||||||
}}
|
}}
|
||||||
open={isSignOutConfirmOpen}
|
open={isSignOutConfirmOpen}
|
||||||
title="确认退出登录"
|
title="确认退出登录"
|
||||||
|
width="sm"
|
||||||
>
|
>
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<p className="text-sm text-muted-foreground">确定要退出当前账号吗?</p>
|
<p className="text-sm text-muted-foreground">确定要退出当前账号吗?</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user