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

@@ -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<string, ElderOperationalContext>;
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<string | null>(null);
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">
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<Badge variant="success"> CRUD</Badge>
<h1 className="mt-3 text-3xl font-semibold tracking-normal"></h1>
<h1 className="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">
@@ -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 text-right font-medium"></Table.Head>
@@ -386,6 +388,9 @@ export function EldersClient({ initialElders, permissions }: EldersClientProps):
</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">
<ContextLines lines={elderContexts[elder.id]?.lines ?? []} />
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">
{elder.primaryContact} / {elder.phone}
</Table.Cell>
@@ -579,3 +584,29 @@ export function EldersClient({ initialElders, permissions }: EldersClientProps):
</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 { 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<string, BedOperationalContext>;
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<BedStatus, string> = {
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 (
<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">
<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>
<section className="flex border-b pb-4">
<div className="flex flex-wrap gap-2" role="tablist" aria-label="床位工作区">
{[
{ value: "overview", label: "概览" },
{ value: "operations", label: "办理" },
{ value: "history", label: "记录" },
].map((tab) => (
{workspaceTabs.map((tab) => (
<Button
aria-selected={activeTab === tab.value}
key={tab.value}
onClick={() => setActiveTab(tab.value as WorkspaceTab)}
onClick={() => setActiveTab(tab.value)}
role="tab"
type="button"
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.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{beds.map((bed) => (
<Table.Row key={bed.id}>
<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">
<Badge variant={bedBadgeVariant(bed.status)}>{bedStatusLabels[bed.status]}</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3">{bed.currentElderName ?? "-"}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{bed.notes || "-"}</Table.Cell>
</Table.Row>
))}
{beds.map((bed) => {
const context = bedContexts[bed.id];
return (
<Table.Row key={bed.id}>
<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">
<Badge variant={bedBadgeVariant(bed.status)}>{bedStatusLabels[bed.status]}</Badge>
</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.Row>
);
})}
{beds.length === 0 ? (
<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.Row>
@@ -576,3 +586,50 @@ export function BedsWorkspace({
</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>;
};