feat: add health data management workspace

This commit is contained in:
2026-07-03 00:25:11 -07:00
parent dc034b6d85
commit 41807ff557
53 changed files with 7367 additions and 3 deletions

View 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>
);
}

View 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
View 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"),
},
};
}