feat: add elder bed operational context
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
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