197 lines
7.1 KiB
TypeScript
197 lines
7.1 KiB
TypeScript
import { Activity, AlertTriangle, BedDouble, CheckCircle2, HeartPulse, 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 { BedOccupancyChart, type BedOccupancyDatum } from "@/modules/dashboard/components/BedOccupancyChart";
|
|
|
|
export type DashboardMetric = {
|
|
label: string;
|
|
value: string;
|
|
trend: string;
|
|
icon: "users" | "beds" | "admissions" | "incidents";
|
|
};
|
|
|
|
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;
|
|
};
|
|
|
|
type DashboardHomeProps = {
|
|
metrics: DashboardMetric[];
|
|
occupancyData: BedOccupancyDatum[];
|
|
recentAdmissions: DashboardAdmission[];
|
|
incidents: DashboardIncident[];
|
|
};
|
|
|
|
const metricIcons: Record<DashboardMetric["icon"], LucideIcon> = {
|
|
users: Users,
|
|
beds: BedDouble,
|
|
admissions: CheckCircle2,
|
|
incidents: HeartPulse,
|
|
};
|
|
|
|
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({
|
|
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>
|
|
<Badge variant="success">实时数据</Badge>
|
|
<h1 className="mt-3 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-4">
|
|
{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-[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>
|
|
<BedOccupancyChart data={occupancyData} />
|
|
</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>
|
|
);
|
|
}
|