From 0ce8cb9644433d49d8c22397f028f6c1a33a4e88 Mon Sep 17 00:00:00 2001 From: TalexDreamSoul Date: Fri, 3 Jul 2026 00:46:17 -0700 Subject: [PATCH] feat: add elder bed operational context --- .../design.md | 72 +++++++ .../implement.md | 38 ++++ .../task.json | 2 +- app/(app)/app/beds/page.tsx | 11 + app/(app)/app/elders/page.tsx | 17 +- modules/elders/components/EldersClient.tsx | 37 +++- .../facilities/components/BedsWorkspace.tsx | 105 ++++++--- modules/operations/server/context.ts | 199 ++++++++++++++++++ modules/operations/types.ts | 26 +++ 9 files changed, 477 insertions(+), 30 deletions(-) create mode 100644 .trellis/tasks/07-02-elder-bed-context-optimization/design.md create mode 100644 .trellis/tasks/07-02-elder-bed-context-optimization/implement.md create mode 100644 modules/operations/server/context.ts create mode 100644 modules/operations/types.ts diff --git a/.trellis/tasks/07-02-elder-bed-context-optimization/design.md b/.trellis/tasks/07-02-elder-bed-context-optimization/design.md new file mode 100644 index 0000000..5cfffbc --- /dev/null +++ b/.trellis/tasks/07-02-elder-bed-context-optimization/design.md @@ -0,0 +1,72 @@ +# Elder Bed Context Optimization Design + +## Summary + +Add read-only cross-module context to the existing elder and bed workspaces. The implementation should not merge workflows; it only surfaces concise recent signals from persisted admissions, care tasks, health records, and emergency incidents. + +## Data Boundary + +Use existing persisted tables: + +- admissions / beds / rooms for current bed state. +- care_tasks for recent and active care execution. +- vital_records and health_anomaly_reviews for health context. +- system_incidents for safety/emergency context. + +No schema change is required. `system_incidents` does not have elder/bed foreign keys yet, so emergency context is shown only when an incident's title, description, or source clearly mentions the elder name, bed code, or room label. + +## Permission Boundary + +Server pages decide which context to load: + +- `admission:read` / `facility:read`: bed/admission context. +- `care:read`: recent care task context. +- `health:read`: health anomalies and latest vitals. +- `incident:read`: matching open/acknowledged emergency context. + +Users without a permission should not receive that context in props. + +## Server Helper + +Add `modules/operations/server/context.ts`: + +- `listElderBedContextData(input)` +- input: + - `organizationId` + - `permissions` + - `elders` + - `beds` + - `admissions` +- output: + - `elderContexts: Record` + - `bedContexts: Record` + +The helper should batch queries and keep bounded result sizes. + +## UI Design + +### Elder list + +Add a compact "近期联动" column: + +- current bed/admission is already shown in the bed column. +- show up to three concise lines: + - latest active/recent care task. + - latest health anomaly or vital summary. + - matching open/acknowledged emergency event. +- If no permitted context exists, show `-`. + +### Bed workspace + +Add an occupant context column to the bed status table: + +- show current elder care level/status. +- show active/recent care task for that occupant. +- show matching emergency event for occupant or bed when present. +- Keep actions in the existing workflows; context is read-only. + +## Compatibility + +- Existing elder CRUD remains unchanged. +- Existing admission workflows remain unchanged. +- Client refresh continues to update elder/bed/admission tables; cross-module snippets are refreshed on page reload. diff --git a/.trellis/tasks/07-02-elder-bed-context-optimization/implement.md b/.trellis/tasks/07-02-elder-bed-context-optimization/implement.md new file mode 100644 index 0000000..98b607d --- /dev/null +++ b/.trellis/tasks/07-02-elder-bed-context-optimization/implement.md @@ -0,0 +1,38 @@ +# Implementation Plan + +## 1. Start Task And Read Specs + +- Start the Trellis task. +- Reuse existing frontend/backend specs. +- Preserve existing uncommitted UI cleanup in elder and bed components. + +## 2. Types And Server Helper + +- Add context DTO types in `modules/operations/types.ts`. +- Add `modules/operations/server/context.ts`. +- Batch-load care, health, and incident context based on permissions. +- Avoid fabricating emergency links; match text only when the persisted incident names the elder/bed/room. + +## 3. Wire Pages + +- Update `app/(app)/app/elders/page.tsx` to load and pass `elderContexts`. +- Update `app/(app)/app/beds/page.tsx` to load and pass `bedContexts`. + +## 4. Update Client Components + +- Extend `EldersClient` with optional `elderContexts`. +- Extend `BedsWorkspace` with optional `bedContexts`. +- Render compact read-only context lines. +- Preserve existing CRUD/admission behavior. + +## 5. Verification + +- `pnpm lint` +- `pnpm type-check` +- `pnpm test` +- `pnpm build` + +## Rollback + +- Remove operations context helper/types. +- Remove context props and columns from elder/bed clients/pages. diff --git a/.trellis/tasks/07-02-elder-bed-context-optimization/task.json b/.trellis/tasks/07-02-elder-bed-context-optimization/task.json index 7866d85..d0617fc 100644 --- a/.trellis/tasks/07-02-elder-bed-context-optimization/task.json +++ b/.trellis/tasks/07-02-elder-bed-context-optimization/task.json @@ -3,7 +3,7 @@ "name": "elder-bed-context-optimization", "title": "老人床位联动优化", "description": "", - "status": "planning", + "status": "in_progress", "dev_type": null, "scope": null, "package": null, diff --git a/app/(app)/app/beds/page.tsx b/app/(app)/app/beds/page.tsx index 7f038c4..74a0f76 100644 --- a/app/(app)/app/beds/page.tsx +++ b/app/(app)/app/beds/page.tsx @@ -9,6 +9,7 @@ import { } from "@/modules/core/server/operations"; import { hasPermission } from "@/modules/core/server/permissions"; import { BedsWorkspace } from "@/modules/facilities/components/BedsWorkspace"; +import { listElderBedContextData } from "@/modules/operations/server/context"; import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing"; export default async function BedsPage(): Promise { @@ -28,9 +29,19 @@ export default async function BedsPage(): Promise { listOperationalAdmissions(organizationId), listOperationalElders(organizationId), ]); + const contextData = organizationId + ? await listElderBedContextData({ + organizationId, + permissions: context.permissions, + elders, + beds, + admissions, + }) + : { bedContexts: {}, elderContexts: {} }; return ( { @@ -16,7 +17,19 @@ export default async function EldersPage(): Promise { redirect(getWorkspaceHref(context.organization?.slug, "/dashboard")); } - const elders = await listOperationalElders(context.organization?.id); + const organizationId = context.organization?.id; + if (!organizationId) { + redirect(getWorkspaceHref(context.organization?.slug, "/dashboard")); + } - return ; + const elders = await listOperationalElders(organizationId); + const contextData = await listElderBedContextData({ + organizationId, + permissions: context.permissions, + elders, + beds: [], + admissions: [], + }); + + return ; } diff --git a/modules/elders/components/EldersClient.tsx b/modules/elders/components/EldersClient.tsx index 4b00cba..03e61a6 100644 --- a/modules/elders/components/EldersClient.tsx +++ b/modules/elders/components/EldersClient.tsx @@ -14,8 +14,10 @@ import type { ApiResult } from "@/modules/core/server/api"; import type { Permission } from "@/modules/core/types"; import type { CareLevel, Elder, ElderInput, ElderStatus } from "@/modules/elders/types"; import { CARE_LEVEL_LABELS, ELDER_STATUS_LABELS, GENDER_LABELS } from "@/modules/elders/types"; +import type { ElderOperationalContext, OperationalContextLine } from "@/modules/operations/types"; type EldersClientProps = { + elderContexts?: Record; initialElders: Elder[]; permissions: Permission[]; }; @@ -109,7 +111,7 @@ function matchesFilters( return matchesSearch && matchesStatus && matchesCareLevel; } -export function EldersClient({ initialElders, permissions }: EldersClientProps): React.ReactElement { +export function EldersClient({ elderContexts = {}, initialElders, permissions }: EldersClientProps): React.ReactElement { const [elders, setElders] = useState(initialElders); const [editingId, setEditingId] = useState(null); const [form, setForm] = useState(emptyInput); @@ -279,8 +281,7 @@ export function EldersClient({ initialElders, permissions }: EldersClientProps):
- 全栈 CRUD -

老人档案

+

老人档案

列表管理、筛选搜索、分页、弹窗维护和审计持久化。

@@ -370,6 +371,7 @@ export function EldersClient({ initialElders, permissions }: EldersClientProps): 姓名 照护 床位 + 近期联动 联系人 状态 操作 @@ -386,6 +388,9 @@ export function EldersClient({ initialElders, permissions }: EldersClientProps): {CARE_LEVEL_LABELS[elder.careLevel]} {formatBedLabel(elder)} + + + {elder.primaryContact} / {elder.phone} @@ -579,3 +584,29 @@ export function EldersClient({ initialElders, permissions }: EldersClientProps):
); } + +function contextToneClass(tone: OperationalContextLine["tone"]): string { + if (tone === "danger") { + return "text-destructive"; + } + if (tone === "warning") { + return "text-amber-700"; + } + return "text-muted-foreground"; +} + +function ContextLines({ lines }: { lines: OperationalContextLine[] }): React.ReactElement { + if (lines.length === 0) { + return -; + } + + return ( +
+ {lines.map((line) => ( +

+ {line.label} {line.value} +

+ ))} +
+ ); +} diff --git a/modules/facilities/components/BedsWorkspace.tsx b/modules/facilities/components/BedsWorkspace.tsx index 6a6fe58..bc1169d 100644 --- a/modules/facilities/components/BedsWorkspace.tsx +++ b/modules/facilities/components/BedsWorkspace.tsx @@ -12,8 +12,10 @@ import { Table } from "@/components/ui/table"; import type { ApiResult } from "@/modules/core/server/api"; import type { Admission, BedStatus, FacilityBed, Permission, Room } from "@/modules/core/types"; import type { Elder } from "@/modules/elders/types"; +import type { BedOperationalContext, OperationalContextLine } from "@/modules/operations/types"; type BedsWorkspaceProps = { + bedContexts?: Record; initialRooms: Room[]; initialBeds: FacilityBed[]; initialAdmissions: Admission[]; @@ -23,6 +25,12 @@ type BedsWorkspaceProps = { type WorkspaceTab = "overview" | "operations" | "history"; +const workspaceTabs: Array<{ value: WorkspaceTab; label: string }> = [ + { value: "overview", label: "概览" }, + { value: "operations", label: "办理" }, + { value: "history", label: "记录" }, +]; + const bedStatusLabels: Record = { available: "空闲", occupied: "占用", @@ -65,6 +73,7 @@ function makeSelectOptions(items: Array<{ id: string; label: string }>, emptyLab } export function BedsWorkspace({ + bedContexts = {}, initialRooms, initialBeds, initialAdmissions, @@ -246,22 +255,14 @@ export function BedsWorkspace({ return (
-
-
- 床位工作区 -

床位房间

-

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

-
+
- {[ - { value: "overview", label: "概览" }, - { value: "operations", label: "办理" }, - { value: "history", label: "记录" }, - ].map((tab) => ( + {workspaceTabs.map((tab) => (
); } + +function contextToneClass(tone: OperationalContextLine["tone"]): string { + if (tone === "danger") { + return "text-destructive"; + } + if (tone === "warning") { + return "text-amber-700"; + } + return "text-muted-foreground"; +} + +function ContextLines({ lines }: { lines: OperationalContextLine[] }): React.ReactElement { + if (lines.length === 0) { + return -; + } + + return ( +
+ {lines.map((line) => ( +

+ {line.label} {line.value} +

+ ))} +
+ ); +} + +function OccupantSummary({ + context, + fallbackName, +}: { + context: BedOperationalContext | undefined; + fallbackName: string | undefined; +}): React.ReactElement { + if (!context?.occupant) { + return {fallbackName ?? "-"}; + } + + return ( +
+

{context.occupant.elderName}

+

+ {context.occupant.careLevel} / {context.occupant.status} +

+
+ ); +} diff --git a/modules/operations/server/context.ts b/modules/operations/server/context.ts new file mode 100644 index 0000000..047f869 --- /dev/null +++ b/modules/operations/server/context.ts @@ -0,0 +1,199 @@ +import { and, desc, eq, inArray, or } from "drizzle-orm"; + +import { getDatabase } from "@/modules/core/server/db"; +import { careTasks, healthAnomalyReviews, systemIncidents, vitalRecords } from "@/modules/core/server/schema"; +import type { Admission, FacilityBed, Permission } from "@/modules/core/types"; +import type { Elder } from "@/modules/elders/types"; +import { CARE_LEVEL_LABELS, ELDER_STATUS_LABELS } from "@/modules/elders/types"; +import { CARE_TASK_STATUS_LABELS, CARE_TASK_TYPE_LABELS } from "@/modules/care/types"; +import { HEALTH_REVIEW_SEVERITY_LABELS, HEALTH_REVIEW_STATUS_LABELS } from "@/modules/health/types"; +import { INCIDENT_SEVERITY_LABELS, INCIDENT_STATUS_LABELS } from "@/modules/emergency/types"; +import type { BedOperationalContext, ElderBedContextData, ElderOperationalContext, OperationalContextLine } from "@/modules/operations/types"; + +type ContextInput = { + admissions: Admission[]; + beds: FacilityBed[]; + elders: Elder[]; + organizationId: string; + permissions: readonly Permission[]; +}; + +function hasPermission(permissions: readonly Permission[], permission: Permission): boolean { + return permissions.includes(permission); +} + +function addLine(target: { lines: OperationalContextLine[] }, line: OperationalContextLine): void { + if (target.lines.length >= 3) { + return; + } + + target.lines.push(line); +} + +function includesText(source: string, token: string): boolean { + return token.length > 0 && source.includes(token); +} + +export async function listElderBedContextData(input: ContextInput): Promise { + const { admissions, beds, elders: elderList, organizationId, permissions } = input; + const elderContexts = Object.fromEntries(elderList.map((elder) => [elder.id, { lines: [] }])) as Record; + const bedContexts = Object.fromEntries(beds.map((bed) => [bed.id, { lines: [] }])) as Record; + const elderById = new Map(elderList.map((elder) => [elder.id, elder])); + const activeAdmissions = admissions.filter((admission) => admission.status === "active"); + + for (const admission of activeAdmissions) { + const elder = elderById.get(admission.elderId); + const bedContext = bedContexts[admission.bedId]; + if (!elder || !bedContext) { + continue; + } + + bedContext.occupant = { + elderName: elder.name, + careLevel: CARE_LEVEL_LABELS[elder.careLevel], + status: ELDER_STATUS_LABELS[elder.status], + }; + } + + const database = getDatabase(); + const elderIds = elderList.map((elder) => elder.id); + if (elderIds.length === 0) { + return { bedContexts, elderContexts }; + } + + const [careRows, reviewRows, vitalRows, incidentRows] = await Promise.all([ + hasPermission(permissions, "care:read") + ? database + .select() + .from(careTasks) + .where(and(eq(careTasks.organizationId, organizationId), inArray(careTasks.elderId, elderIds))) + .orderBy(desc(careTasks.scheduledAt)) + .limit(60) + : [], + hasPermission(permissions, "health:read") + ? database + .select() + .from(healthAnomalyReviews) + .where(and(eq(healthAnomalyReviews.organizationId, organizationId), inArray(healthAnomalyReviews.elderId, elderIds))) + .orderBy(desc(healthAnomalyReviews.createdAt)) + .limit(60) + : [], + hasPermission(permissions, "health:read") + ? database + .select() + .from(vitalRecords) + .where(and(eq(vitalRecords.organizationId, organizationId), inArray(vitalRecords.elderId, elderIds))) + .orderBy(desc(vitalRecords.recordedAt)) + .limit(60) + : [], + hasPermission(permissions, "incident:read") + ? database + .select() + .from(systemIncidents) + .where( + and( + eq(systemIncidents.organizationId, organizationId), + or(eq(systemIncidents.status, "open"), eq(systemIncidents.status, "acknowledged")), + ), + ) + .orderBy(desc(systemIncidents.createdAt)) + .limit(60) + : [], + ]); + + const bedIdByElderId = new Map(activeAdmissions.map((admission) => [admission.elderId, admission.bedId])); + const seenCareElders = new Set(); + for (const task of careRows) { + if (!task.elderId || seenCareElders.has(task.elderId)) { + continue; + } + + const line: OperationalContextLine = { + kind: "care", + label: "护理", + value: `${CARE_TASK_TYPE_LABELS[task.careType]} / ${CARE_TASK_STATUS_LABELS[task.status]}`, + tone: task.status === "pending" || task.status === "in_progress" ? "warning" : "muted", + }; + const elderContext = elderContexts[task.elderId]; + if (elderContext) { + addLine(elderContext, line); + } + const bedId = bedIdByElderId.get(task.elderId); + const bedContext = bedId ? bedContexts[bedId] : undefined; + if (bedContext) { + addLine(bedContext, line); + } + seenCareElders.add(task.elderId); + } + + const seenReviewElders = new Set(); + for (const review of reviewRows) { + if (seenReviewElders.has(review.elderId)) { + continue; + } + + const line: OperationalContextLine = { + kind: "health", + label: "健康", + value: `${HEALTH_REVIEW_SEVERITY_LABELS[review.severity]} / ${HEALTH_REVIEW_STATUS_LABELS[review.status]}:${review.title}`, + tone: review.severity === "critical" ? "danger" : "warning", + }; + const elderContext = elderContexts[review.elderId]; + if (elderContext) { + addLine(elderContext, line); + } + const bedId = bedIdByElderId.get(review.elderId); + const bedContext = bedId ? bedContexts[bedId] : undefined; + if (bedContext) { + addLine(bedContext, line); + } + seenReviewElders.add(review.elderId); + } + + const seenVitalElders = new Set(); + for (const vital of vitalRows) { + if (seenReviewElders.has(vital.elderId) || seenVitalElders.has(vital.elderId)) { + continue; + } + + const bp = vital.systolicBp && vital.diastolicBp ? `血压 ${vital.systolicBp}/${vital.diastolicBp}` : ""; + const spo2 = vital.spo2 === null ? "" : `血氧 ${vital.spo2}%`; + const value = [bp, spo2].filter(Boolean).join(" · ") || "最近体征已记录"; + const line: OperationalContextLine = { kind: "health", label: "体征", value, tone: "muted" }; + const elderContext = elderContexts[vital.elderId]; + if (elderContext) { + addLine(elderContext, line); + } + seenVitalElders.add(vital.elderId); + } + + for (const incident of incidentRows) { + const haystack = [incident.title, incident.description, incident.source].join(" "); + for (const elder of elderList) { + const bedId = bedIdByElderId.get(elder.id); + const bed = bedId ? beds.find((item) => item.id === bedId) : undefined; + const matchesElder = includesText(haystack, elder.name); + const matchesBed = bed ? includesText(haystack, bed.code) || includesText(haystack, bed.roomName) : false; + if (!matchesElder && !matchesBed) { + continue; + } + + const line: OperationalContextLine = { + kind: "incident", + label: "应急", + value: `${INCIDENT_SEVERITY_LABELS[incident.severity]} / ${INCIDENT_STATUS_LABELS[incident.status]}:${incident.title}`, + tone: incident.severity === "critical" ? "danger" : "warning", + }; + const elderContext = elderContexts[elder.id]; + if (elderContext) { + addLine(elderContext, line); + } + const bedContext = bedId ? bedContexts[bedId] : undefined; + if (bedContext) { + addLine(bedContext, line); + } + } + } + + return { bedContexts, elderContexts }; +} diff --git a/modules/operations/types.ts b/modules/operations/types.ts new file mode 100644 index 0000000..82ff166 --- /dev/null +++ b/modules/operations/types.ts @@ -0,0 +1,26 @@ +export type ContextLineKind = "care" | "health" | "incident" | "admission"; + +export type OperationalContextLine = { + kind: ContextLineKind; + label: string; + value: string; + tone?: "danger" | "muted" | "warning"; +}; + +export type ElderOperationalContext = { + lines: OperationalContextLine[]; +}; + +export type BedOperationalContext = { + occupant?: { + careLevel: string; + elderName: string; + status: string; + }; + lines: OperationalContextLine[]; +}; + +export type ElderBedContextData = { + bedContexts: Record; + elderContexts: Record; +};