From 9480030391fc2c763cc6c0cb7f29d2aa49b24c2f Mon Sep 17 00:00:00 2001 From: TalexDreamSoul Date: Thu, 2 Jul 2026 17:30:24 -0700 Subject: [PATCH] feat: complete admission workspace --- .trellis/spec/backend/database.md | 60 ++ app/(app)/app/beds/page.tsx | 217 +------ app/(app)/app/dashboard/page.tsx | 76 ++- app/(app)/app/elders/page.tsx | 7 +- app/api/admissions/[id]/route.ts | 109 ++++ app/api/admissions/route.ts | 41 +- .../components/BedOccupancyChart.tsx | 28 + .../dashboard/components/DashboardHome.tsx | 271 ++++---- modules/elders/components/EldersClient.tsx | 2 +- .../facilities/components/BedsWorkspace.tsx | 578 ++++++++++++++++++ modules/shared/components/AppShell.tsx | 7 - package.json | 1 + pnpm-lock.yaml | 288 +++++++++ 13 files changed, 1335 insertions(+), 350 deletions(-) create mode 100644 app/api/admissions/[id]/route.ts create mode 100644 modules/dashboard/components/BedOccupancyChart.tsx create mode 100644 modules/facilities/components/BedsWorkspace.tsx diff --git a/.trellis/spec/backend/database.md b/.trellis/spec/backend/database.md index c60e01c..8753a2a 100644 --- a/.trellis/spec/backend/database.md +++ b/.trellis/spec/backend/database.md @@ -191,6 +191,66 @@ export async function getOrdersWithItems(params: { ## Advanced SQL Patterns +## Scenario: Admission and Bed Mutations + +### 1. Scope / Trigger + +- Trigger: changing elder admission, transfer, discharge, or bed occupancy behavior. +- These mutations span `admissions`, `beds`, and `elders`, so partial writes are not acceptable. + +### 2. Signatures + +- `POST /api/admissions`: admit an elder or transfer an active elder to a new available bed. +- `PATCH /api/admissions/[id]`: discharge an active admission and release its bed. +- Mutations use `getDatabase().transaction(async (transaction) => ...)`. + +### 3. Contracts + +- `POST /api/admissions` request: `{ elderId: string; bedId: string; notes?: string }`. +- `PATCH /api/admissions/[id]` request: `{ notes?: string }`. +- Success responses include `{ success: true, reason: string, admission }`. +- Failure responses include `{ success: false, reason: string }`. + +### 4. Validation & Error Matrix + +- Missing active organization -> `400`. +- Missing elder or bed -> `404`. +- Target bed not `available` -> `409`. +- Discharge target not found -> `404`. +- Discharge target not `active` -> `409`. +- Insert/update returning no row -> `500`. + +### 5. Good/Base/Bad Cases + +- Good: return a typed mutation result from inside the transaction, then convert it to `jsonFailure` or `jsonSuccess` outside the transaction. +- Base: read-only admission lists may use the temporary `readData()` compatibility model while the domain helper layer is being extracted. +- Bad: throw ordinary `Error` for business conflicts such as occupied beds, because it loses the structured status/reason contract. + +### 6. Tests Required + +- `pnpm lint` +- `pnpm type-check` +- `pnpm build` +- Integration assertions: occupied-bed admission returns `409`; transfer closes the old active admission and frees the old bed; discharge closes the active admission, frees the bed, and marks the elder discharged. + +### 7. Wrong vs Correct + +#### Wrong + +```ts +if (bed.status !== "available") { + throw new Error("床位不可分配"); +} +``` + +#### Correct + +```ts +if (bed.status !== "available") { + return { success: false, reason: "床位不可分配", status: 409 }; +} +``` + ### JSON Column Operations When using PostgreSQL JSON/JSONB columns, proper casting is required for JSON functions. diff --git a/app/(app)/app/beds/page.tsx b/app/(app)/app/beds/page.tsx index 89d971c..0bce349 100644 --- a/app/(app)/app/beds/page.tsx +++ b/app/(app)/app/beds/page.tsx @@ -1,12 +1,9 @@ import { redirect } from "next/navigation"; -import { BedDouble, DoorOpen, History, Wrench } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Table } from "@/components/ui/table"; import { getCurrentAuthContext } from "@/modules/core/server/auth"; import { hasPermission } from "@/modules/core/server/permissions"; import { readData } from "@/modules/core/server/store"; +import { BedsWorkspace } from "@/modules/facilities/components/BedsWorkspace"; export default async function BedsPage(): Promise { const context = await getCurrentAuthContext(); @@ -25,211 +22,15 @@ export default async function BedsPage(): Promise { const admissions = organizationId ? data.admissions.filter((admission) => admission.organizationId === organizationId) : data.admissions; - - 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 elders = organizationId ? data.elders.filter((elder) => elder.organizationId === organizationId) : data.elders; return ( -
-
-
- Facility + Admission -

床位房间

-

房间主数据、床位状态、当前占用和入住历史。

-
-
- -
- - -
- 房间 - {rooms.length} -
-
-
- - -
- 床位 - {beds.length} -
-
- -

占用率 {occupancyRate}%

-
-
- 0 ? "border-amber-200 bg-amber-50/70" : undefined}> - -
- 0 ? "text-amber-700" : undefined}>维修床位 - 0 ? "mt-2 text-3xl text-amber-700" : "mt-2 text-3xl"}> - {maintenanceBeds} - -
- 0 ? "size-5 text-amber-700" : "size-5 text-primary"} aria-hidden="true" /> -
-
- - -
- 入住历史 - {admissions.length} -
-
-
-
- -
- - - 房间台账 - - -
- - - - 房间 - 编号 - 容量 - 床位 - 状态 - - - - {rooms.map((room) => ( - - {room.name} - {room.code} - {room.capacity} - - {room.occupiedBedCount}/{room.bedCount} - - - {room.status} - - - ))} - {rooms.length === 0 ? ( - - - 暂无房间,请通过 /api/facilities/rooms 创建 - - - ) : null} - -
-
-
-
- - - - 床位状态 - - -
- - - - 房间 - 床位 - 状态 - 当前老人 - 备注 - - - - {beds.map((bed) => ( - - {bed.roomName} - {bed.code} - - - {bed.status} - - - {bed.currentElderName ?? "-"} - {bed.notes || "-"} - - ))} - {beds.length === 0 ? ( - - - 暂无床位,请通过 /api/facilities/beds 创建 - - - ) : null} - -
-
-
-
-
- - - - 入住与换床历史 - - -
- - - - 老人 - 床位 - 状态 - 入住时间 - 结束时间 - 备注 - - - - {admissions.map((admission) => ( - - {admission.elderName} - - {admission.roomName}-{admission.bedCode} - - - {admission.status} - - - {new Date(admission.admittedAt).toLocaleString("zh-CN")} - - - {admission.dischargedAt ? new Date(admission.dischargedAt).toLocaleString("zh-CN") : "-"} - - {admission.notes || "-"} - - ))} - {admissions.length === 0 ? ( - - - 暂无入住历史 - - - ) : null} - -
-
-
-
-
+ ); } diff --git a/app/(app)/app/dashboard/page.tsx b/app/(app)/app/dashboard/page.tsx index b7b5514..9640259 100644 --- a/app/(app)/app/dashboard/page.tsx +++ b/app/(app)/app/dashboard/page.tsx @@ -1,5 +1,75 @@ -import { DashboardHome } from "@/modules/dashboard/components/DashboardHome"; +import { redirect } from "next/navigation"; -export default function DashboardPage(): React.ReactElement { - return ; +import { getCurrentAuthContext } from "@/modules/core/server/auth"; +import { readData } from "@/modules/core/server/store"; +import type { BedStatus } from "@/modules/core/types"; +import { DashboardHome, type DashboardMetric } from "@/modules/dashboard/components/DashboardHome"; + +const BED_STATUSES: BedStatus[] = ["available", "occupied", "maintenance", "disabled"]; + +export default async function DashboardPage(): Promise { + const context = await getCurrentAuthContext(); + if (!context) { + redirect("/login"); + } + + const data = await readData(); + const organizationId = context.organization?.id; + const elders = organizationId ? data.elders.filter((elder) => elder.organizationId === organizationId) : data.elders; + const beds = organizationId ? data.beds.filter((bed) => bed.organizationId === organizationId) : data.beds; + const admissions = organizationId + ? data.admissions.filter((admission) => admission.organizationId === organizationId) + : data.admissions; + const incidents = organizationId + ? data.incidents.filter((incident) => !incident.organizationId || incident.organizationId === organizationId) + : data.incidents; + + const activeElders = elders.filter((elder) => elder.status === "active").length; + const pendingElders = elders.filter((elder) => elder.status === "pending").length; + const occupiedBeds = beds.filter((bed) => bed.status === "occupied").length; + const availableBeds = beds.filter((bed) => bed.status === "available").length; + const activeAdmissions = admissions.filter((admission) => admission.status === "active").length; + const openIncidents = incidents.filter((incident) => incident.status !== "closed").length; + + const metrics: DashboardMetric[] = [ + { label: "在院老人", value: String(activeElders), trend: `待入住 ${pendingElders}`, icon: "users" }, + { label: "床位占用", value: `${occupiedBeds}/${beds.length}`, trend: `空闲 ${availableBeds}`, icon: "beds" }, + { label: "当前入住", value: String(activeAdmissions), trend: `历史 ${admissions.length}`, icon: "admissions" }, + { label: "未关闭故障", value: String(openIncidents), trend: `总计 ${incidents.length}`, icon: "incidents" }, + ]; + + const occupancyData = BED_STATUSES.map((status) => ({ + label: status, + count: beds.filter((bed) => bed.status === status).length, + })); + + const recentAdmissions = admissions.slice(0, 6).map((admission) => ({ + id: admission.id, + elderName: admission.elderName, + bedLabel: `${admission.roomName}-${admission.bedCode}`, + status: admission.status, + admittedAt: admission.admittedAt, + dischargedAt: admission.dischargedAt, + })); + + const visibleIncidents = incidents + .filter((incident) => incident.status !== "closed") + .slice(0, 6) + .map((incident) => ({ + id: incident.id, + severity: incident.severity, + status: incident.status, + title: incident.title, + source: incident.source, + createdAt: incident.createdAt, + })); + + return ( + + ); } diff --git a/app/(app)/app/elders/page.tsx b/app/(app)/app/elders/page.tsx index 06c4017..abe3f37 100644 --- a/app/(app)/app/elders/page.tsx +++ b/app/(app)/app/elders/page.tsx @@ -11,5 +11,10 @@ export default async function EldersPage(): Promise { } const data = await readData(); - return ; + const organizationId = context.organization?.id; + const elders = organizationId + ? data.elders.filter((elder) => elder.organizationId === organizationId) + : data.elders; + + return ; } diff --git a/app/api/admissions/[id]/route.ts b/app/api/admissions/[id]/route.ts new file mode 100644 index 0000000..db511b0 --- /dev/null +++ b/app/api/admissions/[id]/route.ts @@ -0,0 +1,109 @@ +import { and, eq } from "drizzle-orm"; + +import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api"; +import { recordAuditLog } from "@/modules/core/server/audit"; +import { requirePermission } from "@/modules/core/server/auth"; +import { getDatabase } from "@/modules/core/server/db"; +import { admissions, beds, elders } from "@/modules/core/server/schema"; + +type RouteContext = { + params: Promise<{ + id: string; + }>; +}; + +type AdmissionRow = typeof admissions.$inferSelect; + +type DischargeResult = + | { + success: true; + admission: AdmissionRow; + } + | { + success: false; + reason: string; + status: number; + }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readString(source: Record, key: string): string { + const value = source[key]; + return typeof value === "string" ? value.trim() : ""; +} + +export async function PATCH(request: Request, context: RouteContext): Promise { + const { id } = await context.params; + const auth = await requirePermission("admission:manage", { + action: "admission.discharge", + targetType: "admission", + targetId: id, + }); + + if (!auth.success) { + return auth.response; + } + + const organizationId = auth.context.organization?.id; + if (!organizationId) { + return jsonFailure("请选择机构后办理退住", 400); + } + + const body = await readJsonBody(request); + const notes = isRecord(body) ? readString(body, "notes") : ""; + + const database = getDatabase(); + const result = await database.transaction(async (transaction): Promise => { + const admissionRows = await transaction + .select() + .from(admissions) + .where(and(eq(admissions.id, id), eq(admissions.organizationId, organizationId))) + .limit(1); + const admission = admissionRows[0]; + if (!admission) { + return { success: false, reason: "入住记录不存在", status: 404 }; + } + + if (admission.status !== "active") { + return { success: false, reason: "该入住记录已结束", status: 409 }; + } + + const updatedRows = await transaction + .update(admissions) + .set({ + status: "discharged", + dischargedAt: new Date(), + notes: notes || admission.notes, + updatedAt: new Date(), + }) + .where(eq(admissions.id, admission.id)) + .returning(); + const updated = updatedRows[0]; + if (!updated) { + return { success: false, reason: "退住记录更新失败", status: 500 }; + } + + await transaction.update(beds).set({ status: "available", updatedAt: new Date() }).where(eq(beds.id, admission.bedId)); + await transaction.update(elders).set({ status: "discharged", updatedAt: new Date() }).where(eq(elders.id, admission.elderId)); + + return { success: true, admission: updated }; + }); + + if (!result.success) { + return jsonFailure(result.reason, result.status); + } + + await recordAuditLog({ + actor: auth.context.account, + organizationId, + action: "admission.discharge", + targetType: "admission", + targetId: result.admission.id, + result: "success", + reason: "办理退住", + }); + + return jsonSuccess("退住已办理", { admission: result.admission }); +} diff --git a/app/api/admissions/route.ts b/app/api/admissions/route.ts index 358b49a..3242ae3 100644 --- a/app/api/admissions/route.ts +++ b/app/api/admissions/route.ts @@ -7,6 +7,19 @@ import { getDatabase } from "@/modules/core/server/db"; import { admissions, beds, elders } from "@/modules/core/server/schema"; import { readData } from "@/modules/core/server/store"; +type AdmissionRow = typeof admissions.$inferSelect; + +type AdmissionMutationResult = + | { + success: true; + admission: AdmissionRow; + } + | { + success: false; + reason: string; + status: number; + }; + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -62,7 +75,7 @@ export async function POST(request: Request): Promise { } const database = getDatabase(); - const admission = await database.transaction(async (transaction) => { + const mutation = await database.transaction(async (transaction): Promise => { const elderRows = await transaction .select() .from(elders) @@ -70,7 +83,7 @@ export async function POST(request: Request): Promise { .limit(1); const elder = elderRows[0]; if (!elder) { - throw new Error("老人档案不存在"); + return { success: false, reason: "老人档案不存在", status: 404 }; } const bedRows = await transaction @@ -80,16 +93,22 @@ export async function POST(request: Request): Promise { .limit(1); const bed = bedRows[0]; if (!bed) { - throw new Error("床位不存在"); + return { success: false, reason: "床位不存在", status: 404 }; } if (bed.status !== "available") { - throw new Error("床位不可分配"); + return { success: false, reason: "床位不可分配", status: 409 }; } const activeAdmissions = await transaction .select() .from(admissions) - .where(and(eq(admissions.elderId, elderId), eq(admissions.status, "active"))); + .where( + and( + eq(admissions.elderId, elderId), + eq(admissions.organizationId, organizationId), + eq(admissions.status, "active"), + ), + ); await Promise.all( activeAdmissions.map((activeAdmission) => transaction @@ -116,23 +135,27 @@ export async function POST(request: Request): Promise { .returning(); const row = rows[0]; if (!row) { - throw new Error("入住记录创建失败"); + return { success: false, reason: "入住记录创建失败", status: 500 }; } await transaction.update(beds).set({ status: "occupied", updatedAt: new Date() }).where(eq(beds.id, bedId)); await transaction.update(elders).set({ status: "active", updatedAt: new Date() }).where(eq(elders.id, elderId)); - return row; + return { success: true, admission: row }; }); + if (!mutation.success) { + return jsonFailure(mutation.reason, mutation.status); + } + await recordAuditLog({ actor: auth.context.account, organizationId, action: "admission.create", targetType: "admission", - targetId: admission.id, + targetId: mutation.admission.id, result: "success", reason: "办理入住/换床", }); - return jsonSuccess("入住记录已创建", { admission }, 201); + return jsonSuccess("入住记录已创建", { admission: mutation.admission }, 201); } diff --git a/modules/dashboard/components/BedOccupancyChart.tsx b/modules/dashboard/components/BedOccupancyChart.tsx new file mode 100644 index 0000000..1b578f3 --- /dev/null +++ b/modules/dashboard/components/BedOccupancyChart.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; + +export type BedOccupancyDatum = { + label: string; + count: number; +}; + +type BedOccupancyChartProps = { + data: BedOccupancyDatum[]; +}; + +export function BedOccupancyChart({ data }: BedOccupancyChartProps): React.ReactElement { + return ( +
+ + + + + + + + + +
+ ); +} diff --git a/modules/dashboard/components/DashboardHome.tsx b/modules/dashboard/components/DashboardHome.tsx index 8397271..e257d9f 100644 --- a/modules/dashboard/components/DashboardHome.tsx +++ b/modules/dashboard/components/DashboardHome.tsx @@ -1,167 +1,196 @@ -import { - Activity, - AlertTriangle, - BedDouble, - CheckCircle2, - Clock3, - HeartPulse, - ShieldAlert, - Users, -} from "lucide-react"; +import { Activity, AlertTriangle, BedDouble, CheckCircle2, HeartPulse, Users } from "lucide-react"; import type { LucideIcon } 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 { Table } from "@/components/ui/table"; +import type { AdmissionStatus, BedStatus, IncidentSeverity } from "@/modules/core/types"; +import { BedOccupancyChart, type BedOccupancyDatum } from "@/modules/dashboard/components/BedOccupancyChart"; -type Metric = { +export type DashboardMetric = { label: string; value: string; trend: string; - icon: LucideIcon; + icon: "users" | "beds" | "admissions" | "incidents"; }; -const metrics: Metric[] = [ - { label: "在院", value: "126", trend: "+6 本周", icon: Users }, - { label: "床位", value: "82%", trend: "28 空闲", icon: BedDouble }, - { label: "任务", value: "184", trend: "142 完成", icon: CheckCircle2 }, - { label: "异常", value: "9", trend: "3 待复核", icon: HeartPulse }, -]; +export type DashboardAdmission = { + id: string; + elderName: string; + bedLabel: string; + status: AdmissionStatus; + admittedAt: string; + dischargedAt?: string; +}; -const taskRows = [ - { name: "晨间生命体征记录", owner: "护理一组", status: "进行中", due: "09:30" }, - { name: "二楼失能老人巡检", owner: "护理二组", status: "待执行", due: "10:00" }, - { name: "高血压老人复测", owner: "医护组", status: "需复核", due: "10:30" }, - { name: "晚间护理计划生成", owner: "护理主管", status: "待派发", due: "16:00" }, -]; +export type DashboardIncident = { + id: string; + severity: IncidentSeverity; + status: string; + title: string; + source: string; + createdAt: string; +}; -const alerts = [ - { label: "任务逾期", count: "4", tone: "warning" }, - { label: "设备故障", count: "2", tone: "danger" }, - { label: "应急处理中", count: "1", tone: "danger" }, - { label: "未读通知", count: "18", tone: "success" }, -] as const; +type DashboardHomeProps = { + metrics: DashboardMetric[]; + occupancyData: BedOccupancyDatum[]; + recentAdmissions: DashboardAdmission[]; + incidents: DashboardIncident[]; +}; -export function DashboardHome(): React.ReactElement { +const metricIcons: Record = { + users: Users, + beds: BedDouble, + admissions: CheckCircle2, + incidents: HeartPulse, +}; + +const admissionStatusLabels: Record = { + active: "在住", + transferred: "已换床", + discharged: "已退住", +}; + +const bedStatusLabels: Record = { + available: "空闲", + occupied: "占用", + maintenance: "维修", + disabled: "停用", +}; + +function formatDateTime(value: string): string { + return new Date(value).toLocaleString("zh-CN"); +} + +function incidentVariant(severity: IncidentSeverity): "secondary" | "warning" | "danger" { + switch (severity) { + case "critical": + return "danger"; + case "warning": + return "warning"; + case "info": + return "secondary"; + } +} + +export function DashboardHome({ + metrics, + occupancyData, + recentAdmissions, + incidents, +}: DashboardHomeProps): React.ReactElement { return (
-
-
- 一期 -
-

运营看板

-

照护、床位、预警一屏处理。

-
-
- - -
-
- -
-
-
-

今日闭环

-
-
-
- {["档案", "床位", "健康", "护理", "应急"].map((item, index) => ( -
- {item} -
-
-
- - {88 - index * 9}% - -
- ))} -
+
+
+ 实时数据 +

运营看板

+

老人、床位、入住和系统故障概况。

- {metrics.map((metric) => ( - - - {metric.label} - - - {metric.value} -

{metric.trend}

-
-
- ))} + {metrics.map((metric) => { + const Icon = metricIcons[metric.icon]; + + return ( + + + {metric.label} + + + {metric.value} +

{metric.trend}

+
+
+ ); + })}
-
+
- 今日护理任务 + + + + {occupancyData.map((item) => `${bedStatusLabels[item.label as BedStatus] ?? item.label} ${item.count}`).join(" / ")} + -
- + + + + + + + 最近入住记录 + + +
+
- 任务 - 负责 + 老人 + 床位 状态 - 截止 + 时间 - {taskRows.map((task) => ( - - {task.name} - {task.owner} + {recentAdmissions.map((admission) => ( + + {admission.elderName} + {admission.bedLabel} - - {task.status} + + {admissionStatusLabels[admission.status]} - {task.due} + + {formatDateTime(admission.dischargedAt ?? admission.admittedAt)} + ))} + {recentAdmissions.length === 0 ? ( + + + 暂无入住记录 + + + ) : null}
- - - - 预警与工单 - - - {alerts.map((alert) => ( -
-
- {alert.tone === "danger" ? ( -
- {alert.count} -
- ))} -
-
+ + + + 系统故障 + + + {incidents.map((incident) => ( +
+
+
+
+

+ {incident.source} / {formatDateTime(incident.createdAt)} +

+
+ {incident.status} +
+ ))} + {incidents.length === 0 ?

暂无未关闭故障

: null} +
+
); } diff --git a/modules/elders/components/EldersClient.tsx b/modules/elders/components/EldersClient.tsx index b0d9828..7f8683e 100644 --- a/modules/elders/components/EldersClient.tsx +++ b/modules/elders/components/EldersClient.tsx @@ -439,7 +439,7 @@ export function EldersClient({ initialElders, permissions }: EldersClientProps): = { + available: "空闲", + occupied: "占用", + maintenance: "维修", + disabled: "停用", +}; + +const admissionStatusLabels: Record = { + active: "在住", + transferred: "已换床", + discharged: "已退住", +}; + +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 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 })), + ]; +} + +export function BedsWorkspace({ + 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("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 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 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)}`, + })), + "选择在住记录", + ); + + async function refreshData(): Promise { + 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): Promise { + 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 submitTransfer(event: FormEvent): Promise { + 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 submitDischarge(event: FormEvent): Promise { + 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); + } + + return ( +
+
+
+ 床位工作区 +

床位房间

+

查看占用、办理入住、换床和退住。

+
+
+ {[ + { value: "overview", label: "概览" }, + { value: "operations", label: "办理" }, + { value: "history", label: "记录" }, + ].map((tab) => ( + + ))} +
+
+ + {message ? ( +

+ {message} +

+ ) : null} + + {activeTab === "overview" ? ( + <> +
+ + +
+ 房间 + {rooms.length} +
+
+
+ + +
+ 床位 + {beds.length} +
+
+ +

占用率 {occupancyRate}%

+
+
+ 0 ? "border-amber-200 bg-amber-50/70" : undefined}> + +
+ 0 ? "text-amber-700" : undefined}>维修床位 + 0 ? "mt-2 text-3xl text-amber-700" : "mt-2 text-3xl"}> + {maintenanceBeds} + +
+ 0 ? "size-5 text-amber-700" : "size-5 text-primary"} aria-hidden="true" /> +
+
+ + +
+ 在住 + {activeAdmissions.length} +
+
+
+
+ +
+ + + 房间台账 + + +
+ + + + 房间 + 编号 + 容量 + 床位 + 状态 + + + + {rooms.map((room) => ( + + {room.name} + {room.code} + {room.capacity} + + {room.occupiedBedCount}/{room.bedCount} + + + {room.status} + + + ))} + {rooms.length === 0 ? ( + + + 暂无房间主数据 + + + ) : null} + +
+
+
+
+ + + + 床位状态 + + +
+ + + + 房间 + 床位 + 状态 + 当前老人 + 备注 + + + + {beds.map((bed) => ( + + {bed.roomName} + {bed.code} + + {bedStatusLabels[bed.status]} + + {bed.currentElderName ?? "-"} + {bed.notes || "-"} + + ))} + {beds.length === 0 ? ( + + + 暂无床位主数据 + + + ) : null} + +
+
+
+
+
+ + ) : null} + + {activeTab === "operations" ? ( +
+ + + + + 选择未在住老人和空闲床位。 + + +
void submitAdmission(event)}> + +