feat: add health data management workspace
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user