feat: add elder bed operational context

This commit is contained in:
2026-07-03 00:46:17 -07:00
parent f620d6c3dd
commit 0ce8cb9644
9 changed files with 477 additions and 30 deletions

View File

@@ -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<string, ElderOperationalContext>`
- `bedContexts: Record<string, BedOperationalContext>`
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.

View File

@@ -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.

View File

@@ -3,7 +3,7 @@
"name": "elder-bed-context-optimization", "name": "elder-bed-context-optimization",
"title": "老人床位联动优化", "title": "老人床位联动优化",
"description": "", "description": "",
"status": "planning", "status": "in_progress",
"dev_type": null, "dev_type": null,
"scope": null, "scope": null,
"package": null, "package": null,

View File

@@ -9,6 +9,7 @@ import {
} from "@/modules/core/server/operations"; } from "@/modules/core/server/operations";
import { hasPermission } from "@/modules/core/server/permissions"; import { hasPermission } from "@/modules/core/server/permissions";
import { BedsWorkspace } from "@/modules/facilities/components/BedsWorkspace"; import { BedsWorkspace } from "@/modules/facilities/components/BedsWorkspace";
import { listElderBedContextData } from "@/modules/operations/server/context";
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing"; import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
export default async function BedsPage(): Promise<React.ReactElement> { export default async function BedsPage(): Promise<React.ReactElement> {
@@ -28,9 +29,19 @@ export default async function BedsPage(): Promise<React.ReactElement> {
listOperationalAdmissions(organizationId), listOperationalAdmissions(organizationId),
listOperationalElders(organizationId), listOperationalElders(organizationId),
]); ]);
const contextData = organizationId
? await listElderBedContextData({
organizationId,
permissions: context.permissions,
elders,
beds,
admissions,
})
: { bedContexts: {}, elderContexts: {} };
return ( return (
<BedsWorkspace <BedsWorkspace
bedContexts={contextData.bedContexts}
initialAdmissions={admissions} initialAdmissions={admissions}
initialBeds={beds} initialBeds={beds}
initialElders={elders} initialElders={elders}

View File

@@ -4,6 +4,7 @@ import { getCurrentAuthContext } from "@/modules/core/server/auth";
import { listOperationalElders } from "@/modules/core/server/operations"; import { listOperationalElders } from "@/modules/core/server/operations";
import { hasPermission } from "@/modules/core/server/permissions"; import { hasPermission } from "@/modules/core/server/permissions";
import { EldersClient } from "@/modules/elders/components/EldersClient"; import { EldersClient } from "@/modules/elders/components/EldersClient";
import { listElderBedContextData } from "@/modules/operations/server/context";
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing"; import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
export default async function EldersPage(): Promise<React.ReactElement> { export default async function EldersPage(): Promise<React.ReactElement> {
@@ -16,7 +17,19 @@ export default async function EldersPage(): Promise<React.ReactElement> {
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard")); 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 <EldersClient initialElders={elders} permissions={context.permissions} />; const elders = await listOperationalElders(organizationId);
const contextData = await listElderBedContextData({
organizationId,
permissions: context.permissions,
elders,
beds: [],
admissions: [],
});
return <EldersClient elderContexts={contextData.elderContexts} initialElders={elders} permissions={context.permissions} />;
} }

View File

@@ -14,8 +14,10 @@ import type { ApiResult } from "@/modules/core/server/api";
import type { Permission } from "@/modules/core/types"; import type { Permission } from "@/modules/core/types";
import type { CareLevel, Elder, ElderInput, ElderStatus } from "@/modules/elders/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 { CARE_LEVEL_LABELS, ELDER_STATUS_LABELS, GENDER_LABELS } from "@/modules/elders/types";
import type { ElderOperationalContext, OperationalContextLine } from "@/modules/operations/types";
type EldersClientProps = { type EldersClientProps = {
elderContexts?: Record<string, ElderOperationalContext>;
initialElders: Elder[]; initialElders: Elder[];
permissions: Permission[]; permissions: Permission[];
}; };
@@ -109,7 +111,7 @@ function matchesFilters(
return matchesSearch && matchesStatus && matchesCareLevel; 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 [elders, setElders] = useState(initialElders);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<ElderFormState>(emptyInput); const [form, setForm] = useState<ElderFormState>(emptyInput);
@@ -279,8 +281,7 @@ export function EldersClient({ initialElders, permissions }: EldersClientProps):
<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 flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between"> <section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
<div> <div>
<Badge variant="success"> CRUD</Badge> <h1 className="text-3xl font-semibold tracking-normal"></h1>
<h1 className="mt-3 text-3xl font-semibold tracking-normal"></h1>
<p className="mt-2 text-sm text-muted-foreground"></p> <p className="mt-2 text-sm text-muted-foreground"></p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -370,6 +371,7 @@ export function EldersClient({ initialElders, permissions }: EldersClientProps):
<Table.Head className="px-4 py-3 font-medium"></Table.Head> <Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head> <Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head> <Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head> <Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head> <Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 text-right font-medium"></Table.Head> <Table.Head className="px-4 py-3 text-right font-medium"></Table.Head>
@@ -386,6 +388,9 @@ export function EldersClient({ initialElders, permissions }: EldersClientProps):
</Table.Cell> </Table.Cell>
<Table.Cell className="px-4 py-3">{CARE_LEVEL_LABELS[elder.careLevel]}</Table.Cell> <Table.Cell className="px-4 py-3">{CARE_LEVEL_LABELS[elder.careLevel]}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatBedLabel(elder)}</Table.Cell> <Table.Cell className="px-4 py-3 text-muted-foreground">{formatBedLabel(elder)}</Table.Cell>
<Table.Cell className="px-4 py-3">
<ContextLines lines={elderContexts[elder.id]?.lines ?? []} />
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground"> <Table.Cell className="px-4 py-3 text-muted-foreground">
{elder.primaryContact} / {elder.phone} {elder.primaryContact} / {elder.phone}
</Table.Cell> </Table.Cell>
@@ -579,3 +584,29 @@ export function EldersClient({ initialElders, permissions }: EldersClientProps):
</div> </div>
); );
} }
function contextToneClass(tone: OperationalContextLine["tone"]): string {
if (tone === "danger") {
return "text-destructive";
}
if (tone === "warning") {
return "text-amber-700";
}
return "text-muted-foreground";
}
function ContextLines({ lines }: { lines: OperationalContextLine[] }): React.ReactElement {
if (lines.length === 0) {
return <span className="text-muted-foreground">-</span>;
}
return (
<div className="grid gap-1">
{lines.map((line) => (
<p className={`max-w-[240px] truncate text-xs ${contextToneClass(line.tone)}`} key={`${line.kind}-${line.label}-${line.value}`}>
<span className="font-medium">{line.label}</span> {line.value}
</p>
))}
</div>
);
}

View File

@@ -12,8 +12,10 @@ import { Table } from "@/components/ui/table";
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";
import type { BedOperationalContext, OperationalContextLine } from "@/modules/operations/types";
type BedsWorkspaceProps = { type BedsWorkspaceProps = {
bedContexts?: Record<string, BedOperationalContext>;
initialRooms: Room[]; initialRooms: Room[];
initialBeds: FacilityBed[]; initialBeds: FacilityBed[];
initialAdmissions: Admission[]; initialAdmissions: Admission[];
@@ -23,6 +25,12 @@ type BedsWorkspaceProps = {
type WorkspaceTab = "overview" | "operations" | "history"; type WorkspaceTab = "overview" | "operations" | "history";
const workspaceTabs: Array<{ value: WorkspaceTab; label: string }> = [
{ value: "overview", label: "概览" },
{ value: "operations", label: "办理" },
{ value: "history", label: "记录" },
];
const bedStatusLabels: Record<BedStatus, string> = { const bedStatusLabels: Record<BedStatus, string> = {
available: "空闲", available: "空闲",
occupied: "占用", occupied: "占用",
@@ -65,6 +73,7 @@ function makeSelectOptions(items: Array<{ id: string; label: string }>, emptyLab
} }
export function BedsWorkspace({ export function BedsWorkspace({
bedContexts = {},
initialRooms, initialRooms,
initialBeds, initialBeds,
initialAdmissions, initialAdmissions,
@@ -246,22 +255,14 @@ export function BedsWorkspace({
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 flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between"> <section className="flex border-b pb-4">
<div>
<Badge variant="success"></Badge>
<h1 className="mt-3 text-3xl font-semibold tracking-normal"></h1>
<p className="mt-2 text-sm text-muted-foreground">退</p>
</div>
<div className="flex flex-wrap gap-2" role="tablist" aria-label="床位工作区"> <div className="flex flex-wrap gap-2" role="tablist" aria-label="床位工作区">
{[ {workspaceTabs.map((tab) => (
{ value: "overview", label: "概览" },
{ value: "operations", label: "办理" },
{ value: "history", label: "记录" },
].map((tab) => (
<Button <Button
aria-selected={activeTab === tab.value} aria-selected={activeTab === tab.value}
key={tab.value} key={tab.value}
onClick={() => setActiveTab(tab.value as WorkspaceTab)} onClick={() => setActiveTab(tab.value)}
role="tab"
type="button" type="button"
variant={activeTab === tab.value ? "default" : "outline"} variant={activeTab === tab.value ? "default" : "outline"}
> >
@@ -380,24 +381,33 @@ export function BedsWorkspace({
<Table.Head className="px-4 py-3 font-medium"></Table.Head> <Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head> <Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head> <Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.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.Row>
</Table.Header> </Table.Header>
<Table.Body className="divide-y bg-card"> <Table.Body className="divide-y bg-card">
{beds.map((bed) => ( {beds.map((bed) => {
const context = bedContexts[bed.id];
return (
<Table.Row key={bed.id}> <Table.Row key={bed.id}>
<Table.Cell className="px-4 py-3">{bed.roomName}</Table.Cell> <Table.Cell className="px-4 py-3">{bed.roomName}</Table.Cell>
<Table.Cell className="px-4 py-3 font-medium">{bed.code}</Table.Cell> <Table.Cell className="px-4 py-3 font-medium">{bed.code}</Table.Cell>
<Table.Cell className="px-4 py-3"> <Table.Cell className="px-4 py-3">
<Badge variant={bedBadgeVariant(bed.status)}>{bedStatusLabels[bed.status]}</Badge> <Badge variant={bedBadgeVariant(bed.status)}>{bedStatusLabels[bed.status]}</Badge>
</Table.Cell> </Table.Cell>
<Table.Cell className="px-4 py-3">{bed.currentElderName ?? "-"}</Table.Cell> <Table.Cell className="px-4 py-3">
<OccupantSummary context={context} fallbackName={bed.currentElderName} />
</Table.Cell>
<Table.Cell className="px-4 py-3">
<ContextLines lines={context?.lines ?? []} />
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{bed.notes || "-"}</Table.Cell> <Table.Cell className="px-4 py-3 text-muted-foreground">{bed.notes || "-"}</Table.Cell>
</Table.Row> </Table.Row>
))} );
})}
{beds.length === 0 ? ( {beds.length === 0 ? (
<Table.Row> <Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={5}> <Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>
@@ -576,3 +586,50 @@ export function BedsWorkspace({
</div> </div>
); );
} }
function contextToneClass(tone: OperationalContextLine["tone"]): string {
if (tone === "danger") {
return "text-destructive";
}
if (tone === "warning") {
return "text-amber-700";
}
return "text-muted-foreground";
}
function ContextLines({ lines }: { lines: OperationalContextLine[] }): React.ReactElement {
if (lines.length === 0) {
return <span className="text-muted-foreground">-</span>;
}
return (
<div className="grid gap-1">
{lines.map((line) => (
<p className={`max-w-[260px] truncate text-xs ${contextToneClass(line.tone)}`} key={`${line.kind}-${line.label}-${line.value}`}>
<span className="font-medium">{line.label}</span> {line.value}
</p>
))}
</div>
);
}
function OccupantSummary({
context,
fallbackName,
}: {
context: BedOperationalContext | undefined;
fallbackName: string | undefined;
}): React.ReactElement {
if (!context?.occupant) {
return <span className="text-muted-foreground">{fallbackName ?? "-"}</span>;
}
return (
<div>
<p className="font-medium">{context.occupant.elderName}</p>
<p className="text-xs text-muted-foreground">
{context.occupant.careLevel} / {context.occupant.status}
</p>
</div>
);
}

View File

@@ -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<ElderBedContextData> {
const { admissions, beds, elders: elderList, organizationId, permissions } = input;
const elderContexts = Object.fromEntries(elderList.map((elder) => [elder.id, { lines: [] }])) as Record<string, ElderOperationalContext>;
const bedContexts = Object.fromEntries(beds.map((bed) => [bed.id, { lines: [] }])) as Record<string, BedOperationalContext>;
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<string>();
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<string>();
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<string>();
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 };
}

View File

@@ -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<string, BedOperationalContext>;
elderContexts: Record<string, ElderOperationalContext>;
};