Files
teatea-pension/modules/dashboard/components/DashboardHome.tsx

561 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Link from "next/link";
import { Activity, AlertTriangle, BedDouble, BrainCircuit, CheckCircle2, HeartPulse, ListChecks, Megaphone, ShieldAlert, TabletSmartphone, Users } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table } from "@/components/ui/table";
import type { ElderAiAnalysisBoardItem, ElderAiRiskLevel } from "@/modules/ai/types";
import type { AlertTriggerStatus } from "@/modules/alerts/types";
import { ALERT_TRIGGER_STATUS_LABELS } from "@/modules/alerts/types";
import type { AdmissionStatus, BedStatus, IncidentSeverity } from "@/modules/core/types";
import type { CareTaskPriority, CareTaskStatus } from "@/modules/care/types";
import { CARE_TASK_PRIORITY_LABELS, CARE_TASK_STATUS_LABELS } from "@/modules/care/types";
import { BedOccupancyChart, type BedOccupancyDatum } from "@/modules/dashboard/components/BedOccupancyChart";
import type { MaintenanceTicketPriority, MaintenanceTicketStatus } from "@/modules/devices/types";
import { MAINTENANCE_TICKET_PRIORITY_LABELS, MAINTENANCE_TICKET_STATUS_LABELS } from "@/modules/devices/types";
import { INCIDENT_SEVERITY_LABELS, INCIDENT_STATUS_LABELS } from "@/modules/emergency/types";
import type { FamilyFeedbackStatus, FamilyVisitStatus } from "@/modules/family/types";
import { FAMILY_FEEDBACK_STATUS_LABELS, FAMILY_VISIT_STATUS_LABELS } from "@/modules/family/types";
import type { HealthReviewSeverity, HealthReviewStatus } from "@/modules/health/types";
import { HEALTH_REVIEW_SEVERITY_LABELS, HEALTH_REVIEW_STATUS_LABELS } from "@/modules/health/types";
import type { NoticeStatus } from "@/modules/notices/types";
import { NOTICE_STATUS_LABELS } from "@/modules/notices/types";
export type DashboardMetric = {
label: string;
value: string;
trend: string;
icon: "admissions" | "ai" | "alerts" | "beds" | "care" | "devices" | "family" | "health" | "incidents" | "notices" | "users";
};
export type DashboardAdmission = {
id: string;
elderName: string;
bedLabel: string;
status: AdmissionStatus;
admittedAt: string;
dischargedAt?: string;
};
export type DashboardIncident = {
id: string;
severity: IncidentSeverity;
status: string;
title: string;
source: string;
createdAt: string;
};
export type DashboardCareTask = {
id: string;
assigneeLabel: string;
elderName: string;
priority: CareTaskPriority;
scheduledAt: string;
status: CareTaskStatus;
title: string;
};
export type DashboardHealthReview = {
id: string;
elderName: string;
severity: HealthReviewSeverity;
status: HealthReviewStatus;
title: string;
};
export type DashboardDeviceTicket = {
id: string;
deviceName: string;
priority: MaintenanceTicketPriority;
status: MaintenanceTicketStatus;
title: string;
updatedAt: string;
};
export type DashboardNotice = {
id: string;
audience: string;
status: NoticeStatus;
title: string;
updatedAt: string;
};
export type DashboardAlertTrigger = {
id: string;
elderName: string;
status: AlertTriggerStatus;
title: string;
updatedAt: string;
};
export type DashboardFamilyVisit = {
id: string;
contactName: string;
elderName: string;
scheduledAt: string;
status: FamilyVisitStatus;
};
export type DashboardFamilyFeedback = {
id: string;
elderName: string;
status: FamilyFeedbackStatus;
updatedAt: string;
};
type DashboardHomeProps = {
alertTriggers?: DashboardAlertTrigger[];
alertsHref: string;
aiBoardItems?: ElderAiAnalysisBoardItem[];
bedsHref: string;
careHref: string;
careTasks?: DashboardCareTask[];
deviceTickets?: DashboardDeviceTicket[];
devicesHref: string;
eldersHref: string;
emergencyHref: string;
emergencyIncidents?: DashboardIncident[];
familyFeedback?: DashboardFamilyFeedback[];
familyHref: string;
familyVisits?: DashboardFamilyVisit[];
healthHref: string;
healthReviews?: DashboardHealthReview[];
metrics: DashboardMetric[];
notices?: DashboardNotice[];
noticesHref: string;
occupancyData?: BedOccupancyDatum[];
recentAdmissions?: DashboardAdmission[];
incidents?: DashboardIncident[];
};
const metricIcons: Record<DashboardMetric["icon"], LucideIcon> = {
admissions: CheckCircle2,
ai: BrainCircuit,
alerts: ShieldAlert,
beds: BedDouble,
care: ListChecks,
devices: TabletSmartphone,
family: Users,
health: HeartPulse,
incidents: HeartPulse,
notices: Megaphone,
users: Users,
};
const admissionStatusLabels: Record<AdmissionStatus, string> = {
active: "在住",
transferred: "已换床",
discharged: "已退住",
};
const bedStatusLabels: Record<BedStatus, string> = {
available: "空闲",
occupied: "占用",
maintenance: "维修",
disabled: "停用",
};
function formatDateTime(value: string): string {
return new Date(value).toLocaleString("zh-CN");
}
function incidentVariant(severity: IncidentSeverity): "secondary" | "warning" | "danger" {
switch (severity) {
case "critical":
return "danger";
case "warning":
return "warning";
case "info":
return "secondary";
}
}
const analysisRiskLabels: Record<ElderAiRiskLevel, string> = {
critical: "高危",
high: "高风险",
low: "低风险",
medium: "中风险",
unknown: "未知",
};
function analysisRiskVariant(risk: ElderAiRiskLevel): "danger" | "secondary" | "success" | "warning" {
switch (risk) {
case "critical":
return "danger";
case "high":
case "medium":
return "warning";
case "low":
return "success";
case "unknown":
return "secondary";
}
}
export function DashboardHome({
alertTriggers,
alertsHref,
aiBoardItems,
bedsHref,
careHref,
careTasks,
deviceTickets,
devicesHref,
eldersHref,
emergencyHref,
emergencyIncidents,
familyFeedback,
familyHref,
familyVisits,
healthHref,
healthReviews,
metrics,
notices,
noticesHref,
occupancyData,
recentAdmissions,
incidents,
}: DashboardHomeProps): React.ReactElement {
const familyQueueItems: QueueItem[] = [
...(familyVisits ?? []).map((visit) => ({
id: `visit-${visit.id}`,
title: `${visit.elderName} 探访`,
detail: `${visit.contactName} / ${formatDateTime(visit.scheduledAt)}`,
badge: FAMILY_VISIT_STATUS_LABELS[visit.status],
variant: visit.status === "requested" ? "warning" as const : "secondary" as const,
meta: "探访",
})),
...(familyFeedback ?? []).map((feedback) => ({
id: `feedback-${feedback.id}`,
title: `${feedback.elderName} 家属反馈`,
detail: formatDateTime(feedback.updatedAt),
badge: FAMILY_FEEDBACK_STATUS_LABELS[feedback.status],
variant: feedback.status === "open" || feedback.status === "in_progress" ? "warning" as const : "secondary" as const,
meta: "反馈",
})),
].slice(0, 6);
const hasCoreQueues = careTasks !== undefined || healthReviews !== undefined || emergencyIncidents !== undefined;
const hasCollaborationQueues = deviceTickets !== undefined || notices !== undefined || alertTriggers !== undefined || familyVisits !== undefined || familyFeedback !== undefined;
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6">
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-3xl font-semibold tracking-normal md:text-4xl"></h1>
<p className="mt-2 text-sm text-muted-foreground"></p>
</div>
</section>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{metrics.map((metric) => {
const Icon = metricIcons[metric.icon];
return (
<Card key={metric.label}>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardDescription>{metric.label}</CardDescription>
<Icon className="size-4 text-primary" aria-hidden="true" />
</CardHeader>
<CardContent>
<CardTitle className="text-3xl">{metric.value}</CardTitle>
<p className="mt-2 text-xs text-muted-foreground">{metric.trend}</p>
</CardContent>
</Card>
);
})}
</section>
{hasCoreQueues ? (
<section className="grid gap-4 xl:grid-cols-3">
{careTasks !== undefined ? (
<QueueCard
emptyText="暂无待处理护理任务"
href={careHref}
items={careTasks.map((task) => ({
id: task.id,
title: task.title,
detail: `${task.elderName || "公共区域"} / ${task.assigneeLabel || "未分配"} / ${formatDateTime(task.scheduledAt)}`,
badge: CARE_TASK_STATUS_LABELS[task.status],
variant: task.priority === "urgent" ? "danger" : task.priority === "high" ? "warning" : "secondary",
meta: CARE_TASK_PRIORITY_LABELS[task.priority],
}))}
title="护理待办"
/>
) : null}
{healthReviews !== undefined ? (
<QueueCard
emptyText="暂无待复核健康异常"
href={healthHref}
items={healthReviews.map((review) => ({
id: review.id,
title: review.title,
detail: review.elderName,
badge: HEALTH_REVIEW_STATUS_LABELS[review.status],
variant: review.severity === "critical" ? "danger" : "warning",
meta: HEALTH_REVIEW_SEVERITY_LABELS[review.severity],
}))}
title="健康复核"
/>
) : null}
{emergencyIncidents !== undefined ? (
<QueueCard
emptyText="暂无待处理应急事件"
href={emergencyHref}
items={emergencyIncidents.map((incident) => ({
id: incident.id,
title: incident.title,
detail: `${incident.source} / ${formatDateTime(incident.createdAt)}`,
badge: INCIDENT_STATUS_LABELS[incident.status as keyof typeof INCIDENT_STATUS_LABELS],
variant: incidentVariant(incident.severity),
meta: INCIDENT_SEVERITY_LABELS[incident.severity],
}))}
title="安全应急"
/>
) : null}
</section>
) : null}
{hasCollaborationQueues ? (
<section className="grid gap-4 xl:grid-cols-4">
{deviceTickets !== undefined ? (
<QueueCard
emptyText="暂无待处理维修工单"
href={devicesHref}
items={deviceTickets.map((ticket) => ({
id: ticket.id,
title: ticket.title,
detail: `${ticket.deviceName} / ${formatDateTime(ticket.updatedAt)}`,
badge: MAINTENANCE_TICKET_STATUS_LABELS[ticket.status],
variant: ticket.priority === "urgent" ? "danger" : ticket.priority === "high" ? "warning" : "secondary",
meta: MAINTENANCE_TICKET_PRIORITY_LABELS[ticket.priority],
}))}
title="设备工单"
/>
) : null}
{notices !== undefined ? (
<QueueCard
emptyText="暂无公告"
href={noticesHref}
items={notices.map((notice) => ({
id: notice.id,
title: notice.title,
detail: `${notice.audience} / ${formatDateTime(notice.updatedAt)}`,
badge: NOTICE_STATUS_LABELS[notice.status],
variant: notice.status === "published" ? "success" : "secondary",
meta: "公告",
}))}
title="公告通知"
/>
) : null}
{alertTriggers !== undefined ? (
<QueueCard
emptyText="暂无预警记录"
href={alertsHref}
items={alertTriggers.map((trigger) => ({
id: trigger.id,
title: trigger.title,
detail: `${trigger.elderName || "公共区域"} / ${formatDateTime(trigger.updatedAt)}`,
badge: ALERT_TRIGGER_STATUS_LABELS[trigger.status],
variant: trigger.status === "open" ? "danger" : trigger.status === "acknowledged" ? "warning" : "secondary",
meta: "预警",
}))}
title="规则预警"
/>
) : null}
{familyVisits !== undefined || familyFeedback !== undefined ? (
<QueueCard
emptyText="暂无家属协同事项"
href={familyHref}
items={familyQueueItems}
title="家属服务"
/>
) : null}
</section>
) : null}
{occupancyData !== undefined || recentAdmissions !== undefined ? (
<section className="grid gap-4 xl:grid-cols-[0.95fr_1.05fr]">
{occupancyData !== undefined ? (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="size-5 text-primary" aria-hidden="true" />
</CardTitle>
<CardDescription>
{occupancyData.map((item) => `${bedStatusLabels[item.label as BedStatus] ?? item.label} ${item.count}`).join(" / ")}
</CardDescription>
</CardHeader>
<CardContent>
<Link className="block" href={bedsHref}>
<BedOccupancyChart data={occupancyData} />
</Link>
</CardContent>
</Card>
) : null}
{recentAdmissions !== undefined ? (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-md border">
<Table className="w-full min-w-[620px] 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.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{recentAdmissions.map((admission) => (
<Table.Row key={admission.id}>
<Table.Cell className="px-4 py-3 font-medium">{admission.elderName}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{admission.bedLabel}</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={admission.status === "active" ? "success" : "secondary"}>
{admissionStatusLabels[admission.status]}
</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">
{formatDateTime(admission.dischargedAt ?? admission.admittedAt)}
</Table.Cell>
</Table.Row>
))}
{recentAdmissions.length === 0 ? (
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={4}>
</Table.Cell>
</Table.Row>
) : null}
</Table.Body>
</Table>
</div>
</CardContent>
</Card>
) : null}
</section>
) : null}
{aiBoardItems !== undefined ? (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2">
<BrainCircuit className="size-5 text-primary" aria-hidden="true" />
</CardTitle>
<CardDescription></CardDescription>
</div>
<Link className="text-sm font-medium text-primary hover:underline" href={eldersHref}>
</Link>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{aiBoardItems.map((item) => (
<Link className="rounded-md border p-3 hover:bg-secondary/60" href={eldersHref} key={item.id}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{item.elderName}</p>
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{item.restricted ? "结果受限:缺少一个或多个数据范围权限" : item.result?.summary ?? item.errorReason ?? "暂无摘要"}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{item.dataScopes.join(" / ")} / {formatDateTime(item.createdAt)}
</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-2">
<Badge variant={item.status === "failed" ? "danger" : "success"}>{item.status === "failed" ? "失败" : "已完成"}</Badge>
{!item.restricted && item.result ? (
<Badge variant={analysisRiskVariant(item.result.overallRiskLevel)}>{analysisRiskLabels[item.result.overallRiskLevel]}</Badge>
) : null}
</div>
</div>
</Link>
))}
{aiBoardItems.length === 0 ? <p className="text-sm text-muted-foreground"></p> : null}
</CardContent>
</Card>
) : null}
{incidents !== undefined ? (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{incidents.map((incident) => (
<div key={incident.id} className="flex items-start justify-between gap-3 rounded-md border p-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<AlertTriangle className="size-4 shrink-0 text-amber-600" aria-hidden="true" />
<p className="truncate text-sm font-medium">{incident.title}</p>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{incident.source} / {formatDateTime(incident.createdAt)}
</p>
</div>
<Badge variant={incidentVariant(incident.severity)}>{incident.status}</Badge>
</div>
))}
{incidents.length === 0 ? <p className="text-sm text-muted-foreground"></p> : null}
</CardContent>
</Card>
) : null}
</div>
);
}
type QueueItem = {
id: string;
badge: string;
detail: string;
meta: string;
title: string;
variant: "danger" | "secondary" | "success" | "warning";
};
function QueueCard({
emptyText,
href,
items,
title,
}: {
emptyText: string;
href: string;
items: QueueItem[];
title: string;
}): React.ReactElement {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-3">
<CardTitle>{title}</CardTitle>
<Link className="text-sm font-medium text-primary hover:underline" href={href}>
</Link>
</CardHeader>
<CardContent className="grid gap-2">
{items.map((item) => (
<Link className="rounded-md border p-3 hover:bg-secondary/60" href={href} key={item.id}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{item.title}</p>
<p className="mt-1 truncate text-xs text-muted-foreground">{item.detail}</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="text-xs text-muted-foreground">{item.meta}</span>
<Badge variant={item.variant}>{item.badge}</Badge>
</div>
</div>
</Link>
))}
{items.length === 0 ? <p className="text-sm text-muted-foreground">{emptyText}</p> : null}
</CardContent>
</Card>
);
}