feat: add elder bed operational context
This commit is contained in:
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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) {
|
||||||
return <EldersClient initialElders={elders} permissions={context.permissions} />;
|
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
199
modules/operations/server/context.ts
Normal file
199
modules/operations/server/context.ts
Normal 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 };
|
||||||
|
}
|
||||||
26
modules/operations/types.ts
Normal file
26
modules/operations/types.ts
Normal 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>;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user