feat: add health data management workspace
This commit is contained in:
@@ -5,6 +5,7 @@ import { cookies } from "next/headers";
|
||||
|
||||
import { jsonFailure } from "@/modules/core/server/api";
|
||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||
import { seedDefaultWorkspaceData } from "@/modules/core/server/default-workspace-data";
|
||||
import { getDatabase } from "@/modules/core/server/db";
|
||||
import { getPermissionsForRole, hasPermission, seedOrganizationRoles } from "@/modules/core/server/permissions";
|
||||
import {
|
||||
@@ -275,6 +276,7 @@ export async function setupFirstPlatformAdmin(input: {
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
await seedDefaultWorkspaceData(created.organization.id);
|
||||
|
||||
const organization = toOrganization(created.organization);
|
||||
const publicAccount = toPublicAccount(created.account, created.platformRole.key, organization);
|
||||
|
||||
677
modules/core/server/default-workspace-data.ts
Normal file
677
modules/core/server/default-workspace-data.ts
Normal file
@@ -0,0 +1,677 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
import { getDatabase } from "@/modules/core/server/db";
|
||||
import {
|
||||
admissions,
|
||||
beds,
|
||||
buildings,
|
||||
campuses,
|
||||
chronicConditions,
|
||||
elders,
|
||||
floors,
|
||||
healthAnomalyReviews,
|
||||
healthProfiles,
|
||||
rooms,
|
||||
systemIncidents,
|
||||
vitalRecords,
|
||||
} from "@/modules/core/server/schema";
|
||||
|
||||
type SeedRoom = {
|
||||
buildingName: string;
|
||||
campusName: string;
|
||||
code: string;
|
||||
floorLevel: number;
|
||||
floorName: string;
|
||||
name: string;
|
||||
capacity: number;
|
||||
};
|
||||
|
||||
type SeedBed = {
|
||||
code: string;
|
||||
notes: string;
|
||||
roomCode: string;
|
||||
status: "available" | "occupied" | "maintenance" | "disabled";
|
||||
};
|
||||
|
||||
type SeedElder = {
|
||||
age: number;
|
||||
careLevel: "self-care" | "semi-assisted" | "assisted" | "intensive";
|
||||
gender: "male" | "female" | "other";
|
||||
medicalNotes: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
primaryContact: string;
|
||||
status: "active" | "pending" | "discharged";
|
||||
};
|
||||
|
||||
type SeedAdmission = {
|
||||
bedCode: string;
|
||||
elderName: string;
|
||||
notes: string;
|
||||
status: "active" | "transferred" | "discharged";
|
||||
};
|
||||
|
||||
type SeedHealthProfile = {
|
||||
allergyNotes: string;
|
||||
careRestrictions: string;
|
||||
elderName: string;
|
||||
emergencyNotes: string;
|
||||
medicalHistory: string;
|
||||
medicationNotes: string;
|
||||
};
|
||||
|
||||
type SeedVitalRecord = {
|
||||
bloodGlucoseTenths?: number;
|
||||
diastolicBp?: number;
|
||||
elderName: string;
|
||||
heartRate?: number;
|
||||
notes: string;
|
||||
recordedHoursAgo: number;
|
||||
source: "manual" | "device" | "import";
|
||||
spo2?: number;
|
||||
systolicBp?: number;
|
||||
temperatureTenths?: number;
|
||||
weightTenths?: number;
|
||||
};
|
||||
|
||||
type SeedChronicCondition = {
|
||||
elderName: string;
|
||||
followUpNotes: string;
|
||||
name: string;
|
||||
status: "active" | "controlled" | "resolved";
|
||||
treatmentNotes: string;
|
||||
};
|
||||
|
||||
type SeedHealthReview = {
|
||||
description: string;
|
||||
elderName: string;
|
||||
resolutionNotes: string;
|
||||
severity: "info" | "warning" | "critical";
|
||||
status: "pending" | "reviewed" | "resolved";
|
||||
title: string;
|
||||
vitalIndex: number;
|
||||
};
|
||||
|
||||
const DEFAULT_ROOMS: SeedRoom[] = [
|
||||
{ campusName: "主院区", buildingName: "护理楼", floorName: "一层", floorLevel: 1, code: "A101", name: "阳光房", capacity: 2 },
|
||||
{ campusName: "主院区", buildingName: "护理楼", floorName: "一层", floorLevel: 1, code: "A102", name: "康养房", capacity: 2 },
|
||||
{ campusName: "主院区", buildingName: "护理楼", floorName: "二层", floorLevel: 2, code: "A201", name: "记忆照护房", capacity: 2 },
|
||||
{ campusName: "主院区", buildingName: "护理楼", floorName: "二层", floorLevel: 2, code: "A202", name: "重点照护房", capacity: 2 },
|
||||
{ campusName: "主院区", buildingName: "康复楼", floorName: "一层", floorLevel: 1, code: "R101", name: "康复观察房", capacity: 2 },
|
||||
{ campusName: "主院区", buildingName: "康复楼", floorName: "一层", floorLevel: 1, code: "R102", name: "短住评估房", capacity: 2 },
|
||||
{ campusName: "北苑院区", buildingName: "颐养楼", floorName: "一层", floorLevel: 1, code: "B101", name: "自理房", capacity: 2 },
|
||||
{ campusName: "北苑院区", buildingName: "颐养楼", floorName: "二层", floorLevel: 2, code: "B201", name: "亲情套房", capacity: 2 },
|
||||
];
|
||||
|
||||
const DEFAULT_BEDS: SeedBed[] = [
|
||||
{ code: "A101-1", roomCode: "A101", status: "occupied", notes: "靠窗,已完成入住评估" },
|
||||
{ code: "A101-2", roomCode: "A101", status: "available", notes: "可安排半护理老人入住" },
|
||||
{ code: "A102-1", roomCode: "A102", status: "occupied", notes: "夜间巡护重点关注" },
|
||||
{ code: "A102-2", roomCode: "A102", status: "maintenance", notes: "床头呼叫器检修中" },
|
||||
{ code: "A201-1", roomCode: "A201", status: "occupied", notes: "记忆照护专区" },
|
||||
{ code: "A201-2", roomCode: "A201", status: "available", notes: "靠近护理站" },
|
||||
{ code: "A202-1", roomCode: "A202", status: "occupied", notes: "压疮风险重点巡查" },
|
||||
{ code: "A202-2", roomCode: "A202", status: "disabled", notes: "待更换护栏" },
|
||||
{ code: "R101-1", roomCode: "R101", status: "occupied", notes: "康复训练后休息床位" },
|
||||
{ code: "R101-2", roomCode: "R101", status: "available", notes: "临时观察床位" },
|
||||
{ code: "R102-1", roomCode: "R102", status: "available", notes: "短住评估床位" },
|
||||
{ code: "R102-2", roomCode: "R102", status: "available", notes: "短住评估床位" },
|
||||
{ code: "B101-1", roomCode: "B101", status: "occupied", notes: "自理老人长期床位" },
|
||||
{ code: "B101-2", roomCode: "B101", status: "available", notes: "低照护需求床位" },
|
||||
{ code: "B201-1", roomCode: "B201", status: "occupied", notes: "亲属探访便利" },
|
||||
{ code: "B201-2", roomCode: "B201", status: "available", notes: "亲情套房备用床" },
|
||||
];
|
||||
|
||||
const DEFAULT_ELDERS: SeedElder[] = [
|
||||
{
|
||||
name: "王桂兰",
|
||||
gender: "female",
|
||||
age: 82,
|
||||
careLevel: "intensive",
|
||||
status: "active",
|
||||
primaryContact: "张敏",
|
||||
phone: "13800000001",
|
||||
medicalNotes: "高血压,需晨晚血压记录。",
|
||||
},
|
||||
{
|
||||
name: "李建国",
|
||||
gender: "male",
|
||||
age: 76,
|
||||
careLevel: "semi-assisted",
|
||||
status: "active",
|
||||
primaryContact: "李明",
|
||||
phone: "13800000002",
|
||||
medicalNotes: "餐后散步陪同,关注睡眠情况。",
|
||||
},
|
||||
{
|
||||
name: "周玉珍",
|
||||
gender: "female",
|
||||
age: 88,
|
||||
careLevel: "assisted",
|
||||
status: "active",
|
||||
primaryContact: "周强",
|
||||
phone: "13800000003",
|
||||
medicalNotes: "糖尿病饮食,餐前血糖提醒。",
|
||||
},
|
||||
{
|
||||
name: "赵国华",
|
||||
gender: "male",
|
||||
age: 91,
|
||||
careLevel: "intensive",
|
||||
status: "active",
|
||||
primaryContact: "赵琳",
|
||||
phone: "13800000004",
|
||||
medicalNotes: "夜间翻身,两小时巡房。",
|
||||
},
|
||||
{
|
||||
name: "孙秀梅",
|
||||
gender: "female",
|
||||
age: 73,
|
||||
careLevel: "semi-assisted",
|
||||
status: "active",
|
||||
primaryContact: "孙磊",
|
||||
phone: "13800000005",
|
||||
medicalNotes: "膝关节康复训练,每日下午一次。",
|
||||
},
|
||||
{
|
||||
name: "刘长青",
|
||||
gender: "male",
|
||||
age: 68,
|
||||
careLevel: "self-care",
|
||||
status: "active",
|
||||
primaryContact: "刘洋",
|
||||
phone: "13800000006",
|
||||
medicalNotes: "自理,需定期复查用药。",
|
||||
},
|
||||
{
|
||||
name: "陈秀英",
|
||||
gender: "female",
|
||||
age: 69,
|
||||
careLevel: "self-care",
|
||||
status: "pending",
|
||||
primaryContact: "陈佳",
|
||||
phone: "13800000007",
|
||||
medicalNotes: "待完成入住评估。",
|
||||
},
|
||||
{
|
||||
name: "何德明",
|
||||
gender: "male",
|
||||
age: 84,
|
||||
careLevel: "assisted",
|
||||
status: "pending",
|
||||
primaryContact: "何静",
|
||||
phone: "13800000008",
|
||||
medicalNotes: "家属已提交体检资料,等待床位确认。",
|
||||
},
|
||||
{
|
||||
name: "吴佩兰",
|
||||
gender: "female",
|
||||
age: 79,
|
||||
careLevel: "semi-assisted",
|
||||
status: "discharged",
|
||||
primaryContact: "吴昊",
|
||||
phone: "13800000009",
|
||||
medicalNotes: "已退住,保留历史档案。",
|
||||
},
|
||||
{
|
||||
name: "郑文远",
|
||||
gender: "male",
|
||||
age: 72,
|
||||
careLevel: "self-care",
|
||||
status: "discharged",
|
||||
primaryContact: "郑洁",
|
||||
phone: "13800000010",
|
||||
medicalNotes: "短住康复结束后退住。",
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_ADMISSIONS: SeedAdmission[] = [
|
||||
{ elderName: "王桂兰", bedCode: "A101-1", status: "active", notes: "默认工作区入住记录" },
|
||||
{ elderName: "李建国", bedCode: "A102-1", status: "active", notes: "默认工作区入住记录" },
|
||||
{ elderName: "周玉珍", bedCode: "A201-1", status: "active", notes: "记忆照护专区入住" },
|
||||
{ elderName: "赵国华", bedCode: "A202-1", status: "active", notes: "重点照护入住" },
|
||||
{ elderName: "孙秀梅", bedCode: "R101-1", status: "active", notes: "康复观察入住" },
|
||||
{ elderName: "刘长青", bedCode: "B101-1", status: "active", notes: "自理区入住" },
|
||||
{ elderName: "吴佩兰", bedCode: "B201-1", status: "discharged", notes: "历史退住记录" },
|
||||
];
|
||||
|
||||
const DEFAULT_HEALTH_PROFILES: SeedHealthProfile[] = [
|
||||
{
|
||||
elderName: "王桂兰",
|
||||
allergyNotes: "青霉素过敏",
|
||||
medicalHistory: "高血压十余年,偶发胸闷。",
|
||||
medicationNotes: "晨服降压药,晚间复核血压。",
|
||||
careRestrictions: "低盐饮食,避免剧烈活动。",
|
||||
emergencyNotes: "胸闷或血压持续升高时立即联系医生。",
|
||||
},
|
||||
{
|
||||
elderName: "周玉珍",
|
||||
allergyNotes: "无明确药物过敏史",
|
||||
medicalHistory: "2 型糖尿病,轻度认知障碍。",
|
||||
medicationNotes: "餐前血糖监测,按医嘱用药。",
|
||||
careRestrictions: "控糖饮食,夜间加强巡护。",
|
||||
emergencyNotes: "低血糖症状时立即补糖并记录。",
|
||||
},
|
||||
{
|
||||
elderName: "赵国华",
|
||||
allergyNotes: "海鲜过敏",
|
||||
medicalHistory: "脑梗后遗症,长期卧床风险。",
|
||||
medicationNotes: "抗凝药按时服用。",
|
||||
careRestrictions: "两小时翻身,防跌倒,防压疮。",
|
||||
emergencyNotes: "意识异常或血氧下降时立即启动应急流程。",
|
||||
},
|
||||
{
|
||||
elderName: "刘长青",
|
||||
allergyNotes: "",
|
||||
medicalHistory: "冠心病随访。",
|
||||
medicationNotes: "自主管理用药,护理员每日提醒。",
|
||||
careRestrictions: "避免爬楼和长时间站立。",
|
||||
emergencyNotes: "胸痛需立即报告。",
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_VITAL_RECORDS: SeedVitalRecord[] = [
|
||||
{
|
||||
elderName: "王桂兰",
|
||||
recordedHoursAgo: 2,
|
||||
source: "manual",
|
||||
systolicBp: 158,
|
||||
diastolicBp: 96,
|
||||
heartRate: 88,
|
||||
temperatureTenths: 367,
|
||||
spo2: 96,
|
||||
bloodGlucoseTenths: 62,
|
||||
weightTenths: 612,
|
||||
notes: "血压偏高,已安排复测。",
|
||||
},
|
||||
{
|
||||
elderName: "李建国",
|
||||
recordedHoursAgo: 5,
|
||||
source: "device",
|
||||
systolicBp: 128,
|
||||
diastolicBp: 78,
|
||||
heartRate: 76,
|
||||
temperatureTenths: 365,
|
||||
spo2: 98,
|
||||
notes: "体征平稳。",
|
||||
},
|
||||
{
|
||||
elderName: "周玉珍",
|
||||
recordedHoursAgo: 7,
|
||||
source: "manual",
|
||||
systolicBp: 134,
|
||||
diastolicBp: 82,
|
||||
heartRate: 80,
|
||||
temperatureTenths: 366,
|
||||
spo2: 97,
|
||||
bloodGlucoseTenths: 118,
|
||||
notes: "餐后血糖偏高,已记录饮食。",
|
||||
},
|
||||
{
|
||||
elderName: "赵国华",
|
||||
recordedHoursAgo: 12,
|
||||
source: "manual",
|
||||
systolicBp: 146,
|
||||
diastolicBp: 88,
|
||||
heartRate: 92,
|
||||
temperatureTenths: 381,
|
||||
spo2: 91,
|
||||
notes: "体温和血氧异常,需复核。",
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_CHRONIC_CONDITIONS: SeedChronicCondition[] = [
|
||||
{
|
||||
elderName: "王桂兰",
|
||||
name: "高血压",
|
||||
status: "active",
|
||||
treatmentNotes: "规律服药,晨晚血压监测。",
|
||||
followUpNotes: "每周汇总血压趋势。",
|
||||
},
|
||||
{
|
||||
elderName: "周玉珍",
|
||||
name: "糖尿病",
|
||||
status: "active",
|
||||
treatmentNotes: "控糖饮食,餐前餐后血糖记录。",
|
||||
followUpNotes: "异常血糖进入复核队列。",
|
||||
},
|
||||
{
|
||||
elderName: "刘长青",
|
||||
name: "冠心病",
|
||||
status: "controlled",
|
||||
treatmentNotes: "按医嘱服药,避免劳累。",
|
||||
followUpNotes: "月度复诊提醒。",
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_HEALTH_REVIEWS: SeedHealthReview[] = [
|
||||
{
|
||||
elderName: "王桂兰",
|
||||
vitalIndex: 0,
|
||||
severity: "warning",
|
||||
status: "pending",
|
||||
title: "血压异常",
|
||||
description: "收缩压和舒张压偏高,需要复测并确认用药。",
|
||||
resolutionNotes: "",
|
||||
},
|
||||
{
|
||||
elderName: "赵国华",
|
||||
vitalIndex: 3,
|
||||
severity: "critical",
|
||||
status: "pending",
|
||||
title: "血氧和体温异常",
|
||||
description: "血氧低于阈值且体温偏高,需要立即复核。",
|
||||
resolutionNotes: "",
|
||||
},
|
||||
{
|
||||
elderName: "周玉珍",
|
||||
vitalIndex: 2,
|
||||
severity: "info",
|
||||
status: "reviewed",
|
||||
title: "餐后血糖偏高",
|
||||
description: "餐后血糖高于日常水平。",
|
||||
resolutionNotes: "已调整晚餐主食并安排次日复测。",
|
||||
},
|
||||
];
|
||||
|
||||
function hasRows(rows: unknown[]): boolean {
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
export async function seedDefaultWorkspaceData(organizationId: string): Promise<void> {
|
||||
const database = getDatabase();
|
||||
const [existingRooms, existingBeds, existingElders, existingAdmissions] = await Promise.all([
|
||||
database.select({ id: rooms.id }).from(rooms).where(eq(rooms.organizationId, organizationId)).limit(1),
|
||||
database.select({ id: beds.id }).from(beds).where(eq(beds.organizationId, organizationId)).limit(1),
|
||||
database.select({ id: elders.id }).from(elders).where(eq(elders.organizationId, organizationId)).limit(1),
|
||||
database.select({ id: admissions.id }).from(admissions).where(eq(admissions.organizationId, organizationId)).limit(1),
|
||||
]);
|
||||
|
||||
if ([existingRooms, existingBeds, existingElders, existingAdmissions].some(hasRows)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await database.transaction(async (transaction) => {
|
||||
const uniqueCampusNames = Array.from(new Set(DEFAULT_ROOMS.map((room) => room.campusName)));
|
||||
const campusRows = await transaction
|
||||
.insert(campuses)
|
||||
.values(
|
||||
uniqueCampusNames.map((name) => ({
|
||||
organizationId,
|
||||
name,
|
||||
address: `${name}养老服务院区`,
|
||||
})),
|
||||
)
|
||||
.returning();
|
||||
if (campusRows.length !== uniqueCampusNames.length) {
|
||||
throw new Error("默认院区初始化失败");
|
||||
}
|
||||
const campusIdByName = new Map(campusRows.map((campus) => [campus.name, campus.id]));
|
||||
|
||||
const uniqueBuildings = Array.from(
|
||||
new Map(DEFAULT_ROOMS.map((room) => [`${room.campusName}/${room.buildingName}`, room])).values(),
|
||||
);
|
||||
const buildingRows = await transaction
|
||||
.insert(buildings)
|
||||
.values(
|
||||
uniqueBuildings.map((building) => {
|
||||
const campusId = campusIdByName.get(building.campusName);
|
||||
if (!campusId) {
|
||||
throw new Error("默认楼栋初始化失败");
|
||||
}
|
||||
|
||||
return {
|
||||
organizationId,
|
||||
campusId,
|
||||
name: building.buildingName,
|
||||
};
|
||||
}),
|
||||
)
|
||||
.returning();
|
||||
if (buildingRows.length !== uniqueBuildings.length) {
|
||||
throw new Error("默认楼栋初始化失败");
|
||||
}
|
||||
const buildingIdByKey = new Map(
|
||||
buildingRows.map((building, index) => {
|
||||
const source = uniqueBuildings[index];
|
||||
if (!source) {
|
||||
throw new Error("默认楼栋初始化失败");
|
||||
}
|
||||
|
||||
return [`${source.campusName}/${source.buildingName}`, building.id];
|
||||
}),
|
||||
);
|
||||
|
||||
const uniqueFloors = Array.from(
|
||||
new Map(
|
||||
DEFAULT_ROOMS.map((room) => [
|
||||
`${room.campusName}/${room.buildingName}/${room.floorLevel}`,
|
||||
room,
|
||||
]),
|
||||
).values(),
|
||||
);
|
||||
const floorRows = await transaction
|
||||
.insert(floors)
|
||||
.values(
|
||||
uniqueFloors.map((floor) => {
|
||||
const buildingId = buildingIdByKey.get(`${floor.campusName}/${floor.buildingName}`);
|
||||
if (!buildingId) {
|
||||
throw new Error("默认楼层初始化失败");
|
||||
}
|
||||
|
||||
return {
|
||||
organizationId,
|
||||
buildingId,
|
||||
name: floor.floorName,
|
||||
level: floor.floorLevel,
|
||||
};
|
||||
}),
|
||||
)
|
||||
.returning();
|
||||
if (floorRows.length !== uniqueFloors.length) {
|
||||
throw new Error("默认楼层初始化失败");
|
||||
}
|
||||
const floorIdByKey = new Map(
|
||||
floorRows.map((floor, index) => {
|
||||
const source = uniqueFloors[index];
|
||||
if (!source) {
|
||||
throw new Error("默认楼层初始化失败");
|
||||
}
|
||||
|
||||
return [`${source.campusName}/${source.buildingName}/${source.floorLevel}`, floor.id];
|
||||
}),
|
||||
);
|
||||
|
||||
const roomRows = await transaction
|
||||
.insert(rooms)
|
||||
.values(
|
||||
DEFAULT_ROOMS.map((room) => {
|
||||
const floorId = floorIdByKey.get(`${room.campusName}/${room.buildingName}/${room.floorLevel}`);
|
||||
if (!floorId) {
|
||||
throw new Error("默认房间初始化失败");
|
||||
}
|
||||
|
||||
return {
|
||||
organizationId,
|
||||
floorId,
|
||||
name: room.name,
|
||||
code: room.code,
|
||||
capacity: room.capacity,
|
||||
};
|
||||
}),
|
||||
)
|
||||
.returning();
|
||||
if (roomRows.length !== DEFAULT_ROOMS.length) {
|
||||
throw new Error("默认房间初始化失败");
|
||||
}
|
||||
|
||||
const roomIdByCode = new Map(roomRows.map((room) => [room.code, room.id]));
|
||||
const bedRows = await transaction
|
||||
.insert(beds)
|
||||
.values(
|
||||
DEFAULT_BEDS.map((bed) => {
|
||||
const roomId = roomIdByCode.get(bed.roomCode);
|
||||
if (!roomId) {
|
||||
throw new Error("默认床位初始化失败");
|
||||
}
|
||||
|
||||
return {
|
||||
organizationId,
|
||||
roomId,
|
||||
code: bed.code,
|
||||
status: bed.status,
|
||||
notes: bed.notes,
|
||||
};
|
||||
}),
|
||||
)
|
||||
.returning();
|
||||
if (bedRows.length !== DEFAULT_BEDS.length) {
|
||||
throw new Error("默认床位初始化失败");
|
||||
}
|
||||
|
||||
const elderRows = await transaction
|
||||
.insert(elders)
|
||||
.values(DEFAULT_ELDERS.map((elder) => ({ ...elder, organizationId })))
|
||||
.returning();
|
||||
if (elderRows.length !== DEFAULT_ELDERS.length) {
|
||||
throw new Error("默认老人档案初始化失败");
|
||||
}
|
||||
|
||||
const elderIdByName = new Map(elderRows.map((elder) => [elder.name, elder.id]));
|
||||
const bedIdByCode = new Map(bedRows.map((bed) => [bed.code, bed.id]));
|
||||
await transaction.insert(admissions).values(
|
||||
DEFAULT_ADMISSIONS.map((admission) => {
|
||||
const elderId = elderIdByName.get(admission.elderName);
|
||||
const bedId = bedIdByCode.get(admission.bedCode);
|
||||
if (!elderId || !bedId) {
|
||||
throw new Error("默认入住初始化失败");
|
||||
}
|
||||
|
||||
return {
|
||||
organizationId,
|
||||
elderId,
|
||||
bedId,
|
||||
status: admission.status,
|
||||
dischargedAt: admission.status === "discharged" ? new Date() : undefined,
|
||||
notes: admission.notes,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
await transaction.insert(healthProfiles).values(
|
||||
DEFAULT_HEALTH_PROFILES.map((profile) => {
|
||||
const elderId = elderIdByName.get(profile.elderName);
|
||||
if (!elderId) {
|
||||
throw new Error("默认健康档案初始化失败");
|
||||
}
|
||||
|
||||
return {
|
||||
organizationId,
|
||||
elderId,
|
||||
allergyNotes: profile.allergyNotes,
|
||||
medicalHistory: profile.medicalHistory,
|
||||
medicationNotes: profile.medicationNotes,
|
||||
careRestrictions: profile.careRestrictions,
|
||||
emergencyNotes: profile.emergencyNotes,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
const vitalRows = await transaction
|
||||
.insert(vitalRecords)
|
||||
.values(
|
||||
DEFAULT_VITAL_RECORDS.map((vital) => {
|
||||
const elderId = elderIdByName.get(vital.elderName);
|
||||
if (!elderId) {
|
||||
throw new Error("默认生命体征初始化失败");
|
||||
}
|
||||
|
||||
return {
|
||||
organizationId,
|
||||
elderId,
|
||||
recordedAt: new Date(now.getTime() - vital.recordedHoursAgo * 60 * 60 * 1000),
|
||||
source: vital.source,
|
||||
systolicBp: vital.systolicBp,
|
||||
diastolicBp: vital.diastolicBp,
|
||||
heartRate: vital.heartRate,
|
||||
temperatureTenths: vital.temperatureTenths,
|
||||
spo2: vital.spo2,
|
||||
bloodGlucoseTenths: vital.bloodGlucoseTenths,
|
||||
weightTenths: vital.weightTenths,
|
||||
notes: vital.notes,
|
||||
};
|
||||
}),
|
||||
)
|
||||
.returning();
|
||||
if (vitalRows.length !== DEFAULT_VITAL_RECORDS.length) {
|
||||
throw new Error("默认生命体征初始化失败");
|
||||
}
|
||||
|
||||
await transaction.insert(chronicConditions).values(
|
||||
DEFAULT_CHRONIC_CONDITIONS.map((condition) => {
|
||||
const elderId = elderIdByName.get(condition.elderName);
|
||||
if (!elderId) {
|
||||
throw new Error("默认慢病记录初始化失败");
|
||||
}
|
||||
|
||||
return {
|
||||
organizationId,
|
||||
elderId,
|
||||
name: condition.name,
|
||||
status: condition.status,
|
||||
treatmentNotes: condition.treatmentNotes,
|
||||
followUpNotes: condition.followUpNotes,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
await transaction.insert(healthAnomalyReviews).values(
|
||||
DEFAULT_HEALTH_REVIEWS.map((review) => {
|
||||
const elderId = elderIdByName.get(review.elderName);
|
||||
const vital = vitalRows[review.vitalIndex];
|
||||
if (!elderId || !vital) {
|
||||
throw new Error("默认健康异常复核初始化失败");
|
||||
}
|
||||
|
||||
return {
|
||||
organizationId,
|
||||
elderId,
|
||||
vitalRecordId: vital.id,
|
||||
severity: review.severity,
|
||||
status: review.status,
|
||||
title: review.title,
|
||||
description: review.description,
|
||||
resolutionNotes: review.resolutionNotes,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
await transaction.insert(systemIncidents).values([
|
||||
{
|
||||
organizationId,
|
||||
severity: "warning",
|
||||
status: "open",
|
||||
title: "床头呼叫器待检修",
|
||||
description: "A102-2 床位呼叫器处于检修状态,完成后可恢复使用。",
|
||||
source: "默认工作区初始化",
|
||||
},
|
||||
{
|
||||
organizationId,
|
||||
severity: "info",
|
||||
status: "acknowledged",
|
||||
title: "本周护理评估待完成",
|
||||
description: "陈秀英、何德明等待入住评估,可在老人档案和床位工作区继续处理。",
|
||||
source: "默认工作区初始化",
|
||||
},
|
||||
{
|
||||
organizationId,
|
||||
severity: "critical",
|
||||
status: "resolved",
|
||||
title: "夜间巡房异常已处理",
|
||||
description: "重点照护房夜间巡房提醒已恢复,保留事件用于状态页验证。",
|
||||
source: "默认工作区初始化",
|
||||
},
|
||||
]);
|
||||
});
|
||||
}
|
||||
@@ -25,6 +25,8 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
|
||||
{ id: "audit:read", label: "查看审计", category: "审计", description: "查看关键操作审计日志。" },
|
||||
{ id: "incident:read", label: "查看故障", category: "运维", description: "查看系统健康和故障中心。" },
|
||||
{ id: "incident:manage", label: "处理故障", category: "运维", description: "确认、处理和关闭故障事件。" },
|
||||
{ id: "health:read", label: "查看健康数据", category: "健康", description: "查看老人健康档案、生命体征和异常复核。" },
|
||||
{ id: "health:manage", label: "管理健康数据", category: "健康", description: "维护健康档案、生命体征、慢病和异常复核。" },
|
||||
{ id: "facility:read", label: "查看房间床位", category: "床位", description: "查看房间、床位和占用状态。" },
|
||||
{ id: "facility:manage", label: "管理房间床位", category: "床位", description: "维护院区、房间、床位和床位状态。" },
|
||||
{ id: "admission:read", label: "查看入住", category: "入住", description: "查看入住、换床、退住历史。" },
|
||||
@@ -89,6 +91,8 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [
|
||||
"permission:read",
|
||||
"audit:read",
|
||||
"incident:read",
|
||||
"health:read",
|
||||
"health:manage",
|
||||
"facility:read",
|
||||
"facility:manage",
|
||||
"admission:read",
|
||||
@@ -107,6 +111,8 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [
|
||||
"account:read",
|
||||
"role:read",
|
||||
"audit:read",
|
||||
"health:read",
|
||||
"health:manage",
|
||||
"facility:read",
|
||||
"facility:manage",
|
||||
"admission:read",
|
||||
@@ -121,13 +127,13 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [
|
||||
key: "caregiver",
|
||||
scope: "organization",
|
||||
description: "照护人员,查看床位和维护老人照护信息。",
|
||||
permissions: ["facility:read", "admission:read", "elder:read", "elder:update"],
|
||||
permissions: ["health:read", "health:manage", "facility:read", "admission:read", "elder:read", "elder:update"],
|
||||
},
|
||||
{
|
||||
key: "viewer",
|
||||
scope: "organization",
|
||||
description: "普通用户,只读查看老人、床位和入住信息。",
|
||||
permissions: ["facility:read", "admission:read", "elder:read"],
|
||||
permissions: ["health:read", "facility:read", "admission:read", "elder:read"],
|
||||
},
|
||||
{
|
||||
key: "family",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
@@ -23,6 +24,9 @@ export const auditResultEnum = pgEnum("audit_result", ["success", "denied", "fai
|
||||
export const incidentStatusEnum = pgEnum("incident_status", ["open", "acknowledged", "resolved", "closed"]);
|
||||
export const incidentSeverityEnum = pgEnum("incident_severity", ["info", "warning", "critical"]);
|
||||
export const joinRequestStatusEnum = pgEnum("join_request_status", ["pending", "approved", "rejected"]);
|
||||
export const vitalRecordSourceEnum = pgEnum("vital_record_source", ["manual", "device", "import"]);
|
||||
export const chronicConditionStatusEnum = pgEnum("chronic_condition_status", ["active", "controlled", "resolved"]);
|
||||
export const healthReviewStatusEnum = pgEnum("health_review_status", ["pending", "reviewed", "resolved"]);
|
||||
|
||||
export const systemSettings = pgTable("system_settings", {
|
||||
id: text("id").primaryKey().default("global"),
|
||||
@@ -226,6 +230,75 @@ export const elders = pgTable("elders", {
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const healthProfiles = pgTable("health_profiles", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||||
elderId: uuid("elder_id").notNull().references(() => elders.id, { onDelete: "cascade" }),
|
||||
allergyNotes: text("allergy_notes").notNull().default(""),
|
||||
medicalHistory: text("medical_history").notNull().default(""),
|
||||
medicationNotes: text("medication_notes").notNull().default(""),
|
||||
careRestrictions: text("care_restrictions").notNull().default(""),
|
||||
emergencyNotes: text("emergency_notes").notNull().default(""),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => ({
|
||||
elderUnique: uniqueIndex("health_profiles_organization_elder_unique").on(table.organizationId, table.elderId),
|
||||
}));
|
||||
|
||||
export const vitalRecords = pgTable("vital_records", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||||
elderId: uuid("elder_id").notNull().references(() => elders.id, { onDelete: "cascade" }),
|
||||
recordedAt: timestamp("recorded_at", { withTimezone: true }).notNull(),
|
||||
source: vitalRecordSourceEnum("source").notNull().default("manual"),
|
||||
systolicBp: integer("systolic_bp"),
|
||||
diastolicBp: integer("diastolic_bp"),
|
||||
heartRate: integer("heart_rate"),
|
||||
temperatureTenths: integer("temperature_tenths"),
|
||||
spo2: integer("spo2"),
|
||||
bloodGlucoseTenths: integer("blood_glucose_tenths"),
|
||||
weightTenths: integer("weight_tenths"),
|
||||
notes: text("notes").notNull().default(""),
|
||||
createdByAccountId: uuid("created_by_account_id").references(() => accounts.id),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => ({
|
||||
recentLookup: index("vital_records_recent_lookup").on(table.organizationId, table.elderId, table.recordedAt),
|
||||
}));
|
||||
|
||||
export const chronicConditions = pgTable("chronic_conditions", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||||
elderId: uuid("elder_id").notNull().references(() => elders.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
status: chronicConditionStatusEnum("status").notNull().default("active"),
|
||||
diagnosedAt: timestamp("diagnosed_at", { withTimezone: true }),
|
||||
treatmentNotes: text("treatment_notes").notNull().default(""),
|
||||
followUpNotes: text("follow_up_notes").notNull().default(""),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => ({
|
||||
elderStatusLookup: index("chronic_conditions_elder_status_lookup").on(table.organizationId, table.elderId, table.status),
|
||||
}));
|
||||
|
||||
export const healthAnomalyReviews = pgTable("health_anomaly_reviews", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||||
elderId: uuid("elder_id").notNull().references(() => elders.id, { onDelete: "cascade" }),
|
||||
vitalRecordId: uuid("vital_record_id").references(() => vitalRecords.id, { onDelete: "set null" }),
|
||||
severity: incidentSeverityEnum("severity").notNull(),
|
||||
status: healthReviewStatusEnum("status").notNull().default("pending"),
|
||||
title: text("title").notNull(),
|
||||
description: text("description").notNull(),
|
||||
reviewedByAccountId: uuid("reviewed_by_account_id").references(() => accounts.id),
|
||||
reviewedAt: timestamp("reviewed_at", { withTimezone: true }),
|
||||
resolutionNotes: text("resolution_notes").notNull().default(""),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => ({
|
||||
reviewQueueLookup: index("health_anomaly_reviews_queue_lookup").on(table.organizationId, table.status, table.severity),
|
||||
}));
|
||||
|
||||
export const admissions = pgTable("admissions", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||||
|
||||
@@ -39,6 +39,8 @@ export const PERMISSIONS = [
|
||||
"audit:read",
|
||||
"incident:read",
|
||||
"incident:manage",
|
||||
"health:read",
|
||||
"health:manage",
|
||||
"facility:read",
|
||||
"facility:manage",
|
||||
"admission:read",
|
||||
|
||||
822
modules/health/components/HealthAdminClient.tsx
Normal file
822
modules/health/components/HealthAdminClient.tsx
Normal file
@@ -0,0 +1,822 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useMemo, useState } from "react";
|
||||
import { Activity, ClipboardPlus, FileText, HeartPulse, Search, ShieldAlert, Stethoscope } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import { Input, Textarea } from "@/components/ui/input";
|
||||
import { Select, type SelectOption } from "@/components/ui/select";
|
||||
import { Table } from "@/components/ui/table";
|
||||
import type { ApiResult } from "@/modules/core/server/api";
|
||||
import type {
|
||||
ChronicCondition,
|
||||
ChronicConditionStatus,
|
||||
HealthAdminData,
|
||||
HealthAnomalyReview,
|
||||
HealthProfile,
|
||||
HealthReviewSeverity,
|
||||
HealthReviewStatus,
|
||||
VitalRecord,
|
||||
VitalSource,
|
||||
} from "@/modules/health/types";
|
||||
import {
|
||||
CHRONIC_CONDITION_STATUS_LABELS,
|
||||
HEALTH_REVIEW_SEVERITY_LABELS,
|
||||
HEALTH_REVIEW_STATUS_LABELS,
|
||||
VITAL_SOURCE_LABELS,
|
||||
} from "@/modules/health/types";
|
||||
|
||||
type HealthAdminClientProps = {
|
||||
canManage: boolean;
|
||||
initialData: HealthAdminData;
|
||||
};
|
||||
|
||||
type HealthTab = "profiles" | "vitals" | "conditions" | "reviews";
|
||||
|
||||
type ProfileForm = {
|
||||
allergyNotes: string;
|
||||
medicalHistory: string;
|
||||
medicationNotes: string;
|
||||
careRestrictions: string;
|
||||
emergencyNotes: string;
|
||||
};
|
||||
|
||||
const tabs: Array<{ value: HealthTab; label: string }> = [
|
||||
{ value: "profiles", label: "健康档案" },
|
||||
{ value: "vitals", label: "生命体征" },
|
||||
{ value: "conditions", label: "慢病记录" },
|
||||
{ value: "reviews", label: "异常复核" },
|
||||
];
|
||||
|
||||
const emptyProfileForm: ProfileForm = {
|
||||
allergyNotes: "",
|
||||
medicalHistory: "",
|
||||
medicationNotes: "",
|
||||
careRestrictions: "",
|
||||
emergencyNotes: "",
|
||||
};
|
||||
|
||||
const emptyVitalForm = {
|
||||
elderId: "",
|
||||
recordedAt: "",
|
||||
source: "manual" as VitalSource,
|
||||
systolicBp: "",
|
||||
diastolicBp: "",
|
||||
heartRate: "",
|
||||
temperatureTenths: "",
|
||||
spo2: "",
|
||||
bloodGlucoseTenths: "",
|
||||
weightTenths: "",
|
||||
notes: "",
|
||||
};
|
||||
|
||||
const emptyConditionForm = {
|
||||
elderId: "",
|
||||
name: "",
|
||||
status: "active" as ChronicConditionStatus,
|
||||
diagnosedAt: "",
|
||||
treatmentNotes: "",
|
||||
followUpNotes: "",
|
||||
};
|
||||
|
||||
function formatDateTime(value: string | undefined): string {
|
||||
return value ? new Date(value).toLocaleString("zh-CN") : "-";
|
||||
}
|
||||
|
||||
function formatTenths(value: number | undefined, suffix = ""): string {
|
||||
return value === undefined ? "-" : `${(value / 10).toFixed(1)}${suffix}`;
|
||||
}
|
||||
|
||||
function optionalNumber(value: string): number | undefined {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const numeric = Number(trimmed);
|
||||
return Number.isFinite(numeric) ? numeric : undefined;
|
||||
}
|
||||
|
||||
function optionalTenths(value: string): number | undefined {
|
||||
const numeric = optionalNumber(value);
|
||||
return numeric === undefined ? undefined : Math.round(numeric * 10);
|
||||
}
|
||||
|
||||
function profileToForm(profile: HealthProfile | undefined): ProfileForm {
|
||||
return profile
|
||||
? {
|
||||
allergyNotes: profile.allergyNotes,
|
||||
medicalHistory: profile.medicalHistory,
|
||||
medicationNotes: profile.medicationNotes,
|
||||
careRestrictions: profile.careRestrictions,
|
||||
emergencyNotes: profile.emergencyNotes,
|
||||
}
|
||||
: emptyProfileForm;
|
||||
}
|
||||
|
||||
function severityBadgeVariant(severity: HealthReviewSeverity): "secondary" | "warning" | "danger" {
|
||||
if (severity === "critical") {
|
||||
return "danger";
|
||||
}
|
||||
|
||||
return severity === "warning" ? "warning" : "secondary";
|
||||
}
|
||||
|
||||
function reviewStatusBadgeVariant(status: HealthReviewStatus): "success" | "secondary" | "warning" {
|
||||
if (status === "pending") {
|
||||
return "warning";
|
||||
}
|
||||
|
||||
return status === "resolved" ? "success" : "secondary";
|
||||
}
|
||||
|
||||
export function HealthAdminClient({ canManage, initialData }: HealthAdminClientProps): React.ReactElement {
|
||||
const [data, setData] = useState(initialData);
|
||||
const [activeTab, setActiveTab] = useState<HealthTab>("profiles");
|
||||
const [query, setQuery] = useState("");
|
||||
const [vitalSourceFilter, setVitalSourceFilter] = useState<"all" | VitalSource>("all");
|
||||
const [conditionStatusFilter, setConditionStatusFilter] = useState<"all" | ChronicConditionStatus>("all");
|
||||
const [reviewStatusFilter, setReviewStatusFilter] = useState<"all" | HealthReviewStatus>("all");
|
||||
const [reviewSeverityFilter, setReviewSeverityFilter] = useState<"all" | HealthReviewSeverity>("all");
|
||||
const [message, setMessage] = useState("");
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [editingProfile, setEditingProfile] = useState<HealthProfile | undefined>();
|
||||
const [profileElderId, setProfileElderId] = useState("");
|
||||
const [profileForm, setProfileForm] = useState<ProfileForm>(emptyProfileForm);
|
||||
const [isVitalOpen, setIsVitalOpen] = useState(false);
|
||||
const [vitalForm, setVitalForm] = useState(emptyVitalForm);
|
||||
const [isConditionOpen, setIsConditionOpen] = useState(false);
|
||||
const [conditionForm, setConditionForm] = useState(emptyConditionForm);
|
||||
const [reviewTarget, setReviewTarget] = useState<HealthAnomalyReview | undefined>();
|
||||
const [reviewStatus, setReviewStatus] = useState<HealthReviewStatus>("reviewed");
|
||||
const [reviewNotes, setReviewNotes] = useState("");
|
||||
|
||||
const elderOptions: SelectOption[] = useMemo(
|
||||
() => [
|
||||
{ value: "", label: "选择老人", disabled: true },
|
||||
...data.elders.map((elder) => ({ value: elder.id, label: elder.name })),
|
||||
],
|
||||
[data.elders],
|
||||
);
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const profilesByElderId = useMemo(
|
||||
() => new Map(data.profiles.map((profile) => [profile.elderId, profile])),
|
||||
[data.profiles],
|
||||
);
|
||||
const filteredElders = useMemo(
|
||||
() =>
|
||||
data.elders.filter((elder) => {
|
||||
if (!normalizedQuery) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const profile = profilesByElderId.get(elder.id);
|
||||
return [elder.name, profile?.allergyNotes, profile?.medicalHistory, profile?.emergencyNotes]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(normalizedQuery);
|
||||
}),
|
||||
[data.elders, normalizedQuery, profilesByElderId],
|
||||
);
|
||||
const filteredVitals = useMemo(
|
||||
() =>
|
||||
data.vitals.filter((vital) => {
|
||||
const matchesQuery = !normalizedQuery || vital.elderName.toLowerCase().includes(normalizedQuery);
|
||||
const matchesSource = vitalSourceFilter === "all" || vital.source === vitalSourceFilter;
|
||||
return matchesQuery && matchesSource;
|
||||
}),
|
||||
[data.vitals, normalizedQuery, vitalSourceFilter],
|
||||
);
|
||||
const filteredConditions = useMemo(
|
||||
() =>
|
||||
data.chronicConditions.filter((condition) => {
|
||||
const matchesQuery = !normalizedQuery
|
||||
? true
|
||||
: [condition.elderName, condition.name].join(" ").toLowerCase().includes(normalizedQuery);
|
||||
const matchesStatus = conditionStatusFilter === "all" || condition.status === conditionStatusFilter;
|
||||
return matchesQuery && matchesStatus;
|
||||
}),
|
||||
[conditionStatusFilter, data.chronicConditions, normalizedQuery],
|
||||
);
|
||||
const filteredReviews = useMemo(
|
||||
() =>
|
||||
data.reviews.filter((review) => {
|
||||
const matchesQuery = !normalizedQuery
|
||||
? true
|
||||
: [review.elderName, review.title, review.description].join(" ").toLowerCase().includes(normalizedQuery);
|
||||
const matchesStatus = reviewStatusFilter === "all" || review.status === reviewStatusFilter;
|
||||
const matchesSeverity = reviewSeverityFilter === "all" || review.severity === reviewSeverityFilter;
|
||||
return matchesQuery && matchesStatus && matchesSeverity;
|
||||
}),
|
||||
[data.reviews, normalizedQuery, reviewSeverityFilter, reviewStatusFilter],
|
||||
);
|
||||
|
||||
async function refreshData(): Promise<void> {
|
||||
const response = await fetch("/api/health/admin");
|
||||
const result = (await response.json()) as ApiResult<{ data: HealthAdminData }>;
|
||||
if (result.success) {
|
||||
setData(result.data);
|
||||
}
|
||||
}
|
||||
|
||||
function openProfileDialog(elderId: string): void {
|
||||
const profile = profilesByElderId.get(elderId);
|
||||
setEditingProfile(profile);
|
||||
setProfileElderId(elderId);
|
||||
setProfileForm(profileToForm(profile));
|
||||
setMessage("");
|
||||
}
|
||||
|
||||
function closeProfileDialog(): void {
|
||||
if (isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEditingProfile(undefined);
|
||||
setProfileElderId("");
|
||||
setProfileForm(emptyProfileForm);
|
||||
}
|
||||
|
||||
async function saveProfile(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (!canManage || !profileElderId) {
|
||||
setMessage("当前角色无权维护健康档案");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPending(true);
|
||||
setMessage("");
|
||||
const response = await fetch(`/api/health/profiles/${profileElderId}`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(profileForm),
|
||||
});
|
||||
const result = (await response.json()) as ApiResult<{ profile: HealthProfile }>;
|
||||
setIsPending(false);
|
||||
setMessage(result.reason);
|
||||
|
||||
if (result.success) {
|
||||
closeProfileDialog();
|
||||
await refreshData();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveVital(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (!canManage) {
|
||||
setMessage("当前角色无权录入生命体征");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPending(true);
|
||||
setMessage("");
|
||||
const response = await fetch("/api/health/vitals", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
elderId: vitalForm.elderId,
|
||||
recordedAt: vitalForm.recordedAt,
|
||||
source: vitalForm.source,
|
||||
systolicBp: optionalNumber(vitalForm.systolicBp),
|
||||
diastolicBp: optionalNumber(vitalForm.diastolicBp),
|
||||
heartRate: optionalNumber(vitalForm.heartRate),
|
||||
temperatureTenths: optionalTenths(vitalForm.temperatureTenths),
|
||||
spo2: optionalNumber(vitalForm.spo2),
|
||||
bloodGlucoseTenths: optionalTenths(vitalForm.bloodGlucoseTenths),
|
||||
weightTenths: optionalTenths(vitalForm.weightTenths),
|
||||
notes: vitalForm.notes,
|
||||
}),
|
||||
});
|
||||
const result = (await response.json()) as ApiResult<{ vital: VitalRecord; review?: HealthAnomalyReview }>;
|
||||
setIsPending(false);
|
||||
setMessage(result.reason);
|
||||
|
||||
if (result.success) {
|
||||
setIsVitalOpen(false);
|
||||
setVitalForm(emptyVitalForm);
|
||||
await refreshData();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCondition(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (!canManage) {
|
||||
setMessage("当前角色无权新增慢病记录");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPending(true);
|
||||
setMessage("");
|
||||
const response = await fetch("/api/health/chronic-conditions", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(conditionForm),
|
||||
});
|
||||
const result = (await response.json()) as ApiResult<{ condition: ChronicCondition }>;
|
||||
setIsPending(false);
|
||||
setMessage(result.reason);
|
||||
|
||||
if (result.success) {
|
||||
setIsConditionOpen(false);
|
||||
setConditionForm(emptyConditionForm);
|
||||
await refreshData();
|
||||
}
|
||||
}
|
||||
|
||||
function openReviewDialog(review: HealthAnomalyReview): void {
|
||||
setReviewTarget(review);
|
||||
setReviewStatus(review.status === "pending" ? "reviewed" : review.status);
|
||||
setReviewNotes(review.resolutionNotes);
|
||||
setMessage("");
|
||||
}
|
||||
|
||||
function closeReviewDialog(): void {
|
||||
if (isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
setReviewTarget(undefined);
|
||||
setReviewStatus("reviewed");
|
||||
setReviewNotes("");
|
||||
}
|
||||
|
||||
async function saveReview(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (!canManage || !reviewTarget) {
|
||||
setMessage("当前角色无权处理异常复核");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPending(true);
|
||||
setMessage("");
|
||||
const response = await fetch(`/api/health/reviews/${reviewTarget.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ status: reviewStatus, resolutionNotes: reviewNotes }),
|
||||
});
|
||||
const result = (await response.json()) as ApiResult<{ review: HealthAnomalyReview }>;
|
||||
setIsPending(false);
|
||||
setMessage(result.reason);
|
||||
|
||||
if (result.success) {
|
||||
closeReviewDialog();
|
||||
await refreshData();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<MetricCard icon={FileText} label="健康档案" value={data.metrics.eldersWithProfiles} />
|
||||
<MetricCard icon={Activity} label="今日体征" value={data.metrics.vitalsToday} />
|
||||
<MetricCard icon={Stethoscope} label="慢病管理" value={data.metrics.activeChronicConditions} />
|
||||
<MetricCard icon={ShieldAlert} label="待复核" value={data.metrics.pendingReviews} />
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-3 border-b pb-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-wrap gap-2" role="tablist" aria-label="健康数据管理">
|
||||
{tabs.map((tab) => (
|
||||
<Button
|
||||
aria-selected={activeTab === tab.value}
|
||||
key={tab.value}
|
||||
onClick={() => setActiveTab(tab.value)}
|
||||
role="tab"
|
||||
type="button"
|
||||
variant={activeTab === tab.value ? "default" : "outline"}
|
||||
>
|
||||
{tab.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<label className="relative block">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="w-full pl-9 sm:w-72"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="搜索老人、记录或异常"
|
||||
value={query}
|
||||
/>
|
||||
</label>
|
||||
{activeTab === "vitals" ? (
|
||||
<Select
|
||||
aria-label="体征来源"
|
||||
onValueChange={(value) => setVitalSourceFilter(value as "all" | VitalSource)}
|
||||
options={[
|
||||
{ value: "all", label: "全部来源" },
|
||||
...Object.entries(VITAL_SOURCE_LABELS).map(([value, label]) => ({ value, label })),
|
||||
]}
|
||||
value={vitalSourceFilter}
|
||||
/>
|
||||
) : null}
|
||||
{activeTab === "conditions" ? (
|
||||
<Select
|
||||
aria-label="慢病状态"
|
||||
onValueChange={(value) => setConditionStatusFilter(value as "all" | ChronicConditionStatus)}
|
||||
options={[
|
||||
{ value: "all", label: "全部状态" },
|
||||
...Object.entries(CHRONIC_CONDITION_STATUS_LABELS).map(([value, label]) => ({ value, label })),
|
||||
]}
|
||||
value={conditionStatusFilter}
|
||||
/>
|
||||
) : null}
|
||||
{activeTab === "reviews" ? (
|
||||
<>
|
||||
<Select
|
||||
aria-label="复核状态"
|
||||
onValueChange={(value) => setReviewStatusFilter(value as "all" | HealthReviewStatus)}
|
||||
options={[
|
||||
{ value: "all", label: "全部状态" },
|
||||
...Object.entries(HEALTH_REVIEW_STATUS_LABELS).map(([value, label]) => ({ value, label })),
|
||||
]}
|
||||
value={reviewStatusFilter}
|
||||
/>
|
||||
<Select
|
||||
aria-label="异常级别"
|
||||
onValueChange={(value) => setReviewSeverityFilter(value as "all" | HealthReviewSeverity)}
|
||||
options={[
|
||||
{ value: "all", label: "全部级别" },
|
||||
...Object.entries(HEALTH_REVIEW_SEVERITY_LABELS).map(([value, label]) => ({ value, label })),
|
||||
]}
|
||||
value={reviewSeverityFilter}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{activeTab === "vitals" ? (
|
||||
<Button disabled={!canManage} onClick={() => setIsVitalOpen(true)} type="button">
|
||||
<HeartPulse className="size-4" aria-hidden="true" />
|
||||
录入体征
|
||||
</Button>
|
||||
) : null}
|
||||
{activeTab === "conditions" ? (
|
||||
<Button disabled={!canManage} onClick={() => setIsConditionOpen(true)} type="button">
|
||||
<ClipboardPlus className="size-4" aria-hidden="true" />
|
||||
新增慢病
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{message ? (
|
||||
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{activeTab === "profiles" ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>健康档案</CardTitle>
|
||||
<CardDescription>按老人维护过敏、病史、用药和紧急说明。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<Table className="w-full min-w-[840px] text-sm">
|
||||
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
|
||||
<Table.Row>
|
||||
<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.Row>
|
||||
</Table.Header>
|
||||
<Table.Body className="divide-y bg-card">
|
||||
{filteredElders.map((elder) => {
|
||||
const profile = profilesByElderId.get(elder.id);
|
||||
return (
|
||||
<Table.Row key={elder.id}>
|
||||
<Table.Cell className="px-4 py-3 font-medium">{elder.name}</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3 text-muted-foreground">{profile?.allergyNotes || "-"}</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3 text-muted-foreground">{profile?.medicationNotes || "-"}</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3 text-muted-foreground">{profile?.emergencyNotes || "-"}</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3 text-right">
|
||||
<Button
|
||||
disabled={!canManage}
|
||||
onClick={() => openProfileDialog(elder.id)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
维护档案
|
||||
</Button>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
})}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{activeTab === "vitals" ? (
|
||||
<DataTable
|
||||
emptyText="暂无生命体征记录"
|
||||
headers={["老人", "时间", "来源", "血压", "心率", "体温", "血氧", "备注"]}
|
||||
rows={filteredVitals.map((vital) => [
|
||||
vital.elderName,
|
||||
formatDateTime(vital.recordedAt),
|
||||
VITAL_SOURCE_LABELS[vital.source],
|
||||
vital.systolicBp && vital.diastolicBp ? `${vital.systolicBp}/${vital.diastolicBp}` : "-",
|
||||
vital.heartRate?.toString() ?? "-",
|
||||
formatTenths(vital.temperatureTenths, "°C"),
|
||||
vital.spo2 === undefined ? "-" : `${vital.spo2}%`,
|
||||
vital.notes || "-",
|
||||
])}
|
||||
title="生命体征"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{activeTab === "conditions" ? (
|
||||
<DataTable
|
||||
emptyText="暂无慢病记录"
|
||||
headers={["老人", "慢病", "状态", "诊断时间", "治疗记录", "随访备注"]}
|
||||
rows={filteredConditions.map((condition) => [
|
||||
condition.elderName,
|
||||
condition.name,
|
||||
CHRONIC_CONDITION_STATUS_LABELS[condition.status],
|
||||
formatDateTime(condition.diagnosedAt),
|
||||
condition.treatmentNotes || "-",
|
||||
condition.followUpNotes || "-",
|
||||
])}
|
||||
title="慢病记录"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{activeTab === "reviews" ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>异常复核</CardTitle>
|
||||
<CardDescription>处理生命体征触发或手工创建的健康异常。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<Table className="w-full min-w-[900px] text-sm">
|
||||
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
|
||||
<Table.Row>
|
||||
<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.Row>
|
||||
</Table.Header>
|
||||
<Table.Body className="divide-y bg-card">
|
||||
{filteredReviews.map((review) => (
|
||||
<Table.Row key={review.id}>
|
||||
<Table.Cell className="px-4 py-3 font-medium">{review.elderName}</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3">
|
||||
<p className="font-medium">{review.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{review.description}</p>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3">
|
||||
<Badge variant={severityBadgeVariant(review.severity)}>
|
||||
{HEALTH_REVIEW_SEVERITY_LABELS[review.severity]}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3">
|
||||
<Badge variant={reviewStatusBadgeVariant(review.status)}>
|
||||
{HEALTH_REVIEW_STATUS_LABELS[review.status]}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3 text-muted-foreground">{review.reviewedByName ?? "-"}</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3 text-right">
|
||||
<Button
|
||||
disabled={!canManage}
|
||||
onClick={() => openReviewDialog(review)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
处理
|
||||
</Button>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
{filteredReviews.length === 0 ? (
|
||||
<Table.Row>
|
||||
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
|
||||
暂无异常复核
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
) : null}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Dialog
|
||||
className="max-w-2xl"
|
||||
description="维护老人健康档案基础信息。"
|
||||
onClose={closeProfileDialog}
|
||||
open={profileElderId.length > 0}
|
||||
title={editingProfile ? "编辑健康档案" : "新增健康档案"}
|
||||
>
|
||||
<form className="grid gap-3" onSubmit={saveProfile}>
|
||||
<Textarea
|
||||
label="过敏记录"
|
||||
onChange={(event) => setProfileForm((current) => ({ ...current, allergyNotes: event.target.value }))}
|
||||
value={profileForm.allergyNotes}
|
||||
/>
|
||||
<Textarea
|
||||
label="既往病史"
|
||||
onChange={(event) => setProfileForm((current) => ({ ...current, medicalHistory: event.target.value }))}
|
||||
value={profileForm.medicalHistory}
|
||||
/>
|
||||
<Textarea
|
||||
label="用药备注"
|
||||
onChange={(event) => setProfileForm((current) => ({ ...current, medicationNotes: event.target.value }))}
|
||||
value={profileForm.medicationNotes}
|
||||
/>
|
||||
<Textarea
|
||||
label="照护限制"
|
||||
onChange={(event) => setProfileForm((current) => ({ ...current, careRestrictions: event.target.value }))}
|
||||
value={profileForm.careRestrictions}
|
||||
/>
|
||||
<Textarea
|
||||
label="紧急说明"
|
||||
onChange={(event) => setProfileForm((current) => ({ ...current, emergencyNotes: event.target.value }))}
|
||||
value={profileForm.emergencyNotes}
|
||||
/>
|
||||
<DialogActions isPending={isPending} onCancel={closeProfileDialog} submitLabel="保存档案" />
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
<Dialog className="max-w-2xl" description="录入一条真实生命体征记录。" onClose={() => setIsVitalOpen(false)} open={isVitalOpen} title="录入生命体征">
|
||||
<form className="grid gap-3" onSubmit={saveVital}>
|
||||
<Select
|
||||
label="老人"
|
||||
onValueChange={(value) => setVitalForm((current) => ({ ...current, elderId: value }))}
|
||||
options={elderOptions}
|
||||
required
|
||||
value={vitalForm.elderId}
|
||||
/>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input
|
||||
label="记录时间"
|
||||
onChange={(event) => setVitalForm((current) => ({ ...current, recordedAt: event.target.value }))}
|
||||
required
|
||||
type="datetime-local"
|
||||
value={vitalForm.recordedAt}
|
||||
/>
|
||||
<Select
|
||||
label="来源"
|
||||
onValueChange={(value) => setVitalForm((current) => ({ ...current, source: value as VitalSource }))}
|
||||
options={Object.entries(VITAL_SOURCE_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
value={vitalForm.source}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<Input label="收缩压" onChange={(event) => setVitalForm((current) => ({ ...current, systolicBp: event.target.value }))} value={vitalForm.systolicBp} />
|
||||
<Input label="舒张压" onChange={(event) => setVitalForm((current) => ({ ...current, diastolicBp: event.target.value }))} value={vitalForm.diastolicBp} />
|
||||
<Input label="心率" onChange={(event) => setVitalForm((current) => ({ ...current, heartRate: event.target.value }))} value={vitalForm.heartRate} />
|
||||
<Input label="体温" onChange={(event) => setVitalForm((current) => ({ ...current, temperatureTenths: event.target.value }))} value={vitalForm.temperatureTenths} />
|
||||
<Input label="血氧" onChange={(event) => setVitalForm((current) => ({ ...current, spo2: event.target.value }))} value={vitalForm.spo2} />
|
||||
<Input label="体重" onChange={(event) => setVitalForm((current) => ({ ...current, weightTenths: event.target.value }))} value={vitalForm.weightTenths} />
|
||||
</div>
|
||||
<Textarea label="备注" onChange={(event) => setVitalForm((current) => ({ ...current, notes: event.target.value }))} value={vitalForm.notes} />
|
||||
<DialogActions isPending={isPending} onCancel={() => setIsVitalOpen(false)} submitLabel="录入体征" />
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
<Dialog className="max-w-xl" description="新增老人慢病管理记录。" onClose={() => setIsConditionOpen(false)} open={isConditionOpen} title="新增慢病记录">
|
||||
<form className="grid gap-3" onSubmit={saveCondition}>
|
||||
<Select
|
||||
label="老人"
|
||||
onValueChange={(value) => setConditionForm((current) => ({ ...current, elderId: value }))}
|
||||
options={elderOptions}
|
||||
required
|
||||
value={conditionForm.elderId}
|
||||
/>
|
||||
<Input label="慢病名称" onChange={(event) => setConditionForm((current) => ({ ...current, name: event.target.value }))} required value={conditionForm.name} />
|
||||
<Select
|
||||
label="状态"
|
||||
onValueChange={(value) => setConditionForm((current) => ({ ...current, status: value as ChronicConditionStatus }))}
|
||||
options={Object.entries(CHRONIC_CONDITION_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
value={conditionForm.status}
|
||||
/>
|
||||
<Input
|
||||
label="诊断日期"
|
||||
onChange={(event) => setConditionForm((current) => ({ ...current, diagnosedAt: event.target.value }))}
|
||||
type="date"
|
||||
value={conditionForm.diagnosedAt}
|
||||
/>
|
||||
<Textarea label="治疗记录" onChange={(event) => setConditionForm((current) => ({ ...current, treatmentNotes: event.target.value }))} value={conditionForm.treatmentNotes} />
|
||||
<Textarea label="随访备注" onChange={(event) => setConditionForm((current) => ({ ...current, followUpNotes: event.target.value }))} value={conditionForm.followUpNotes} />
|
||||
<DialogActions isPending={isPending} onCancel={() => setIsConditionOpen(false)} submitLabel="新增慢病" />
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
<Dialog className="max-w-lg" description={reviewTarget?.description} onClose={closeReviewDialog} open={reviewTarget !== undefined} title={reviewTarget?.title ?? "处理异常复核"}>
|
||||
<form className="grid gap-3" onSubmit={saveReview}>
|
||||
<Select
|
||||
label="处理状态"
|
||||
onValueChange={(value) => setReviewStatus(value as HealthReviewStatus)}
|
||||
options={Object.entries(HEALTH_REVIEW_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
value={reviewStatus}
|
||||
/>
|
||||
<Textarea label="处理备注" onChange={(event) => setReviewNotes(event.target.value)} value={reviewNotes} />
|
||||
<DialogActions isPending={isPending} onCancel={closeReviewDialog} submitLabel="保存复核" />
|
||||
</form>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ComponentType<{ "aria-hidden"?: boolean; className?: string }>;
|
||||
label: string;
|
||||
value: number;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between p-4">
|
||||
<div>
|
||||
<CardDescription>{label}</CardDescription>
|
||||
<CardTitle className="mt-2 text-3xl">{value}</CardTitle>
|
||||
</div>
|
||||
<Icon className="size-5 text-primary" aria-hidden={true} />
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DataTable({
|
||||
emptyText,
|
||||
headers,
|
||||
rows,
|
||||
title,
|
||||
}: {
|
||||
emptyText: string;
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
title: string;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<Table className="w-full min-w-[860px] text-sm">
|
||||
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
|
||||
<Table.Row>
|
||||
{headers.map((header) => (
|
||||
<Table.Head className="px-4 py-3 font-medium" key={header}>
|
||||
{header}
|
||||
</Table.Head>
|
||||
))}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body className="divide-y bg-card">
|
||||
{rows.map((row, rowIndex) => (
|
||||
<Table.Row key={row.join("|") || rowIndex}>
|
||||
{row.map((cell, cellIndex) => (
|
||||
<Table.Cell className={cellIndex === 0 ? "px-4 py-3 font-medium" : "px-4 py-3 text-muted-foreground"} key={`${cell}-${cellIndex}`}>
|
||||
{cell}
|
||||
</Table.Cell>
|
||||
))}
|
||||
</Table.Row>
|
||||
))}
|
||||
{rows.length === 0 ? (
|
||||
<Table.Row>
|
||||
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={headers.length}>
|
||||
{emptyText}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
) : null}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogActions({
|
||||
isPending,
|
||||
onCancel,
|
||||
submitLabel,
|
||||
}: {
|
||||
isPending: boolean;
|
||||
onCancel: () => void;
|
||||
submitLabel: string;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
|
||||
<Button disabled={isPending} onClick={onCancel} type="button" variant="outline">
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={isPending} type="submit">
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
378
modules/health/server/operations.ts
Normal file
378
modules/health/server/operations.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
|
||||
import { getDatabase } from "@/modules/core/server/db";
|
||||
import {
|
||||
accounts,
|
||||
chronicConditions,
|
||||
elders,
|
||||
healthAnomalyReviews,
|
||||
healthProfiles,
|
||||
vitalRecords,
|
||||
} from "@/modules/core/server/schema";
|
||||
import type {
|
||||
ChronicCondition,
|
||||
ChronicConditionInput,
|
||||
HealthAdminData,
|
||||
HealthAnomalyReview,
|
||||
HealthProfile,
|
||||
HealthProfileInput,
|
||||
HealthReviewUpdateInput,
|
||||
VitalRecord,
|
||||
VitalRecordInput,
|
||||
} from "@/modules/health/types";
|
||||
|
||||
export type MutationFailure = {
|
||||
success: false;
|
||||
reason: string;
|
||||
status: number;
|
||||
};
|
||||
|
||||
type ElderRow = typeof elders.$inferSelect;
|
||||
type AccountNameRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export function isMutationFailure(value: unknown): value is MutationFailure {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"success" in value &&
|
||||
(value as { success?: unknown }).success === false
|
||||
);
|
||||
}
|
||||
|
||||
function iso(value: Date): string {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
function optionalIso(value: Date | null): string | undefined {
|
||||
return value ? iso(value) : undefined;
|
||||
}
|
||||
|
||||
function buildNameMap(rows: AccountNameRow[]): Map<string, string> {
|
||||
return new Map(rows.map((row) => [row.id, row.name]));
|
||||
}
|
||||
|
||||
function toProfile(row: typeof healthProfiles.$inferSelect, elderName: string): HealthProfile {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
elderId: row.elderId,
|
||||
elderName,
|
||||
allergyNotes: row.allergyNotes,
|
||||
medicalHistory: row.medicalHistory,
|
||||
medicationNotes: row.medicationNotes,
|
||||
careRestrictions: row.careRestrictions,
|
||||
emergencyNotes: row.emergencyNotes,
|
||||
createdAt: iso(row.createdAt),
|
||||
updatedAt: iso(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function toVital(row: typeof vitalRecords.$inferSelect, elderName: string, accountNameById: Map<string, string>): VitalRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
elderId: row.elderId,
|
||||
elderName,
|
||||
recordedAt: iso(row.recordedAt),
|
||||
source: row.source,
|
||||
systolicBp: row.systolicBp ?? undefined,
|
||||
diastolicBp: row.diastolicBp ?? undefined,
|
||||
heartRate: row.heartRate ?? undefined,
|
||||
temperatureTenths: row.temperatureTenths ?? undefined,
|
||||
spo2: row.spo2 ?? undefined,
|
||||
bloodGlucoseTenths: row.bloodGlucoseTenths ?? undefined,
|
||||
weightTenths: row.weightTenths ?? undefined,
|
||||
notes: row.notes,
|
||||
createdByAccountId: row.createdByAccountId ?? undefined,
|
||||
createdByName: row.createdByAccountId ? accountNameById.get(row.createdByAccountId) : undefined,
|
||||
createdAt: iso(row.createdAt),
|
||||
updatedAt: iso(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function toCondition(row: typeof chronicConditions.$inferSelect, elderName: string): ChronicCondition {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
elderId: row.elderId,
|
||||
elderName,
|
||||
name: row.name,
|
||||
status: row.status,
|
||||
diagnosedAt: optionalIso(row.diagnosedAt),
|
||||
treatmentNotes: row.treatmentNotes,
|
||||
followUpNotes: row.followUpNotes,
|
||||
createdAt: iso(row.createdAt),
|
||||
updatedAt: iso(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function toReview(
|
||||
row: typeof healthAnomalyReviews.$inferSelect,
|
||||
elderName: string,
|
||||
accountNameById: Map<string, string>,
|
||||
): HealthAnomalyReview {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
elderId: row.elderId,
|
||||
elderName,
|
||||
vitalRecordId: row.vitalRecordId ?? undefined,
|
||||
severity: row.severity,
|
||||
status: row.status,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
reviewedByAccountId: row.reviewedByAccountId ?? undefined,
|
||||
reviewedByName: row.reviewedByAccountId ? accountNameById.get(row.reviewedByAccountId) : undefined,
|
||||
reviewedAt: optionalIso(row.reviewedAt),
|
||||
resolutionNotes: row.resolutionNotes,
|
||||
createdAt: iso(row.createdAt),
|
||||
updatedAt: iso(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function getElderName(row: ElderRow | undefined): string {
|
||||
return row?.name ?? "";
|
||||
}
|
||||
|
||||
async function findElder(organizationId: string, elderId: string): Promise<ElderRow | undefined> {
|
||||
const database = getDatabase();
|
||||
const rows = await database
|
||||
.select()
|
||||
.from(elders)
|
||||
.where(and(eq(elders.id, elderId), eq(elders.organizationId, organizationId)))
|
||||
.limit(1);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
function detectVitalAnomaly(input: VitalRecordInput): { title: string; description: string; severity: "warning" | "critical" } | null {
|
||||
if ((input.systolicBp !== undefined && input.systolicBp >= 180) || (input.diastolicBp !== undefined && input.diastolicBp >= 110)) {
|
||||
return { title: "血压严重异常", description: "血压达到紧急复核阈值", severity: "critical" };
|
||||
}
|
||||
|
||||
if ((input.systolicBp !== undefined && input.systolicBp >= 150) || (input.diastolicBp !== undefined && input.diastolicBp >= 95)) {
|
||||
return { title: "血压异常", description: "血压偏高,需要护理人员复核", severity: "warning" };
|
||||
}
|
||||
|
||||
if (input.temperatureTenths !== undefined && input.temperatureTenths >= 380) {
|
||||
return { title: "体温异常", description: "体温偏高,需要复核", severity: "warning" };
|
||||
}
|
||||
|
||||
if (input.spo2 !== undefined && input.spo2 < 92) {
|
||||
return { title: "血氧异常", description: "血氧偏低,需要复核", severity: "critical" };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function listHealthAdminData(organizationId: string): Promise<HealthAdminData> {
|
||||
const database = getDatabase();
|
||||
const todayStart = new Date();
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
|
||||
const [elderRows, profileRows, vitalRows, conditionRows, reviewRows, accountRows] = await Promise.all([
|
||||
database.select().from(elders).where(eq(elders.organizationId, organizationId)).orderBy(desc(elders.createdAt)),
|
||||
database.select().from(healthProfiles).where(eq(healthProfiles.organizationId, organizationId)).orderBy(desc(healthProfiles.updatedAt)),
|
||||
database.select().from(vitalRecords).where(eq(vitalRecords.organizationId, organizationId)).orderBy(desc(vitalRecords.recordedAt)).limit(100),
|
||||
database
|
||||
.select()
|
||||
.from(chronicConditions)
|
||||
.where(eq(chronicConditions.organizationId, organizationId))
|
||||
.orderBy(desc(chronicConditions.updatedAt)),
|
||||
database
|
||||
.select()
|
||||
.from(healthAnomalyReviews)
|
||||
.where(eq(healthAnomalyReviews.organizationId, organizationId))
|
||||
.orderBy(desc(healthAnomalyReviews.createdAt)),
|
||||
database.select({ id: accounts.id, name: accounts.name }).from(accounts),
|
||||
]);
|
||||
|
||||
const elderById = new Map(elderRows.map((elder) => [elder.id, elder]));
|
||||
const accountNameById = buildNameMap(accountRows);
|
||||
|
||||
return {
|
||||
elders: elderRows.map((elder) => ({
|
||||
id: elder.id,
|
||||
name: elder.name,
|
||||
careLevel: elder.careLevel,
|
||||
status: elder.status,
|
||||
})),
|
||||
profiles: profileRows.map((profile) => toProfile(profile, getElderName(elderById.get(profile.elderId)))),
|
||||
vitals: vitalRows.map((vital) => toVital(vital, getElderName(elderById.get(vital.elderId)), accountNameById)),
|
||||
chronicConditions: conditionRows.map((condition) => toCondition(condition, getElderName(elderById.get(condition.elderId)))),
|
||||
reviews: reviewRows.map((review) => toReview(review, getElderName(elderById.get(review.elderId)), accountNameById)),
|
||||
metrics: {
|
||||
eldersWithProfiles: profileRows.length,
|
||||
vitalsToday: vitalRows.filter((vital) => vital.recordedAt >= todayStart).length,
|
||||
activeChronicConditions: conditionRows.filter((condition) => condition.status === "active").length,
|
||||
pendingReviews: reviewRows.filter((review) => review.status === "pending").length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function upsertHealthProfile(input: HealthProfileInput & { elderId: string; organizationId: string }): Promise<HealthProfile | MutationFailure> {
|
||||
const elder = await findElder(input.organizationId, input.elderId);
|
||||
if (!elder) {
|
||||
return { success: false, reason: "老人档案不存在", status: 404 };
|
||||
}
|
||||
|
||||
const database = getDatabase();
|
||||
const rows = await database
|
||||
.insert(healthProfiles)
|
||||
.values({
|
||||
organizationId: input.organizationId,
|
||||
elderId: input.elderId,
|
||||
allergyNotes: input.allergyNotes,
|
||||
medicalHistory: input.medicalHistory,
|
||||
medicationNotes: input.medicationNotes,
|
||||
careRestrictions: input.careRestrictions,
|
||||
emergencyNotes: input.emergencyNotes,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [healthProfiles.organizationId, healthProfiles.elderId],
|
||||
set: {
|
||||
allergyNotes: input.allergyNotes,
|
||||
medicalHistory: input.medicalHistory,
|
||||
medicationNotes: input.medicationNotes,
|
||||
careRestrictions: input.careRestrictions,
|
||||
emergencyNotes: input.emergencyNotes,
|
||||
updatedAt: sql`NOW()`,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
const profile = rows[0];
|
||||
if (!profile) {
|
||||
return { success: false, reason: "健康档案保存失败", status: 500 };
|
||||
}
|
||||
|
||||
return toProfile(profile, elder.name);
|
||||
}
|
||||
|
||||
export async function createVitalRecord(input: VitalRecordInput & { accountId: string; organizationId: string }): Promise<
|
||||
| {
|
||||
vital: VitalRecord;
|
||||
review?: HealthAnomalyReview;
|
||||
}
|
||||
| MutationFailure
|
||||
> {
|
||||
const elder = await findElder(input.organizationId, input.elderId);
|
||||
if (!elder) {
|
||||
return { success: false, reason: "老人档案不存在", status: 404 };
|
||||
}
|
||||
|
||||
const database = getDatabase();
|
||||
const created = await database.transaction(async (transaction) => {
|
||||
const vitalRows = await transaction
|
||||
.insert(vitalRecords)
|
||||
.values({
|
||||
organizationId: input.organizationId,
|
||||
elderId: input.elderId,
|
||||
recordedAt: input.recordedAt,
|
||||
source: input.source,
|
||||
systolicBp: input.systolicBp,
|
||||
diastolicBp: input.diastolicBp,
|
||||
heartRate: input.heartRate,
|
||||
temperatureTenths: input.temperatureTenths,
|
||||
spo2: input.spo2,
|
||||
bloodGlucoseTenths: input.bloodGlucoseTenths,
|
||||
weightTenths: input.weightTenths,
|
||||
notes: input.notes,
|
||||
createdByAccountId: input.accountId,
|
||||
})
|
||||
.returning();
|
||||
const vital = vitalRows[0];
|
||||
if (!vital) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const anomaly = detectVitalAnomaly(input);
|
||||
if (!anomaly) {
|
||||
return { vital };
|
||||
}
|
||||
|
||||
const reviewRows = await transaction
|
||||
.insert(healthAnomalyReviews)
|
||||
.values({
|
||||
organizationId: input.organizationId,
|
||||
elderId: input.elderId,
|
||||
vitalRecordId: vital.id,
|
||||
severity: anomaly.severity,
|
||||
status: "pending",
|
||||
title: anomaly.title,
|
||||
description: anomaly.description,
|
||||
})
|
||||
.returning();
|
||||
return { vital, review: reviewRows[0] };
|
||||
});
|
||||
|
||||
if (!created) {
|
||||
return { success: false, reason: "生命体征记录创建失败", status: 500 };
|
||||
}
|
||||
|
||||
const accountRows = await database.select({ id: accounts.id, name: accounts.name }).from(accounts).where(eq(accounts.id, input.accountId));
|
||||
const accountNameById = buildNameMap(accountRows);
|
||||
|
||||
return {
|
||||
vital: toVital(created.vital, elder.name, accountNameById),
|
||||
review: created.review ? toReview(created.review, elder.name, accountNameById) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createChronicCondition(
|
||||
input: ChronicConditionInput & { organizationId: string },
|
||||
): Promise<ChronicCondition | MutationFailure> {
|
||||
const elder = await findElder(input.organizationId, input.elderId);
|
||||
if (!elder) {
|
||||
return { success: false, reason: "老人档案不存在", status: 404 };
|
||||
}
|
||||
|
||||
const database = getDatabase();
|
||||
const rows = await database
|
||||
.insert(chronicConditions)
|
||||
.values({
|
||||
organizationId: input.organizationId,
|
||||
elderId: input.elderId,
|
||||
name: input.name,
|
||||
status: input.status,
|
||||
diagnosedAt: input.diagnosedAt,
|
||||
treatmentNotes: input.treatmentNotes,
|
||||
followUpNotes: input.followUpNotes,
|
||||
})
|
||||
.returning();
|
||||
const condition = rows[0];
|
||||
if (!condition) {
|
||||
return { success: false, reason: "慢病记录创建失败", status: 500 };
|
||||
}
|
||||
|
||||
return toCondition(condition, elder.name);
|
||||
}
|
||||
|
||||
export async function updateHealthReview(input: HealthReviewUpdateInput & { accountId: string; id: string; organizationId: string }): Promise<
|
||||
HealthAnomalyReview | MutationFailure
|
||||
> {
|
||||
const database = getDatabase();
|
||||
const rows = await database
|
||||
.update(healthAnomalyReviews)
|
||||
.set({
|
||||
status: input.status,
|
||||
resolutionNotes: input.resolutionNotes,
|
||||
reviewedByAccountId: input.accountId,
|
||||
reviewedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(healthAnomalyReviews.id, input.id), eq(healthAnomalyReviews.organizationId, input.organizationId)))
|
||||
.returning();
|
||||
const review = rows[0];
|
||||
if (!review) {
|
||||
return { success: false, reason: "异常复核记录不存在", status: 404 };
|
||||
}
|
||||
|
||||
const [elderRows, accountRows] = await Promise.all([
|
||||
database.select().from(elders).where(eq(elders.id, review.elderId)).limit(1),
|
||||
database.select({ id: accounts.id, name: accounts.name }).from(accounts).where(eq(accounts.id, input.accountId)),
|
||||
]);
|
||||
|
||||
return toReview(review, getElderName(elderRows[0]), buildNameMap(accountRows));
|
||||
}
|
||||
332
modules/health/types.ts
Normal file
332
modules/health/types.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
export const VITAL_SOURCE_VALUES = ["manual", "device", "import"] as const;
|
||||
export type VitalSource = (typeof VITAL_SOURCE_VALUES)[number];
|
||||
|
||||
export const CHRONIC_CONDITION_STATUS_VALUES = ["active", "controlled", "resolved"] as const;
|
||||
export type ChronicConditionStatus = (typeof CHRONIC_CONDITION_STATUS_VALUES)[number];
|
||||
|
||||
export const HEALTH_REVIEW_STATUS_VALUES = ["pending", "reviewed", "resolved"] as const;
|
||||
export type HealthReviewStatus = (typeof HEALTH_REVIEW_STATUS_VALUES)[number];
|
||||
|
||||
export const HEALTH_REVIEW_SEVERITY_VALUES = ["info", "warning", "critical"] as const;
|
||||
export type HealthReviewSeverity = (typeof HEALTH_REVIEW_SEVERITY_VALUES)[number];
|
||||
|
||||
export const VITAL_SOURCE_LABELS: Record<VitalSource, string> = {
|
||||
manual: "手工录入",
|
||||
device: "设备采集",
|
||||
import: "批量导入",
|
||||
};
|
||||
|
||||
export const CHRONIC_CONDITION_STATUS_LABELS: Record<ChronicConditionStatus, string> = {
|
||||
active: "持续管理",
|
||||
controlled: "控制稳定",
|
||||
resolved: "已结束",
|
||||
};
|
||||
|
||||
export const HEALTH_REVIEW_STATUS_LABELS: Record<HealthReviewStatus, string> = {
|
||||
pending: "待复核",
|
||||
reviewed: "已复核",
|
||||
resolved: "已处理",
|
||||
};
|
||||
|
||||
export const HEALTH_REVIEW_SEVERITY_LABELS: Record<HealthReviewSeverity, string> = {
|
||||
info: "提示",
|
||||
warning: "预警",
|
||||
critical: "紧急",
|
||||
};
|
||||
|
||||
export type HealthElderOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
careLevel: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type HealthProfile = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
elderId: string;
|
||||
elderName: string;
|
||||
allergyNotes: string;
|
||||
medicalHistory: string;
|
||||
medicationNotes: string;
|
||||
careRestrictions: string;
|
||||
emergencyNotes: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type VitalRecord = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
elderId: string;
|
||||
elderName: string;
|
||||
recordedAt: string;
|
||||
source: VitalSource;
|
||||
systolicBp?: number;
|
||||
diastolicBp?: number;
|
||||
heartRate?: number;
|
||||
temperatureTenths?: number;
|
||||
spo2?: number;
|
||||
bloodGlucoseTenths?: number;
|
||||
weightTenths?: number;
|
||||
notes: string;
|
||||
createdByAccountId?: string;
|
||||
createdByName?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ChronicCondition = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
elderId: string;
|
||||
elderName: string;
|
||||
name: string;
|
||||
status: ChronicConditionStatus;
|
||||
diagnosedAt?: string;
|
||||
treatmentNotes: string;
|
||||
followUpNotes: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type HealthAnomalyReview = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
elderId: string;
|
||||
elderName: string;
|
||||
vitalRecordId?: string;
|
||||
severity: HealthReviewSeverity;
|
||||
status: HealthReviewStatus;
|
||||
title: string;
|
||||
description: string;
|
||||
reviewedByAccountId?: string;
|
||||
reviewedByName?: string;
|
||||
reviewedAt?: string;
|
||||
resolutionNotes: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type HealthAdminMetrics = {
|
||||
eldersWithProfiles: number;
|
||||
vitalsToday: number;
|
||||
activeChronicConditions: number;
|
||||
pendingReviews: number;
|
||||
};
|
||||
|
||||
export type HealthAdminData = {
|
||||
elders: HealthElderOption[];
|
||||
profiles: HealthProfile[];
|
||||
vitals: VitalRecord[];
|
||||
chronicConditions: ChronicCondition[];
|
||||
reviews: HealthAnomalyReview[];
|
||||
metrics: HealthAdminMetrics;
|
||||
};
|
||||
|
||||
export type HealthProfileInput = {
|
||||
allergyNotes: string;
|
||||
medicalHistory: string;
|
||||
medicationNotes: string;
|
||||
careRestrictions: string;
|
||||
emergencyNotes: string;
|
||||
};
|
||||
|
||||
export type VitalRecordInput = {
|
||||
elderId: string;
|
||||
recordedAt: Date;
|
||||
source: VitalSource;
|
||||
systolicBp?: number;
|
||||
diastolicBp?: number;
|
||||
heartRate?: number;
|
||||
temperatureTenths?: number;
|
||||
spo2?: number;
|
||||
bloodGlucoseTenths?: number;
|
||||
weightTenths?: number;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
export type ChronicConditionInput = {
|
||||
elderId: string;
|
||||
name: string;
|
||||
status: ChronicConditionStatus;
|
||||
diagnosedAt?: Date;
|
||||
treatmentNotes: string;
|
||||
followUpNotes: string;
|
||||
};
|
||||
|
||||
export type HealthReviewUpdateInput = {
|
||||
status: HealthReviewStatus;
|
||||
resolutionNotes: string;
|
||||
};
|
||||
|
||||
type ValidationSuccess<T> = {
|
||||
success: true;
|
||||
data: T;
|
||||
};
|
||||
|
||||
type ValidationFailure = {
|
||||
success: false;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type ValidationResult<T> = ValidationSuccess<T> | ValidationFailure;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readString(source: Record<string, unknown>, key: string): string {
|
||||
const value = source[key];
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function readOptionalInteger(source: Record<string, unknown>, key: string): number | undefined | null {
|
||||
const value = source[key];
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const numeric = typeof value === "number" ? value : Number(value);
|
||||
return Number.isInteger(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
function readEnum<T extends string>(value: unknown, values: readonly T[]): T | null {
|
||||
return typeof value === "string" ? values.find((item) => item === value) ?? null : null;
|
||||
}
|
||||
|
||||
function readOptionalDate(source: Record<string, unknown>, key: string): Date | undefined | null {
|
||||
const value = source[key];
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
export function validateHealthProfileInput(value: unknown): ValidationResult<HealthProfileInput> {
|
||||
if (!isRecord(value)) {
|
||||
return { success: false, reason: "请求数据格式无效" };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
allergyNotes: readString(value, "allergyNotes"),
|
||||
medicalHistory: readString(value, "medicalHistory"),
|
||||
medicationNotes: readString(value, "medicationNotes"),
|
||||
careRestrictions: readString(value, "careRestrictions"),
|
||||
emergencyNotes: readString(value, "emergencyNotes"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function validateVitalRecordInput(value: unknown): ValidationResult<VitalRecordInput> {
|
||||
if (!isRecord(value)) {
|
||||
return { success: false, reason: "请求数据格式无效" };
|
||||
}
|
||||
|
||||
const elderId = readString(value, "elderId");
|
||||
if (!elderId) {
|
||||
return { success: false, reason: "老人不能为空" };
|
||||
}
|
||||
|
||||
const recordedAt = readOptionalDate(value, "recordedAt");
|
||||
if (!recordedAt) {
|
||||
return { success: false, reason: "记录时间无效" };
|
||||
}
|
||||
|
||||
const source = readEnum(value.source, VITAL_SOURCE_VALUES);
|
||||
if (!source) {
|
||||
return { success: false, reason: "记录来源无效" };
|
||||
}
|
||||
|
||||
const integerKeys = [
|
||||
"systolicBp",
|
||||
"diastolicBp",
|
||||
"heartRate",
|
||||
"temperatureTenths",
|
||||
"spo2",
|
||||
"bloodGlucoseTenths",
|
||||
"weightTenths",
|
||||
] as const;
|
||||
const parsed: Partial<Record<(typeof integerKeys)[number], number>> = {};
|
||||
for (const key of integerKeys) {
|
||||
const numberValue = readOptionalInteger(value, key);
|
||||
if (numberValue === null) {
|
||||
return { success: false, reason: "生命体征数值需为整数" };
|
||||
}
|
||||
if (numberValue !== undefined) {
|
||||
parsed[key] = numberValue;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
elderId,
|
||||
recordedAt,
|
||||
source,
|
||||
notes: readString(value, "notes"),
|
||||
...parsed,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function validateChronicConditionInput(value: unknown): ValidationResult<ChronicConditionInput> {
|
||||
if (!isRecord(value)) {
|
||||
return { success: false, reason: "请求数据格式无效" };
|
||||
}
|
||||
|
||||
const elderId = readString(value, "elderId");
|
||||
const name = readString(value, "name");
|
||||
if (!elderId || !name) {
|
||||
return { success: false, reason: "老人和慢病名称不能为空" };
|
||||
}
|
||||
|
||||
const status = readEnum(value.status, CHRONIC_CONDITION_STATUS_VALUES);
|
||||
if (!status) {
|
||||
return { success: false, reason: "慢病状态无效" };
|
||||
}
|
||||
|
||||
const diagnosedAt = readOptionalDate(value, "diagnosedAt");
|
||||
if (diagnosedAt === null) {
|
||||
return { success: false, reason: "诊断日期无效" };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
elderId,
|
||||
name,
|
||||
status,
|
||||
diagnosedAt,
|
||||
treatmentNotes: readString(value, "treatmentNotes"),
|
||||
followUpNotes: readString(value, "followUpNotes"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function validateHealthReviewUpdateInput(value: unknown): ValidationResult<HealthReviewUpdateInput> {
|
||||
if (!isRecord(value)) {
|
||||
return { success: false, reason: "请求数据格式无效" };
|
||||
}
|
||||
|
||||
const status = readEnum(value.status, HEALTH_REVIEW_STATUS_VALUES);
|
||||
if (!status) {
|
||||
return { success: false, reason: "复核状态无效" };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
status,
|
||||
resolutionNotes: readString(value, "resolutionNotes"),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -26,6 +26,7 @@ const routeLabels: Record<string, string> = {
|
||||
"/settings/organizations": "机构管理",
|
||||
"/settings/users": "用户管理",
|
||||
"/settings/roles": "角色权限",
|
||||
"/settings/health": "健康数据管理",
|
||||
"/settings/audit": "审计日志",
|
||||
"/settings/status": "运行状态",
|
||||
};
|
||||
|
||||
@@ -125,6 +125,12 @@ export const navGroups: NavGroup[] = [
|
||||
icon: "roles",
|
||||
permission: "role:read",
|
||||
},
|
||||
{
|
||||
path: "/settings/health",
|
||||
label: "健康数据管理",
|
||||
icon: "health",
|
||||
permission: "health:read",
|
||||
},
|
||||
{
|
||||
path: "/settings/audit",
|
||||
label: "审计日志",
|
||||
|
||||
Reference in New Issue
Block a user