diff --git a/.trellis/tasks/07-02-operations-dashboard-integration/design.md b/.trellis/tasks/07-02-operations-dashboard-integration/design.md new file mode 100644 index 0000000..f457cc7 --- /dev/null +++ b/.trellis/tasks/07-02-operations-dashboard-integration/design.md @@ -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. diff --git a/.trellis/tasks/07-02-operations-dashboard-integration/implement.md b/.trellis/tasks/07-02-operations-dashboard-integration/implement.md new file mode 100644 index 0000000..be4a8a7 --- /dev/null +++ b/.trellis/tasks/07-02-operations-dashboard-integration/implement.md @@ -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. diff --git a/.trellis/tasks/07-02-operations-dashboard-integration/task.json b/.trellis/tasks/07-02-operations-dashboard-integration/task.json index 6df24d4..128248a 100644 --- a/.trellis/tasks/07-02-operations-dashboard-integration/task.json +++ b/.trellis/tasks/07-02-operations-dashboard-integration/task.json @@ -3,7 +3,7 @@ "name": "operations-dashboard-integration", "title": "运营看板整合优化", "description": "", - "status": "planning", + "status": "in_progress", "dev_type": null, "scope": null, "package": null, diff --git a/app/(app)/app/dashboard/page.tsx b/app/(app)/app/dashboard/page.tsx index 5291c60..d2261ee 100644 --- a/app/(app)/app/dashboard/page.tsx +++ b/app/(app)/app/dashboard/page.tsx @@ -9,11 +9,14 @@ import { } from "@/modules/core/server/operations"; import { hasPermission } from "@/modules/core/server/permissions"; 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 { listEmergencyIncidentData } from "@/modules/emergency/server/operations"; +import { listHealthAdminData } from "@/modules/health/server/operations"; import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing"; 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 { const context = await getCurrentAuthContext(); @@ -27,11 +30,18 @@ export default async function DashboardPage(): Promise { 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), listOperationalBeds(organizationId), listOperationalAdmissions(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; @@ -40,12 +50,20 @@ export default async function DashboardPage(): Promise { const availableBeds = beds.filter((bed) => bed.status === "available").length; const activeAdmissions = admissions.filter((admission) => admission.status === "active").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[] = [ { label: "在院老人", value: String(activeElders), trend: `待入住 ${pendingElders}`, icon: "users" }, { 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(openIncidents), trend: `总计 ${incidents.length}`, icon: "incidents" }, + { label: "应急事件", value: String(openEmergency), trend: `紧急 ${criticalEmergency} / 未关闭 ${openIncidents}`, icon: "incidents" }, ]; const occupancyData = BED_STATUSES.map((status) => ({ @@ -73,9 +91,49 @@ export default async function DashboardPage(): Promise { source: incident.source, 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 ( = { - users: Users, - beds: BedDouble, admissions: CheckCircle2, + beds: BedDouble, + care: ListChecks, + health: HeartPulse, incidents: HeartPulse, + users: Users, }; const admissionStatusLabels: Record = { @@ -75,6 +108,13 @@ function incidentVariant(severity: IncidentSeverity): "secondary" | "warning" | } export function DashboardHome({ + bedsHref, + careHref, + careTasks, + emergencyHref, + emergencyIncidents, + healthHref, + healthReviews, metrics, occupancyData, recentAdmissions, @@ -84,13 +124,12 @@ export function DashboardHome({
- 实时数据 -

运营看板

-

老人、床位、入住和系统故障概况。

+

运营看板

+

老人、床位、护理、健康和应急事件概况。

-
+
{metrics.map((metric) => { const Icon = metricIcons[metric.icon]; @@ -109,6 +148,48 @@ export function DashboardHome({ })}
+
+ ({ + 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="护理待办" + /> + ({ + 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="健康复核" + /> + ({ + 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="安全应急" + /> +
+
@@ -121,7 +202,9 @@ export function DashboardHome({ - + + + @@ -171,7 +254,7 @@ export function DashboardHome({ - 系统故障 + 未关闭故障 {incidents.map((incident) => ( @@ -194,3 +277,52 @@ export function DashboardHome({
); } + +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 ( + + + {title} + + 进入 + + + + {items.map((item) => ( + +
+
+

{item.title}

+

{item.detail}

+
+
+ {item.meta} + {item.badge} +
+
+ + ))} + {items.length === 0 ?

{emptyText}

: null} +
+
+ ); +}