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

329 lines
12 KiB
TypeScript

import Link from "next/link";
import { Activity, AlertTriangle, BedDouble, CheckCircle2, HeartPulse, ListChecks, 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 { 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 { INCIDENT_SEVERITY_LABELS, INCIDENT_STATUS_LABELS } from "@/modules/emergency/types";
import type { HealthReviewSeverity, HealthReviewStatus } from "@/modules/health/types";
import { HEALTH_REVIEW_SEVERITY_LABELS, HEALTH_REVIEW_STATUS_LABELS } from "@/modules/health/types";
export type DashboardMetric = {
label: string;
value: string;
trend: string;
icon: "admissions" | "beds" | "care" | "health" | "incidents" | "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;
};
type DashboardHomeProps = {
bedsHref: string;
careHref: string;
careTasks: DashboardCareTask[];
emergencyHref: string;
emergencyIncidents: DashboardIncident[];
healthHref: string;
healthReviews: DashboardHealthReview[];
metrics: DashboardMetric[];
occupancyData: BedOccupancyDatum[];
recentAdmissions: DashboardAdmission[];
incidents: DashboardIncident[];
};
const metricIcons: Record<DashboardMetric["icon"], LucideIcon> = {
admissions: CheckCircle2,
beds: BedDouble,
care: ListChecks,
health: HeartPulse,
incidents: HeartPulse,
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";
}
}
export function DashboardHome({
bedsHref,
careHref,
careTasks,
emergencyHref,
emergencyIncidents,
healthHref,
healthReviews,
metrics,
occupancyData,
recentAdmissions,
incidents,
}: DashboardHomeProps): React.ReactElement {
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>
<section className="grid gap-4 xl:grid-cols-3">
<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="护理待办"
/>
<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="健康复核"
/>
<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="安全应急"
/>
</section>
<section className="grid gap-4 xl:grid-cols-[0.95fr_1.05fr]">
<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>
<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>
</section>
<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>
</div>
);
}
type QueueItem = {
id: string;
badge: string;
detail: string;
meta: string;
title: string;
variant: "danger" | "secondary" | "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>
);
}