feat: integrate operations dashboard summaries
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
# Operations Dashboard Integration Design
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Upgrade the dashboard into a cross-module operations summary now that health, care, emergency, elder, bed, and admission data are persisted.
|
||||||
|
|
||||||
|
## Data Boundary
|
||||||
|
|
||||||
|
The dashboard remains a Server Component:
|
||||||
|
|
||||||
|
- elder/bed/admission data from `modules/core/server/operations.ts`
|
||||||
|
- health data from `listHealthAdminData`
|
||||||
|
- care data from `listCareExecutionData`
|
||||||
|
- emergency data from `listEmergencyIncidentData`
|
||||||
|
|
||||||
|
Load optional module data only when the viewer has the corresponding read permission. Do not fabricate metrics when permission or data is absent.
|
||||||
|
|
||||||
|
## UI Boundary
|
||||||
|
|
||||||
|
`DashboardHome` stays presentational. It receives:
|
||||||
|
|
||||||
|
- primary metrics
|
||||||
|
- bed occupancy chart data
|
||||||
|
- recent admissions
|
||||||
|
- work queues for health, care, and emergency
|
||||||
|
|
||||||
|
Dashboard items should link into modules:
|
||||||
|
|
||||||
|
- health reviews -> `/settings/health`
|
||||||
|
- care tasks -> `/care`
|
||||||
|
- emergency events -> `/emergency`
|
||||||
|
- admissions -> `/beds`
|
||||||
|
|
||||||
|
The dashboard is not a mutation surface.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
- Existing bed occupancy chart remains data-backed.
|
||||||
|
- Existing admission list remains data-backed.
|
||||||
|
- Top badge-style tag should be removed to match the module cleanup direction.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Implementation Plan
|
||||||
|
|
||||||
|
## 1. Start Task
|
||||||
|
|
||||||
|
- Start Trellis task.
|
||||||
|
- Preserve unrelated dirty settings/dialog files.
|
||||||
|
|
||||||
|
## 2. Server Data
|
||||||
|
|
||||||
|
- Update dashboard page to load health/care/emergency summaries when permitted.
|
||||||
|
- Keep all reads organization-scoped.
|
||||||
|
- Build route hrefs with `getWorkspaceHref`.
|
||||||
|
|
||||||
|
## 3. Presentational UI
|
||||||
|
|
||||||
|
- Extend `DashboardHome` props for health reviews, care tasks, and emergency events.
|
||||||
|
- Add compact queue sections with links to modules.
|
||||||
|
- Remove top badge tag.
|
||||||
|
|
||||||
|
## 4. Verification
|
||||||
|
|
||||||
|
- `pnpm lint`
|
||||||
|
- `pnpm type-check`
|
||||||
|
- `pnpm test`
|
||||||
|
- `pnpm build`
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
- Revert dashboard page and `DashboardHome` changes.
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"name": "operations-dashboard-integration",
|
"name": "operations-dashboard-integration",
|
||||||
"title": "运营看板整合优化",
|
"title": "运营看板整合优化",
|
||||||
"description": "",
|
"description": "",
|
||||||
"status": "planning",
|
"status": "in_progress",
|
||||||
"dev_type": null,
|
"dev_type": null,
|
||||||
"scope": null,
|
"scope": null,
|
||||||
"package": null,
|
"package": null,
|
||||||
|
|||||||
@@ -9,11 +9,14 @@ import {
|
|||||||
} from "@/modules/core/server/operations";
|
} from "@/modules/core/server/operations";
|
||||||
import { hasPermission } from "@/modules/core/server/permissions";
|
import { hasPermission } from "@/modules/core/server/permissions";
|
||||||
import type { BedStatus, Permission } from "@/modules/core/types";
|
import type { BedStatus, Permission } from "@/modules/core/types";
|
||||||
|
import { listCareExecutionData } from "@/modules/care/server/operations";
|
||||||
import { DashboardHome, type DashboardMetric } from "@/modules/dashboard/components/DashboardHome";
|
import { DashboardHome, type DashboardMetric } from "@/modules/dashboard/components/DashboardHome";
|
||||||
|
import { listEmergencyIncidentData } from "@/modules/emergency/server/operations";
|
||||||
|
import { listHealthAdminData } from "@/modules/health/server/operations";
|
||||||
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
const BED_STATUSES: BedStatus[] = ["available", "occupied", "maintenance", "disabled"];
|
const BED_STATUSES: BedStatus[] = ["available", "occupied", "maintenance", "disabled"];
|
||||||
const DASHBOARD_PERMISSIONS: Permission[] = ["elder:read", "facility:read", "admission:read", "incident:read"];
|
const DASHBOARD_PERMISSIONS: Permission[] = ["elder:read", "facility:read", "admission:read", "incident:read", "care:read", "health:read"];
|
||||||
|
|
||||||
export default async function DashboardPage(): Promise<React.ReactElement> {
|
export default async function DashboardPage(): Promise<React.ReactElement> {
|
||||||
const context = await getCurrentAuthContext();
|
const context = await getCurrentAuthContext();
|
||||||
@@ -27,11 +30,18 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
|
|||||||
redirect(getWorkspaceHref(context.organization?.slug, "/family"));
|
redirect(getWorkspaceHref(context.organization?.slug, "/family"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const [elders, beds, admissions, incidents] = await Promise.all([
|
const canReadCare = hasPermission(context.permissions, "care:read");
|
||||||
|
const canReadHealth = hasPermission(context.permissions, "health:read");
|
||||||
|
const canReadIncidents = hasPermission(context.permissions, "incident:read");
|
||||||
|
|
||||||
|
const [elders, beds, admissions, incidents, careData, healthData, emergencyData] = await Promise.all([
|
||||||
listOperationalElders(organizationId),
|
listOperationalElders(organizationId),
|
||||||
listOperationalBeds(organizationId),
|
listOperationalBeds(organizationId),
|
||||||
listOperationalAdmissions(organizationId),
|
listOperationalAdmissions(organizationId),
|
||||||
listOperationalIncidents(organizationId),
|
listOperationalIncidents(organizationId),
|
||||||
|
organizationId && canReadCare ? listCareExecutionData(organizationId) : undefined,
|
||||||
|
organizationId && canReadHealth ? listHealthAdminData(organizationId) : undefined,
|
||||||
|
organizationId && canReadIncidents ? listEmergencyIncidentData(organizationId) : undefined,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const activeElders = elders.filter((elder) => elder.status === "active").length;
|
const activeElders = elders.filter((elder) => elder.status === "active").length;
|
||||||
@@ -40,12 +50,20 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
|
|||||||
const availableBeds = beds.filter((bed) => bed.status === "available").length;
|
const availableBeds = beds.filter((bed) => bed.status === "available").length;
|
||||||
const activeAdmissions = admissions.filter((admission) => admission.status === "active").length;
|
const activeAdmissions = admissions.filter((admission) => admission.status === "active").length;
|
||||||
const openIncidents = incidents.filter((incident) => incident.status !== "closed").length;
|
const openIncidents = incidents.filter((incident) => incident.status !== "closed").length;
|
||||||
|
const pendingCare = careData?.metrics.pending ?? 0;
|
||||||
|
const inProgressCare = careData?.metrics.inProgress ?? 0;
|
||||||
|
const pendingHealthReviews = healthData?.metrics.pendingReviews ?? 0;
|
||||||
|
const activeChronicConditions = healthData?.metrics.activeChronicConditions ?? 0;
|
||||||
|
const openEmergency = emergencyData?.metrics.open ?? 0;
|
||||||
|
const criticalEmergency = emergencyData?.metrics.critical ?? 0;
|
||||||
|
|
||||||
const metrics: DashboardMetric[] = [
|
const metrics: DashboardMetric[] = [
|
||||||
{ label: "在院老人", value: String(activeElders), trend: `待入住 ${pendingElders}`, icon: "users" },
|
{ label: "在院老人", value: String(activeElders), trend: `待入住 ${pendingElders}`, icon: "users" },
|
||||||
{ label: "床位占用", value: `${occupiedBeds}/${beds.length}`, trend: `空闲 ${availableBeds}`, icon: "beds" },
|
{ label: "床位占用", value: `${occupiedBeds}/${beds.length}`, trend: `空闲 ${availableBeds}`, icon: "beds" },
|
||||||
|
{ label: "护理任务", value: String(pendingCare + inProgressCare), trend: `待处理 ${pendingCare} / 进行中 ${inProgressCare}`, icon: "care" },
|
||||||
|
{ label: "健康复核", value: String(pendingHealthReviews), trend: `慢病管理 ${activeChronicConditions}`, icon: "health" },
|
||||||
{ label: "当前入住", value: String(activeAdmissions), trend: `历史 ${admissions.length}`, icon: "admissions" },
|
{ label: "当前入住", value: String(activeAdmissions), trend: `历史 ${admissions.length}`, icon: "admissions" },
|
||||||
{ label: "未关闭故障", value: String(openIncidents), trend: `总计 ${incidents.length}`, icon: "incidents" },
|
{ label: "应急事件", value: String(openEmergency), trend: `紧急 ${criticalEmergency} / 未关闭 ${openIncidents}`, icon: "incidents" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const occupancyData = BED_STATUSES.map((status) => ({
|
const occupancyData = BED_STATUSES.map((status) => ({
|
||||||
@@ -73,9 +91,49 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
|
|||||||
source: incident.source,
|
source: incident.source,
|
||||||
createdAt: incident.createdAt,
|
createdAt: incident.createdAt,
|
||||||
}));
|
}));
|
||||||
|
const careTasks = (careData?.tasks ?? [])
|
||||||
|
.filter((task) => task.status === "pending" || task.status === "in_progress" || task.priority === "urgent" || task.priority === "high")
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((task) => ({
|
||||||
|
id: task.id,
|
||||||
|
assigneeLabel: task.assigneeLabel,
|
||||||
|
elderName: task.elderName,
|
||||||
|
priority: task.priority,
|
||||||
|
scheduledAt: task.scheduledAt,
|
||||||
|
status: task.status,
|
||||||
|
title: task.title,
|
||||||
|
}));
|
||||||
|
const healthReviews = (healthData?.reviews ?? [])
|
||||||
|
.filter((review) => review.status === "pending" || review.severity === "critical")
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((review) => ({
|
||||||
|
id: review.id,
|
||||||
|
elderName: review.elderName,
|
||||||
|
severity: review.severity,
|
||||||
|
status: review.status,
|
||||||
|
title: review.title,
|
||||||
|
}));
|
||||||
|
const emergencyIncidents = (emergencyData?.incidents ?? [])
|
||||||
|
.filter((incident) => incident.status === "open" || incident.status === "acknowledged" || incident.severity === "critical")
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((incident) => ({
|
||||||
|
id: incident.id,
|
||||||
|
severity: incident.severity,
|
||||||
|
status: incident.status,
|
||||||
|
title: incident.title,
|
||||||
|
source: incident.source,
|
||||||
|
createdAt: incident.createdAt,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardHome
|
<DashboardHome
|
||||||
|
bedsHref={getWorkspaceHref(context.organization?.slug, "/beds")}
|
||||||
|
careHref={getWorkspaceHref(context.organization?.slug, "/care")}
|
||||||
|
careTasks={careTasks}
|
||||||
|
emergencyHref={getWorkspaceHref(context.organization?.slug, "/emergency")}
|
||||||
|
emergencyIncidents={emergencyIncidents}
|
||||||
|
healthHref={getWorkspaceHref(context.organization?.slug, "/settings/health")}
|
||||||
|
healthReviews={healthReviews}
|
||||||
incidents={visibleIncidents}
|
incidents={visibleIncidents}
|
||||||
metrics={metrics}
|
metrics={metrics}
|
||||||
occupancyData={occupancyData}
|
occupancyData={occupancyData}
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
import { Activity, AlertTriangle, BedDouble, CheckCircle2, HeartPulse, Users } from "lucide-react";
|
import Link from "next/link";
|
||||||
|
import { Activity, AlertTriangle, BedDouble, CheckCircle2, HeartPulse, ListChecks, Users } from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Table } from "@/components/ui/table";
|
import { Table } from "@/components/ui/table";
|
||||||
import type { AdmissionStatus, BedStatus, IncidentSeverity } from "@/modules/core/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 { 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 = {
|
export type DashboardMetric = {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
trend: string;
|
trend: string;
|
||||||
icon: "users" | "beds" | "admissions" | "incidents";
|
icon: "admissions" | "beds" | "care" | "health" | "incidents" | "users";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DashboardAdmission = {
|
export type DashboardAdmission = {
|
||||||
@@ -32,7 +38,32 @@ export type DashboardIncident = {
|
|||||||
createdAt: 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 = {
|
type DashboardHomeProps = {
|
||||||
|
bedsHref: string;
|
||||||
|
careHref: string;
|
||||||
|
careTasks: DashboardCareTask[];
|
||||||
|
emergencyHref: string;
|
||||||
|
emergencyIncidents: DashboardIncident[];
|
||||||
|
healthHref: string;
|
||||||
|
healthReviews: DashboardHealthReview[];
|
||||||
metrics: DashboardMetric[];
|
metrics: DashboardMetric[];
|
||||||
occupancyData: BedOccupancyDatum[];
|
occupancyData: BedOccupancyDatum[];
|
||||||
recentAdmissions: DashboardAdmission[];
|
recentAdmissions: DashboardAdmission[];
|
||||||
@@ -40,10 +71,12 @@ type DashboardHomeProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const metricIcons: Record<DashboardMetric["icon"], LucideIcon> = {
|
const metricIcons: Record<DashboardMetric["icon"], LucideIcon> = {
|
||||||
users: Users,
|
|
||||||
beds: BedDouble,
|
|
||||||
admissions: CheckCircle2,
|
admissions: CheckCircle2,
|
||||||
|
beds: BedDouble,
|
||||||
|
care: ListChecks,
|
||||||
|
health: HeartPulse,
|
||||||
incidents: HeartPulse,
|
incidents: HeartPulse,
|
||||||
|
users: Users,
|
||||||
};
|
};
|
||||||
|
|
||||||
const admissionStatusLabels: Record<AdmissionStatus, string> = {
|
const admissionStatusLabels: Record<AdmissionStatus, string> = {
|
||||||
@@ -75,6 +108,13 @@ function incidentVariant(severity: IncidentSeverity): "secondary" | "warning" |
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardHome({
|
export function DashboardHome({
|
||||||
|
bedsHref,
|
||||||
|
careHref,
|
||||||
|
careTasks,
|
||||||
|
emergencyHref,
|
||||||
|
emergencyIncidents,
|
||||||
|
healthHref,
|
||||||
|
healthReviews,
|
||||||
metrics,
|
metrics,
|
||||||
occupancyData,
|
occupancyData,
|
||||||
recentAdmissions,
|
recentAdmissions,
|
||||||
@@ -84,13 +124,12 @@ export function DashboardHome({
|
|||||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6">
|
<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">
|
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Badge variant="success">实时数据</Badge>
|
<h1 className="text-3xl font-semibold tracking-normal md:text-4xl">运营看板</h1>
|
||||||
<h1 className="mt-3 text-3xl font-semibold tracking-normal md:text-4xl">运营看板</h1>
|
<p className="mt-2 text-sm text-muted-foreground">老人、床位、护理、健康和应急事件概况。</p>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">老人、床位、入住和系统故障概况。</p>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{metrics.map((metric) => {
|
{metrics.map((metric) => {
|
||||||
const Icon = metricIcons[metric.icon];
|
const Icon = metricIcons[metric.icon];
|
||||||
|
|
||||||
@@ -109,6 +148,48 @@ export function DashboardHome({
|
|||||||
})}
|
})}
|
||||||
</section>
|
</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]">
|
<section className="grid gap-4 xl:grid-cols-[0.95fr_1.05fr]">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -121,7 +202,9 @@ export function DashboardHome({
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
<Link className="block" href={bedsHref}>
|
||||||
<BedOccupancyChart data={occupancyData} />
|
<BedOccupancyChart data={occupancyData} />
|
||||||
|
</Link>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -171,7 +254,7 @@ export function DashboardHome({
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>系统故障</CardTitle>
|
<CardTitle>未关闭故障</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{incidents.map((incident) => (
|
{incidents.map((incident) => (
|
||||||
@@ -194,3 +277,52 @@ export function DashboardHome({
|
|||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user