diff --git a/.trellis/tasks/07-08-complete-ops-ai-board/check.jsonl b/.trellis/tasks/07-08-complete-ops-ai-board/check.jsonl new file mode 100644 index 0000000..7c101c4 --- /dev/null +++ b/.trellis/tasks/07-08-complete-ops-ai-board/check.jsonl @@ -0,0 +1,4 @@ +{"file":".trellis/spec/backend/type-safety.md","reason":"Check backend AI aggregation and seed helper typing."} +{"file":".trellis/spec/frontend/components.md","reason":"Check dashboard rendering remains Server Component and uses persisted data only."} +{"file":".trellis/spec/shared/code-quality.md","reason":"Check focused tests, no dead code, no console logs, and no forbidden TypeScript patterns."} +{"file":".trellis/spec/guides/pre-implementation-checklist.md","reason":"Check cross-layer dashboard data flow and permission gates."} diff --git a/.trellis/tasks/07-08-complete-ops-ai-board/design.md b/.trellis/tasks/07-08-complete-ops-ai-board/design.md new file mode 100644 index 0000000..094dd58 --- /dev/null +++ b/.trellis/tasks/07-08-complete-ops-ai-board/design.md @@ -0,0 +1,61 @@ +# Design + +## Boundaries + +This task is a completion pass over existing persisted modules. It does not add schema or a new AI provider path. + +- Collaboration data remains owned by `modules/devices`, `modules/notices`, `modules/alerts`, and `modules/family`. +- Dashboard data loading stays in the Server Component at `app/(app)/app/dashboard/page.tsx`. +- Presentation stays in `modules/dashboard/components/DashboardHome.tsx`. +- AI board aggregation belongs in `modules/ai/server/analysis.ts` because redaction depends on existing AI analysis scope rules. + +## Data Flow + +1. Dashboard page loads auth context and checks whether any visible dashboard section is permitted. +2. It conditionally calls existing operations for core care/health/emergency and collaboration modules. +3. It calls a new AI analysis listing function only when `ai:read` is present. +4. The AI service lists organization-scoped `elder_ai_analyses`, joins elders for display names, and maps each row through `canViewAnalysisScopes`. +5. The dashboard component receives only serializable data and renders queue cards/summary cards from that data. + +## Seeding Strategy + +Existing `seedDefaultWorkspaceData` exits early when core room/bed/elder/admission rows already exist. That protected existing workspaces, but it skipped collaboration seed records added later. Add a separate idempotent collaboration seeding helper that: + +- reads existing rows by stable natural keys per table; +- inserts only missing default rows; +- resolves dependencies from existing or newly inserted rows; +- never updates or deletes existing operator data; +- runs both for fully new workspaces and for already-initialized workspaces. + +Stable keys: + +- devices: `code` +- tickets: `title` +- notices: `title` +- alert rules: `name` +- alert triggers: `title` +- family contacts: `elderId + name` +- family visits: `elderId + contactId + scheduled offset/status/notes` is not stable across reruns, so use `elderId + contactId + notes` for default records +- family feedback: `elderId + contactId + content` + +## Permission Model + +- Dashboard visibility expands to collaboration and AI read permissions. +- Collaboration sections call server operations only when their read permission is present. +- AI board calls the aggregation function only with `ai:read`. +- Redaction uses the existing `canViewAnalysisScopes` and `getPermissionForDataScope` rules, so a user may see that an analysis exists without seeing restricted result details. + +## UI Shape + +- Keep `DashboardHome` as a single Server-rendered component with small local helper render functions. +- Add collaboration queue cards for device tickets, notices, alert triggers, and family items. +- Add an AI analysis board card with risk/status badges, data scopes, created time, and a link back to the elder workspace. +- Empty states must be honest: no generated counts or placeholder rows. + +## Compatibility + +No schema migration is required. The seeding helper is additive and only inserts missing default data. + +## Rollback + +Rollback is limited to the AI aggregation helper, dashboard props/rendering, dashboard page conditional loads, and the additive seed helper. Existing collaboration CRUD modules remain untouched unless tests expose a direct integration bug. diff --git a/.trellis/tasks/07-08-complete-ops-ai-board/implement.jsonl b/.trellis/tasks/07-08-complete-ops-ai-board/implement.jsonl new file mode 100644 index 0000000..5e4236b --- /dev/null +++ b/.trellis/tasks/07-08-complete-ops-ai-board/implement.jsonl @@ -0,0 +1,5 @@ +{"file":".trellis/spec/backend/type-safety.md","reason":"Backend service results, scope narrowing, and no non-null assertions."} +{"file":".trellis/spec/frontend/components.md","reason":"Dashboard Server Component data loading, Kumo UI adapter use, and no fake operational data."} +{"file":".trellis/spec/shared/typescript.md","reason":"Shared TypeScript constraints for exported types and discriminated unions."} +{"file":".trellis/spec/shared/code-quality.md","reason":"No any, no non-null assertions, import ordering, and test expectations."} +{"file":".trellis/spec/guides/pre-implementation-checklist.md","reason":"Cross-layer readiness checks before dashboard and seed changes."} diff --git a/.trellis/tasks/07-08-complete-ops-ai-board/implement.md b/.trellis/tasks/07-08-complete-ops-ai-board/implement.md new file mode 100644 index 0000000..7086cf2 --- /dev/null +++ b/.trellis/tasks/07-08-complete-ops-ai-board/implement.md @@ -0,0 +1,33 @@ +# Implementation Plan + +## 1. Planning and Context + +- [x] Read Trellis workflow context and relevant backend/frontend/shared specs. +- [x] Inspect existing collaboration modules, dashboard page/component, AI analysis service, permissions, and seed data. +- [x] Persist PRD, design, implementation plan, and curated spec manifests. + +## 2. Seeding + +- [x] Extract idempotent collaboration seed helper from the full-workspace seed path. +- [x] Call the helper for both existing core workspaces and newly seeded workspaces. +- [x] Preserve existing operator rows and insert only missing default records. + +## 3. AI Analysis Board + +- [x] Add an organization-scoped AI board listing function that joins elder names and redacts restricted analysis rows. +- [x] Add serializable dashboard board item types. +- [x] Keep existing elder-specific analysis APIs unchanged. + +## 4. Dashboard Integration + +- [x] Expand dashboard permission gate to collaboration and AI read permissions. +- [x] Conditionally load devices, notices, alerts, family, and AI board data by permission. +- [x] Render collaboration cards and AI analysis board from persisted data only. +- [x] Preserve slug-aware links via `getWorkspaceHref`. + +## 5. Verification + +- [x] Add focused tests for collaboration seed idempotency and AI board redaction. +- [x] Run focused tests covering changed code. +- [x] Run `pnpm type-check`. +- [x] Smoke-test the dashboard route when feasible via `pnpm build` route compilation. diff --git a/.trellis/tasks/07-08-complete-ops-ai-board/prd.md b/.trellis/tasks/07-08-complete-ops-ai-board/prd.md new file mode 100644 index 0000000..8dc79a5 --- /dev/null +++ b/.trellis/tasks/07-08-complete-ops-ai-board/prd.md @@ -0,0 +1,29 @@ +# Complete operations modules and AI analysis board + +## Goal + +Finish the currently visible operations surface by making collaboration modules fully reachable from the workspace/dashboard and adding a real persisted AI analysis board that summarizes elder AI analysis history without fabricating data. + +## Requirements + +- Keep the existing persisted collaboration modules for devices, notices, alerts, and family visible through permission-aware workspace navigation and dashboard entry points. +- Seed collaboration-module records idempotently for already-initialized local/demo organizations without overwriting operator-created records. +- Show an AI analysis board from persisted `elder_ai_analyses` rows, joined to elder names, with permission-aware redaction for unavailable data scopes. +- Restrict AI board access to users with `ai:read`; never expose analysis content when underlying data-scope permissions are missing. +- Preserve organization scoping for all data loads and route links, including slug-aware `/app/{organizationSlug}/...` links. +- Reuse existing module server operations and UI adapters; do not add new dependencies or fake/static operational data. + +## Acceptance Criteria + +- [x] Existing collaboration modules remain data-backed and reachable from unscoped and organization-scoped workspace routes. +- [x] Existing workspaces with core resident data but no collaboration data receive missing default devices, notices, alerts, and family records on seed rerun. +- [x] The dashboard can be viewed by users with collaboration or AI read permissions, not only core care/facility permissions. +- [x] The dashboard renders collaboration queues/metrics only from persisted server queries and hides unavailable sections by permission. +- [x] The dashboard renders an AI analysis board sourced from persisted analysis history when `ai:read` is present. +- [x] AI analysis board items redact result content when `canViewAnalysisScopes` denies one or more stored data scopes. +- [x] Focused tests cover idempotent collaboration seeding and AI board redaction behavior. +- [x] `pnpm type-check` and focused tests pass. + +## Notes + +- Out of scope: background alert-rule execution, family portal/authentication, AI generation changes, and new database schema. diff --git a/.trellis/tasks/07-08-complete-ops-ai-board/task.json b/.trellis/tasks/07-08-complete-ops-ai-board/task.json new file mode 100644 index 0000000..786d7dd --- /dev/null +++ b/.trellis/tasks/07-08-complete-ops-ai-board/task.json @@ -0,0 +1,26 @@ +{ + "id": "complete-ops-ai-board", + "name": "complete-ops-ai-board", + "title": "Complete operations modules and AI analysis board", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "TalexDreamSoul", + "assignee": "TalexDreamSoul", + "createdAt": "2026-07-08", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/app/(app)/app/dashboard/page.tsx b/app/(app)/app/dashboard/page.tsx index 7ddcb04..5a1a506 100644 --- a/app/(app)/app/dashboard/page.tsx +++ b/app/(app)/app/dashboard/page.tsx @@ -1,5 +1,7 @@ import { redirect } from "next/navigation"; +import { listAiAnalysisBoard } from "@/modules/ai/server/analysis"; +import { listAlertCenterData } from "@/modules/alerts/server/operations"; import { getCurrentAuthContext } from "@/modules/core/server/auth"; import { listOperationalAdmissions, @@ -11,12 +13,27 @@ 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 { listDeviceOperationsData } from "@/modules/devices/server/operations"; import { listEmergencyIncidentData } from "@/modules/emergency/server/operations"; +import { listFamilyServiceData } from "@/modules/family/server/operations"; import { listHealthAdminData } from "@/modules/health/server/operations"; +import { listNoticeCenterData } from "@/modules/notices/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", "care:read", "health:read"]; +const DASHBOARD_PERMISSIONS: Permission[] = [ + "elder:read", + "facility:read", + "admission:read", + "incident:read", + "care:read", + "health:read", + "device:read", + "notice:read", + "alert:read", + "family:read", + "ai:read", +]; export default async function DashboardPage(): Promise { const context = await getCurrentAuthContext(); @@ -30,18 +47,31 @@ export default async function DashboardPage(): Promise { redirect(getWorkspaceHref(context.organization?.slug, "/family")); } + const canReadElders = hasPermission(context.permissions, "elder:read"); + const canReadFacility = hasPermission(context.permissions, "facility:read"); + const canReadAdmissions = hasPermission(context.permissions, "admission:read"); const canReadCare = hasPermission(context.permissions, "care:read"); const canReadHealth = hasPermission(context.permissions, "health:read"); const canReadIncidents = hasPermission(context.permissions, "incident:read"); + const canReadDevices = hasPermission(context.permissions, "device:read"); + const canReadNotices = hasPermission(context.permissions, "notice:read"); + const canReadAlerts = hasPermission(context.permissions, "alert:read"); + const canReadFamily = hasPermission(context.permissions, "family:read"); + const canReadAi = hasPermission(context.permissions, "ai:read"); - const [elders, beds, admissions, incidents, careData, healthData, emergencyData] = await Promise.all([ - listOperationalElders(organizationId), - listOperationalBeds(organizationId), - listOperationalAdmissions(organizationId), - listOperationalIncidents(organizationId), + const [elders, beds, admissions, incidents, careData, healthData, emergencyData, deviceData, noticeData, alertData, familyData, aiBoardResult] = await Promise.all([ + organizationId && canReadElders ? listOperationalElders(organizationId) : [], + organizationId && (canReadFacility || canReadAdmissions) ? listOperationalBeds(organizationId) : [], + organizationId && canReadAdmissions ? listOperationalAdmissions(organizationId) : [], + organizationId && canReadIncidents ? listOperationalIncidents(organizationId) : [], organizationId && canReadCare ? listCareExecutionData(organizationId) : undefined, organizationId && canReadHealth ? listHealthAdminData(organizationId) : undefined, organizationId && canReadIncidents ? listEmergencyIncidentData(organizationId) : undefined, + organizationId && canReadDevices ? listDeviceOperationsData(organizationId) : undefined, + organizationId && canReadNotices ? listNoticeCenterData(organizationId, context.account.id) : undefined, + organizationId && canReadAlerts ? listAlertCenterData(organizationId) : undefined, + organizationId && canReadFamily ? listFamilyServiceData(organizationId) : undefined, + canReadAi ? listAiAnalysisBoard(context, 6) : undefined, ]); const activeElders = elders.filter((elder) => elder.status === "active").length; @@ -56,42 +86,62 @@ export default async function DashboardPage(): Promise { const activeChronicConditions = healthData?.metrics.activeChronicConditions ?? 0; const openEmergency = emergencyData?.metrics.open ?? 0; const criticalEmergency = emergencyData?.metrics.critical ?? 0; + const openDeviceTickets = deviceData?.metrics.openTickets ?? 0; + const urgentDeviceTickets = deviceData?.metrics.urgentTickets ?? 0; + const publishedNotices = noticeData?.metrics.published ?? 0; + const unreadNotices = noticeData?.metrics.unread ?? 0; + const openAlertTriggers = alertData?.metrics.openTriggers ?? 0; + const criticalAlertTriggers = alertData?.metrics.criticalTriggers ?? 0; + const pendingFamilyVisits = familyData?.metrics.pendingVisits ?? 0; + const openFamilyFeedback = familyData?.metrics.openFeedback ?? 0; + const aiBoardItems = aiBoardResult?.success ? aiBoardResult.data.items : undefined; 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(openEmergency), trend: `紧急 ${criticalEmergency} / 未关闭 ${openIncidents}`, icon: "incidents" }, + ...(canReadElders ? [{ label: "在院老人", value: String(activeElders), trend: `待入住 ${pendingElders}`, icon: "users" as const }] : []), + ...(canReadFacility || canReadAdmissions ? [{ label: "床位占用", value: `${occupiedBeds}/${beds.length}`, trend: `空闲 ${availableBeds}`, icon: "beds" as const }] : []), + ...(canReadCare ? [{ label: "护理任务", value: String(pendingCare + inProgressCare), trend: `待处理 ${pendingCare} / 进行中 ${inProgressCare}`, icon: "care" as const }] : []), + ...(canReadHealth ? [{ label: "健康复核", value: String(pendingHealthReviews), trend: `慢病管理 ${activeChronicConditions}`, icon: "health" as const }] : []), + ...(canReadAdmissions ? [{ label: "当前入住", value: String(activeAdmissions), trend: `历史 ${admissions.length}`, icon: "admissions" as const }] : []), + ...(canReadIncidents ? [{ label: "应急事件", value: String(openEmergency), trend: `紧急 ${criticalEmergency} / 未关闭 ${openIncidents}`, icon: "incidents" as const }] : []), + ...(canReadDevices ? [{ label: "设备工单", value: String(openDeviceTickets), trend: `紧急 ${urgentDeviceTickets}`, icon: "devices" as const }] : []), + ...(canReadNotices ? [{ label: "公告通知", value: String(publishedNotices), trend: `未读 ${unreadNotices}`, icon: "notices" as const }] : []), + ...(canReadAlerts ? [{ label: "规则预警", value: String(openAlertTriggers), trend: `关键 ${criticalAlertTriggers}`, icon: "alerts" as const }] : []), + ...(canReadFamily ? [{ label: "家属服务", value: String(pendingFamilyVisits + openFamilyFeedback), trend: `探访 ${pendingFamilyVisits} / 反馈 ${openFamilyFeedback}`, icon: "family" as const }] : []), + ...(canReadAi && aiBoardItems ? [{ label: "智能分析", value: String(aiBoardItems.length), trend: "已保存分析历史", icon: "ai" as const }] : []), ]; - const occupancyData = BED_STATUSES.map((status) => ({ - label: status, - count: beds.filter((bed) => bed.status === status).length, - })); + const occupancyData = canReadFacility || canReadAdmissions + ? BED_STATUSES.map((status) => ({ + label: status, + count: beds.filter((bed) => bed.status === status).length, + })) + : undefined; - const recentAdmissions = admissions.slice(0, 6).map((admission) => ({ - id: admission.id, - elderName: admission.elderName, - bedLabel: `${admission.roomName}-${admission.bedCode}`, - status: admission.status, - admittedAt: admission.admittedAt, - dischargedAt: admission.dischargedAt, - })); + const recentAdmissions = canReadAdmissions + ? admissions.slice(0, 6).map((admission) => ({ + id: admission.id, + elderName: admission.elderName, + bedLabel: `${admission.roomName}-${admission.bedCode}`, + status: admission.status, + admittedAt: admission.admittedAt, + dischargedAt: admission.dischargedAt, + })) + : undefined; - const visibleIncidents = incidents - .filter((incident) => incident.status !== "closed") - .slice(0, 6) - .map((incident) => ({ - id: incident.id, - severity: incident.severity, - status: incident.status, - title: incident.title, - source: incident.source, - createdAt: incident.createdAt, - })); - const careTasks = (careData?.tasks ?? []) + const visibleIncidents = canReadIncidents + ? incidents + .filter((incident) => incident.status !== "closed") + .slice(0, 6) + .map((incident) => ({ + id: incident.id, + severity: incident.severity, + status: incident.status, + title: incident.title, + source: incident.source, + createdAt: incident.createdAt, + })) + : undefined; + const careTasks = careData?.tasks .filter((task) => task.status === "pending" || task.status === "in_progress" || task.priority === "urgent" || task.priority === "high") .slice(0, 6) .map((task) => ({ @@ -103,7 +153,7 @@ export default async function DashboardPage(): Promise { status: task.status, title: task.title, })); - const healthReviews = (healthData?.reviews ?? []) + const healthReviews = healthData?.reviews .filter((review) => review.status === "pending" || review.severity === "critical") .slice(0, 6) .map((review) => ({ @@ -113,7 +163,7 @@ export default async function DashboardPage(): Promise { status: review.status, title: review.title, })); - const emergencyIncidents = (emergencyData?.incidents ?? []) + const emergencyIncidents = emergencyData?.incidents .filter((incident) => incident.status === "open" || incident.status === "acknowledged" || incident.severity === "critical") .slice(0, 6) .map((incident) => ({ @@ -124,18 +174,76 @@ export default async function DashboardPage(): Promise { source: incident.source, createdAt: incident.createdAt, })); + const deviceTickets = deviceData?.tickets + .filter((ticket) => ticket.status === "open" || ticket.status === "assigned" || ticket.priority === "urgent" || ticket.priority === "high") + .slice(0, 6) + .map((ticket) => ({ + id: ticket.id, + deviceName: ticket.deviceName, + priority: ticket.priority, + status: ticket.status, + title: ticket.title, + updatedAt: ticket.updatedAt, + })); + const notices = noticeData?.notices.slice(0, 6).map((notice) => ({ + id: notice.id, + audience: notice.audience, + status: notice.status, + title: notice.title, + updatedAt: notice.updatedAt, + })); + const alertTriggers = alertData?.triggers + .filter((trigger) => trigger.status === "open" || trigger.status === "acknowledged") + .slice(0, 6) + .map((trigger) => ({ + id: trigger.id, + elderName: trigger.elderName, + status: trigger.status, + title: trigger.title, + updatedAt: trigger.updatedAt, + })); + const familyVisits = familyData?.visits + .filter((visit) => visit.status === "requested" || visit.status === "approved") + .slice(0, 3) + .map((visit) => ({ + id: visit.id, + contactName: visit.contactName, + elderName: visit.elderName, + scheduledAt: visit.scheduledAt, + status: visit.status, + })); + const familyFeedback = familyData?.feedback + .filter((feedback) => feedback.status === "open" || feedback.status === "in_progress") + .slice(0, 3) + .map((feedback) => ({ + id: feedback.id, + elderName: feedback.elderName, + status: feedback.status, + updatedAt: feedback.updatedAt, + })); return ( diff --git a/modules/ai/server/analysis.test.ts b/modules/ai/server/analysis.test.ts index b3b83b8..96d1efa 100644 --- a/modules/ai/server/analysis.test.ts +++ b/modules/ai/server/analysis.test.ts @@ -5,7 +5,7 @@ import type { AiRuntimeConfigResult } from "@/modules/ai/server/config"; import { getAiRuntimeConfig } from "@/modules/ai/server/config"; import type { ElderAiResidentContext } from "@/modules/ai/server/elder-context"; import { buildElderAiContext } from "@/modules/ai/server/elder-context"; -import { generateElderAiAnalysis, listElderAiAnalyses } from "@/modules/ai/server/analysis"; +import { generateElderAiAnalysis, listAiAnalysisBoard, listElderAiAnalyses } from "@/modules/ai/server/analysis"; import { retrieveKnowledge } from "@/modules/ai/server/knowledge"; import type { AiCitation, ElderAiAnalysisOutput } from "@/modules/ai/types"; import { recordAuditLog } from "@/modules/core/server/audit"; @@ -65,6 +65,11 @@ type AnalysisRow = { updatedAt: Date; }; +type ElderNameRow = { + id: string; + name: string; +}; + type AnalysisInsertPayload = { organizationId?: unknown; elderId?: unknown; @@ -258,8 +263,9 @@ function createAnalysisRow(values: AnalysisInsertPayload, index: number): Analys }; } -function createDatabaseFake(selectRows: AnalysisRow[] = []): AnalysisDatabaseFake { +function createDatabaseFake(selectRows: AnalysisRow[] = [], elderRows: ElderNameRow[] = []): AnalysisDatabaseFake { const insertedValues: AnalysisInsertPayload[] = []; + const orderedSelectRows = [...selectRows].sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime()); const insert = vi.fn(() => ({ values: vi.fn((values: AnalysisInsertPayload) => { insertedValues.push(values); @@ -268,15 +274,23 @@ function createDatabaseFake(selectRows: AnalysisRow[] = []): AnalysisDatabaseFak }; }), })); - const select = vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - orderBy: vi.fn(() => ({ - limit: vi.fn(async () => selectRows), - })), + const select = vi.fn((selection?: unknown) => { + const selectsElderNames = selection !== null && typeof selection === "object" && "name" in selection; + return { + from: vi.fn(() => ({ + where: vi.fn(() => { + if (selectsElderNames) { + return elderRows; + } + return { + orderBy: vi.fn(() => ({ + limit: vi.fn(async (rowLimit: number) => orderedSelectRows.slice(0, rowLimit)), + })), + }; + }), })), - })), - })); + }; + }); return { insertedValues, insert, select }; } @@ -442,6 +456,130 @@ describe("elder AI analysis service", () => { }); }); + it("lists board analysis rows with elder display names and permitted result content", async () => { + const analysisResult = createModelOutput({ + overallRiskLevel: "high", + summary: "Night wandering requires a care-plan review.", + }); + const completedRow = createAnalysisRow({ + organizationId: "org-1", + elderId: "elder-2", + actorAccountId: "account-1", + status: "completed", + dataScopes: ["elder", "health"], + resultJson: analysisResult, + citationsJson: analysisResult.citations, + modelSummaryJson: analysisResult.modelSummary, + }, 1); + useDatabase(createDatabaseFake([completedRow], [{ id: "elder-2", name: "李建国" }])); + + const result = await listAiAnalysisBoard(createAuthContext(["ai:read", "elder:read", "health:read"]), 5); + + expect(result).toEqual({ + success: true, + data: { + items: [ + expect.objectContaining({ + id: "analysis-2", + elderId: "elder-2", + elderName: "李建国", + status: "completed", + dataScopes: ["elder", "health"], + createdAt: "2026-07-02T01:00:00.000Z", + restricted: false, + result: analysisResult, + }), + ], + }, + }); + }); + + it("redacts board analysis rows when a stored data scope is not permitted", async () => { + const restrictedResult = createModelOutput({ + summary: "Health details should not be visible without health permission.", + }); + const restrictedRow = createAnalysisRow({ + organizationId: "org-1", + elderId: "elder-3", + actorAccountId: "account-1", + status: "completed", + dataScopes: ["elder", "health"], + resultJson: restrictedResult, + citationsJson: restrictedResult.citations, + modelSummaryJson: restrictedResult.modelSummary, + errorCategory: "provider_error", + errorReason: "provider detail should stay hidden", + }, 2); + useDatabase(createDatabaseFake([restrictedRow], [{ id: "elder-3", name: "周玉珍" }])); + + const result = await listAiAnalysisBoard(createAuthContext(["ai:read", "elder:read"]), 5); + + expect(result.success).toBe(true); + if (result.success !== true) { + return; + } + expect(result.data.items).toEqual([ + { + id: "analysis-3", + elderId: "elder-3", + elderName: "周玉珍", + status: "completed", + dataScopes: ["elder", "health"], + createdAt: "2026-07-02T02:00:00.000Z", + restricted: true, + }, + ]); + expect(result.data.items[0]).not.toHaveProperty("result"); + expect(result.data.items[0]).not.toHaveProperty("errorCategory"); + expect(result.data.items[0]).not.toHaveProperty("errorReason"); + }); + + it("returns board rows in newest-first order up to the requested limit", async () => { + const newestRow = createAnalysisRow({ + organizationId: "org-1", + elderId: "elder-4", + actorAccountId: "account-1", + status: "completed", + dataScopes: ["elder"], + resultJson: createModelOutput({ summary: "Newest analysis" }), + }, 3); + const olderRow = createAnalysisRow({ + organizationId: "org-1", + elderId: "elder-1", + actorAccountId: "account-1", + status: "completed", + dataScopes: ["elder"], + resultJson: createModelOutput({ summary: "Older analysis" }), + }, 0); + const middleRow = createAnalysisRow({ + organizationId: "org-1", + elderId: "elder-2", + actorAccountId: "account-1", + status: "completed", + dataScopes: ["elder"], + resultJson: createModelOutput({ summary: "Middle analysis" }), + }, 1); + useDatabase(createDatabaseFake( + [olderRow, newestRow, middleRow], + [ + { id: "elder-1", name: "王阿姨" }, + { id: "elder-2", name: "李建国" }, + { id: "elder-4", name: "陈桂兰" }, + ], + )); + + const result = await listAiAnalysisBoard(createAuthContext(["ai:read", "elder:read"]), 2); + + expect(result.success).toBe(true); + if (result.success !== true) { + return; + } + expect(result.data.items.map((item) => ({ id: item.id, elderName: item.elderName, createdAt: item.createdAt }))).toEqual([ + { id: "analysis-4", elderName: "陈桂兰", createdAt: "2026-07-02T03:00:00.000Z" }, + { id: "analysis-2", elderName: "李建国", createdAt: "2026-07-02T01:00:00.000Z" }, + ]); + }); + it("rejects model output that cites IDs outside resident and knowledge citations", async () => { const database = createDatabaseFake(); useDatabase(database); diff --git a/modules/ai/server/analysis.ts b/modules/ai/server/analysis.ts index 3c705a6..191c45f 100644 --- a/modules/ai/server/analysis.ts +++ b/modules/ai/server/analysis.ts @@ -9,6 +9,7 @@ import { retrieveKnowledge } from "@/modules/ai/server/knowledge"; import type { AiCitation, AiErrorCategory, + ElderAiAnalysisBoardItem, ElderAiAnalysisHistoryItem, ElderAiAnalysisOutput, } from "@/modules/ai/types"; @@ -19,7 +20,7 @@ import { } from "@/modules/ai/types"; import { recordAuditLog } from "@/modules/core/server/audit"; import { getDatabase } from "@/modules/core/server/db"; -import { elderAiAnalyses } from "@/modules/core/server/schema"; +import { elderAiAnalyses, elders } from "@/modules/core/server/schema"; import type { AuthContext } from "@/modules/core/types"; type ServiceResult = { success: true; data: T } | { success: false; reason: string; status: number }; @@ -202,6 +203,48 @@ export async function listElderAiAnalyses( }; } +function rowToBoardItem(row: AnalysisRow, elderName: string, permissions: AuthContext["permissions"]): ElderAiAnalysisBoardItem { + return { + elderName, + ...rowToHistoryItem(row, permissions), + }; +} + +export async function listAiAnalysisBoard( + context: AuthContext, + limit = 8, +): Promise> { + if (!context.permissions.includes("ai:read")) { + return { success: false, reason: "无权查看 AI 分析", status: 403 }; + } + + const organizationId = context.organization?.id; + if (!organizationId) { + return { success: false, reason: "请选择机构后查看 AI 分析", status: 400 }; + } + + const normalizedLimit = Number.isFinite(limit) ? Math.trunc(limit) : 8; + const rowLimit = Math.min(20, Math.max(1, normalizedLimit)); + const database = getDatabase(); + const [rows, elderRows] = await Promise.all([ + database + .select() + .from(elderAiAnalyses) + .where(eq(elderAiAnalyses.organizationId, organizationId)) + .orderBy(desc(elderAiAnalyses.createdAt)) + .limit(rowLimit), + database.select({ id: elders.id, name: elders.name }).from(elders).where(eq(elders.organizationId, organizationId)), + ]); + const elderNameById = new Map(elderRows.map((elder) => [elder.id, elder.name])); + + return { + success: true, + data: { + items: rows.map((row) => rowToBoardItem(row, elderNameById.get(row.elderId) ?? "未知老人", context.permissions)), + }, + }; +} + export async function generateElderAiAnalysis( context: AuthContext, elderId: string, diff --git a/modules/ai/types.ts b/modules/ai/types.ts index c75d229..71a0273 100644 --- a/modules/ai/types.ts +++ b/modules/ai/types.ts @@ -93,6 +93,10 @@ export type ElderAiAnalysisHistoryItem = { errorReason?: string; }; +export type ElderAiAnalysisBoardItem = ElderAiAnalysisHistoryItem & { + elderName: string; +}; + export type KnowledgeEntryInput = { scope: AiKnowledgeScope; organizationId?: string; diff --git a/modules/core/server/default-workspace-data.test.ts b/modules/core/server/default-workspace-data.test.ts index 1098d3b..409d683 100644 --- a/modules/core/server/default-workspace-data.test.ts +++ b/modules/core/server/default-workspace-data.test.ts @@ -1,9 +1,19 @@ import { describe, expect, it, vi } from "vitest"; import { validateElderAiAnalysisOutput, validateKnowledgeEntryInput } from "@/modules/ai/types"; +import { getDatabase, type AppDatabase } from "@/modules/core/server/db"; import { DEFAULT_AI_KNOWLEDGE_ENTRIES, + DEFAULT_ALERT_RULES, + DEFAULT_ALERT_TRIGGERS, + DEFAULT_DEVICE_ASSETS, + DEFAULT_FAMILY_CONTACTS, + DEFAULT_FAMILY_FEEDBACK, + DEFAULT_FAMILY_VISITS, + DEFAULT_MAINTENANCE_TICKETS, + DEFAULT_NOTICES, DEFAULT_PREPARED_AI_ANALYSES, + seedDefaultCollaborationWorkspaceData, } from "@/modules/core/server/default-workspace-data"; const FORBIDDEN_DEMO_WORDING_PATTERN = /mock|simulation|simulated|demo|sample|placeholder|fixture|fake|test data|模拟|仿真|演示|样例|示例|占位|测试数据/i; @@ -13,6 +23,258 @@ vi.mock("@/modules/core/server/db", () => ({ getDatabase: vi.fn(), })); +function expectStableUniqueNaturalKeys(items: T[], getKey: (item: T) => string, expectedKeys: string[]): void { + const keys = items.map(getKey); + expect(keys).toEqual(expectedKeys); + expect(new Set(keys)).toHaveLength(items.length); + + for (const key of keys) { + expect(key.trim()).toBe(key); + expect(key).not.toBe(""); + } +} + +type SeedInsertRow = Record; + +function isSeedInsertRow(value: unknown): value is SeedInsertRow { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function toSeedInsertRows(values: unknown): SeedInsertRow[] { + return Array.isArray(values) ? values.filter(isSeedInsertRow) : []; +} + +function rowsWithKey(rows: SeedInsertRow[], key: string): SeedInsertRow[] { + return rows.filter((row) => key in row); +} + +function createCollaborationSeedDatabaseMock(params: { + missingDeviceCode: string; + missingTicketTitle: string; + missingNoticeTitle: string; + missingAlertRuleName: string; + missingAlertTriggerTitle: string; + missingFamilyContactName: string; + missingFamilyVisitNotes: string; + missingFamilyFeedbackContent: string; +}): { database: AppDatabase; insertCalls: Array<{ values: SeedInsertRow[] }> } { + const elderNames = Array.from( + new Set([ + ...DEFAULT_FAMILY_CONTACTS.map((contact) => contact.elderName), + ...DEFAULT_ALERT_TRIGGERS.flatMap((trigger) => (trigger.elderName ? [trigger.elderName] : [])), + ]), + ); + const elderRows = elderNames.map((name, index) => ({ id: `elder-${index}`, name })); + const getElderId = (name: string): string => { + const elder = elderRows.find((row) => row.name === name); + if (!elder) { + throw new Error(`Missing test elder row for ${name}`); + } + + return elder.id; + }; + + const allFamilyContactRows = DEFAULT_FAMILY_CONTACTS.map((contact, index) => ({ + id: `contact-${index}`, + elderId: getElderId(contact.elderName), + name: contact.name, + })); + const getContactId = (elderName: string, contactName: string): string => { + const elderId = getElderId(elderName); + const contact = allFamilyContactRows.find((row) => row.elderId === elderId && row.name === contactName); + if (!contact) { + throw new Error(`Missing test contact row for ${elderName}/${contactName}`); + } + + return contact.id; + }; + + const selectResults = [ + elderRows, + DEFAULT_DEVICE_ASSETS.filter((device) => device.code !== params.missingDeviceCode).map((device) => ({ id: `device-${device.code}`, code: device.code })), + DEFAULT_MAINTENANCE_TICKETS.filter((ticket) => ticket.title !== params.missingTicketTitle).map((ticket) => ({ title: ticket.title })), + DEFAULT_NOTICES.filter((notice) => notice.title !== params.missingNoticeTitle).map((notice) => ({ title: notice.title })), + DEFAULT_ALERT_RULES.filter((rule) => rule.name !== params.missingAlertRuleName).map((rule) => ({ id: `rule-${rule.name}`, name: rule.name })), + DEFAULT_ALERT_TRIGGERS.filter((trigger) => trigger.title !== params.missingAlertTriggerTitle).map((trigger) => ({ title: trigger.title })), + allFamilyContactRows.filter((contact) => contact.name !== params.missingFamilyContactName), + DEFAULT_FAMILY_VISITS.filter((visit) => visit.notes !== params.missingFamilyVisitNotes).map((visit) => ({ + elderId: getElderId(visit.elderName), + contactId: getContactId(visit.elderName, visit.contactName), + notes: visit.notes, + })), + DEFAULT_FAMILY_FEEDBACK.filter((feedback) => feedback.content !== params.missingFamilyFeedbackContent).map((feedback) => ({ + elderId: getElderId(feedback.elderName), + contactId: getContactId(feedback.elderName, feedback.contactName), + content: feedback.content, + })), + ]; + let selectIndex = 0; + const insertCalls: Array<{ values: SeedInsertRow[] }> = []; + const transaction = { + insert: vi.fn(() => ({ + values: vi.fn((values: unknown) => { + const rows = toSeedInsertRows(values); + insertCalls.push({ values: rows }); + + return { + returning: vi.fn(() => rows.map((row, index) => ({ ...row, id: `inserted-${index}` }))), + }; + }), + })), + }; + const database = { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => { + const result = selectResults[selectIndex] ?? []; + selectIndex += 1; + return result; + }), + })), + })), + transaction: vi.fn(async (callback: (transactionClient: typeof transaction) => Promise | void) => { + await callback(transaction); + }), + }; + + return { database: database as unknown as AppDatabase, insertCalls }; +} + +describe("default collaboration seed data", () => { + it("keeps idempotent seed natural keys stable, populated, and unique per collaboration table", () => { + expectStableUniqueNaturalKeys(DEFAULT_DEVICE_ASSETS, (device) => device.code, [ + "DEV-CALL-A102-2", + "DEV-SPO2-S101", + "DEV-OXY-A302-2", + "DEV-REHAB-R101", + ]); + expectStableUniqueNaturalKeys(DEFAULT_MAINTENANCE_TICKETS, (ticket) => ticket.title, [ + "床头呼叫器按键失灵", + "氧气接口保养延期", + "康复步行带日常校准", + ]); + expectStableUniqueNaturalKeys(DEFAULT_NOTICES, (notice) => notice.title, [ + "本周家属探访安排", + "夜间重点巡房提醒", + "设备巡检培训材料", + ]); + expectStableUniqueNaturalKeys(DEFAULT_ALERT_RULES, (rule) => rule.name, [ + "血氧低值连续告警", + "高优先级护理任务逾期", + "设备维修工单未派单", + ]); + expectStableUniqueNaturalKeys(DEFAULT_ALERT_TRIGGERS, (trigger) => trigger.title, [ + "S101 血氧低值需复核", + "重点照护夜间巡检逾期", + "氧气接口保养待派单", + ]); + expectStableUniqueNaturalKeys(DEFAULT_FAMILY_CONTACTS, (contact) => `${contact.elderName}/${contact.name}`, [ + "王桂兰/张敏", + "钱松柏/钱宁", + "林玉琴/林晓", + "赵国华/赵蕾", + ]); + expectStableUniqueNaturalKeys(DEFAULT_FAMILY_VISITS, (visit) => `${visit.elderName}/${visit.contactName}/${visit.notes}`, [ + "王桂兰/张敏/安排护理楼一层会客区。", + "钱松柏/钱宁/需医生确认夜间血氧情况后再回复。", + "林玉琴/林晓/已完成探访和短住事项确认。", + ]); + expectStableUniqueNaturalKeys(DEFAULT_FAMILY_FEEDBACK, (feedback) => `${feedback.elderName}/${feedback.contactName}/${feedback.content}`, [ + "赵国华/赵蕾/希望夜间巡房后能同步重点观察结果。", + "林玉琴/林晓/短住房间清洁和呼叫器说明比较清楚。", + ]); + }); + + it("keeps default collaboration records resolvable through their natural-key dependencies", () => { + const deviceCodes = new Set(DEFAULT_DEVICE_ASSETS.map((device) => device.code)); + const alertRuleNames = new Set(DEFAULT_ALERT_RULES.map((rule) => rule.name)); + const elderNames = new Set(DEFAULT_FAMILY_CONTACTS.map((contact) => contact.elderName)); + const contactKeys = new Set(DEFAULT_FAMILY_CONTACTS.map((contact) => `${contact.elderName}/${contact.name}`)); + + expect(DEFAULT_DEVICE_ASSETS.length).toBeGreaterThanOrEqual(3); + expect(DEFAULT_MAINTENANCE_TICKETS.length).toBeGreaterThanOrEqual(1); + expect(DEFAULT_NOTICES.length).toBeGreaterThanOrEqual(1); + expect(DEFAULT_ALERT_RULES.length).toBeGreaterThanOrEqual(1); + expect(DEFAULT_ALERT_TRIGGERS.length).toBeGreaterThanOrEqual(1); + expect(DEFAULT_FAMILY_CONTACTS.length).toBeGreaterThanOrEqual(1); + expect(DEFAULT_FAMILY_VISITS.length).toBeGreaterThanOrEqual(1); + expect(DEFAULT_FAMILY_FEEDBACK.length).toBeGreaterThanOrEqual(1); + + for (const ticket of DEFAULT_MAINTENANCE_TICKETS) { + expect(deviceCodes.has(ticket.deviceCode)).toBe(true); + } + + for (const trigger of DEFAULT_ALERT_TRIGGERS) { + expect(alertRuleNames.has(trigger.ruleName)).toBe(true); + if (trigger.elderName) { + expect(elderNames.has(trigger.elderName)).toBe(true); + } + } + + for (const visit of DEFAULT_FAMILY_VISITS) { + expect(contactKeys.has(`${visit.elderName}/${visit.contactName}`)).toBe(true); + } + + for (const feedback of DEFAULT_FAMILY_FEEDBACK) { + expect(contactKeys.has(`${feedback.elderName}/${feedback.contactName}`)).toBe(true); + } + }); + + it("keeps user-visible collaboration defaults realistic", () => { + const userVisibleContent = [ + ...DEFAULT_DEVICE_ASSETS.map((device) => [device.name, device.category, device.location, device.notes].join("\n")), + ...DEFAULT_MAINTENANCE_TICKETS.map((ticket) => [ticket.title, ticket.description, ticket.assigneeLabel, ticket.resolutionNotes].join("\n")), + ...DEFAULT_NOTICES.map((notice) => [notice.title, notice.content, notice.audience].join("\n")), + ...DEFAULT_ALERT_RULES.map((rule) => [rule.name, rule.ruleType, rule.conditionSummary, rule.suggestion].join("\n")), + ...DEFAULT_ALERT_TRIGGERS.map((trigger) => [trigger.title, trigger.description, trigger.source, trigger.handlingNotes].join("\n")), + ...DEFAULT_FAMILY_CONTACTS.map((contact) => [contact.elderName, contact.name, contact.relationship, contact.phone, contact.notes].join("\n")), + ...DEFAULT_FAMILY_VISITS.map((visit) => [visit.elderName, visit.contactName, visit.status, visit.notes].join("\n")), + ...DEFAULT_FAMILY_FEEDBACK.map((feedback) => [feedback.elderName, feedback.contactName, feedback.feedbackType, feedback.content, feedback.responseNotes].join("\n")), + ].join("\n"); + + expect(userVisibleContent).not.toMatch(FORBIDDEN_DEMO_WORDING_PATTERN); + }); + + it("inserts only collaboration defaults missing from existing natural-key sets", async () => { + const missingDevice = DEFAULT_DEVICE_ASSETS.find((device) => device.code === "DEV-CALL-A102-2"); + const missingTicket = DEFAULT_MAINTENANCE_TICKETS.find((ticket) => ticket.title === "床头呼叫器按键失灵"); + const missingNotice = DEFAULT_NOTICES.find((notice) => notice.title === "设备巡检培训材料"); + const missingAlertRule = DEFAULT_ALERT_RULES.find((rule) => rule.name === "血氧低值连续告警"); + const missingAlertTrigger = DEFAULT_ALERT_TRIGGERS.find((trigger) => trigger.title === "S101 血氧低值需复核"); + const missingFamilyContact = DEFAULT_FAMILY_CONTACTS.find((contact) => contact.name === "张敏"); + const missingFamilyVisit = DEFAULT_FAMILY_VISITS.find((visit) => visit.notes === "安排护理楼一层会客区。"); + const missingFamilyFeedback = DEFAULT_FAMILY_FEEDBACK.find((feedback) => feedback.content === "希望夜间巡房后能同步重点观察结果。"); + if (!missingDevice || !missingTicket || !missingNotice || !missingAlertRule || !missingAlertTrigger || !missingFamilyContact || !missingFamilyVisit || !missingFamilyFeedback) { + throw new Error("Missing collaboration seed fixture for behavior test"); + } + + const { database, insertCalls } = createCollaborationSeedDatabaseMock({ + missingDeviceCode: missingDevice.code, + missingTicketTitle: missingTicket.title, + missingNoticeTitle: missingNotice.title, + missingAlertRuleName: missingAlertRule.name, + missingAlertTriggerTitle: missingAlertTrigger.title, + missingFamilyContactName: missingFamilyContact.name, + missingFamilyVisitNotes: missingFamilyVisit.notes, + missingFamilyFeedbackContent: missingFamilyFeedback.content, + }); + vi.mocked(getDatabase).mockReturnValue(database); + + await seedDefaultCollaborationWorkspaceData("organization-1"); + + const insertedRows = insertCalls.flatMap((call) => call.values); + expect(insertedRows).toHaveLength(8); + expect(rowsWithKey(insertedRows, "code")).toEqual([expect.objectContaining({ code: missingDevice.code })]); + expect(rowsWithKey(insertedRows, "priority")).toEqual([expect.objectContaining({ title: missingTicket.title })]); + expect(rowsWithKey(insertedRows, "audience")).toEqual([expect.objectContaining({ title: missingNotice.title })]); + expect(rowsWithKey(insertedRows, "conditionSummary")).toEqual([expect.objectContaining({ name: missingAlertRule.name })]); + expect(rowsWithKey(insertedRows, "source")).toEqual([expect.objectContaining({ title: missingAlertTrigger.title })]); + expect(rowsWithKey(insertedRows, "relationship")).toEqual([expect.objectContaining({ name: missingFamilyContact.name })]); + expect(rowsWithKey(insertedRows, "scheduledAt")).toEqual([expect.objectContaining({ notes: missingFamilyVisit.notes })]); + expect(rowsWithKey(insertedRows, "feedbackType")).toEqual([expect.objectContaining({ content: missingFamilyFeedback.content })]); + }); +}); + describe("default AI workspace seed data", () => { it("keeps user-visible AI knowledge content realistic and validator-ready", () => { expect(DEFAULT_AI_KNOWLEDGE_ENTRIES.length).toBeGreaterThanOrEqual(3); diff --git a/modules/core/server/default-workspace-data.ts b/modules/core/server/default-workspace-data.ts index 3fd5833..ca6dc6f 100644 --- a/modules/core/server/default-workspace-data.ts +++ b/modules/core/server/default-workspace-data.ts @@ -979,7 +979,7 @@ const DEFAULT_SYSTEM_INCIDENTS: SeedSystemIncident[] = [ }, ]; -const DEFAULT_DEVICE_ASSETS: SeedDeviceAsset[] = [ +export const DEFAULT_DEVICE_ASSETS: SeedDeviceAsset[] = [ { name: "A102 床头呼叫器", code: "DEV-CALL-A102-2", @@ -1018,7 +1018,7 @@ const DEFAULT_DEVICE_ASSETS: SeedDeviceAsset[] = [ }, ]; -const DEFAULT_MAINTENANCE_TICKETS: SeedMaintenanceTicket[] = [ +export const DEFAULT_MAINTENANCE_TICKETS: SeedMaintenanceTicket[] = [ { deviceCode: "DEV-CALL-A102-2", title: "床头呼叫器按键失灵", @@ -1048,7 +1048,7 @@ const DEFAULT_MAINTENANCE_TICKETS: SeedMaintenanceTicket[] = [ }, ]; -const DEFAULT_NOTICES: SeedNotice[] = [ +export const DEFAULT_NOTICES: SeedNotice[] = [ { title: "本周家属探访安排", content: "周六上午开放护理楼一层和康复楼探访,请各护理组提前确认老人状态和陪同人员。", @@ -1071,7 +1071,7 @@ const DEFAULT_NOTICES: SeedNotice[] = [ }, ]; -const DEFAULT_ALERT_RULES: SeedAlertRule[] = [ +export const DEFAULT_ALERT_RULES: SeedAlertRule[] = [ { name: "血氧低值连续告警", ruleType: "health", @@ -1098,7 +1098,7 @@ const DEFAULT_ALERT_RULES: SeedAlertRule[] = [ }, ]; -const DEFAULT_ALERT_TRIGGERS: SeedAlertTrigger[] = [ +export const DEFAULT_ALERT_TRIGGERS: SeedAlertTrigger[] = [ { ruleName: "血氧低值连续告警", elderName: "钱松柏", @@ -1130,20 +1130,20 @@ const DEFAULT_ALERT_TRIGGERS: SeedAlertTrigger[] = [ }, ]; -const DEFAULT_FAMILY_CONTACTS: SeedFamilyContact[] = [ +export const DEFAULT_FAMILY_CONTACTS: SeedFamilyContact[] = [ { elderName: "王桂兰", name: "张敏", relationship: "女儿", phone: "13800000001", status: "active", notes: "主要联系人,每周六探访。" }, { elderName: "钱松柏", name: "钱宁", relationship: "儿子", phone: "13800000011", status: "active", notes: "关注夜间吸氧情况。" }, { elderName: "林玉琴", name: "林晓", relationship: "女儿", phone: "13800000014", status: "active", notes: "短住托养资料已确认。" }, { elderName: "赵国华", name: "赵蕾", relationship: "孙女", phone: "13800000016", status: "active", notes: "希望接收护理反馈。" }, ]; -const DEFAULT_FAMILY_VISITS: SeedFamilyVisit[] = [ +export const DEFAULT_FAMILY_VISITS: SeedFamilyVisit[] = [ { elderName: "王桂兰", contactName: "张敏", scheduledHoursOffset: 30, status: "approved", notes: "安排护理楼一层会客区。" }, { elderName: "钱松柏", contactName: "钱宁", scheduledHoursOffset: 8, status: "requested", notes: "需医生确认夜间血氧情况后再回复。" }, { elderName: "林玉琴", contactName: "林晓", scheduledHoursOffset: -20, status: "completed", notes: "已完成探访和短住事项确认。" }, ]; -const DEFAULT_FAMILY_FEEDBACK: SeedFamilyFeedback[] = [ +export const DEFAULT_FAMILY_FEEDBACK: SeedFamilyFeedback[] = [ { elderName: "赵国华", contactName: "赵蕾", @@ -1510,6 +1510,271 @@ async function seedDefaultAiWorkspaceData(organizationId: string): Promise }); } +function getFamilyContactKey(elderId: string, contactName: string): string { + return `${elderId}:${contactName}`; +} + +function getFamilyVisitKey(elderId: string, contactId: string, notes: string): string { + return `${elderId}:${contactId}:${notes}`; +} + +function getFamilyFeedbackKey(elderId: string, contactId: string, content: string): string { + return `${elderId}:${contactId}:${content}`; +} + +export async function seedDefaultCollaborationWorkspaceData(organizationId: string): Promise { + const database = getDatabase(); + const [ + elderRows, + existingDeviceRows, + existingTicketRows, + existingNoticeRows, + existingAlertRuleRows, + existingAlertTriggerRows, + existingFamilyContactRows, + existingFamilyVisitRows, + existingFamilyFeedbackRows, + ] = await Promise.all([ + database.select({ id: elders.id, name: elders.name }).from(elders).where(eq(elders.organizationId, organizationId)), + database.select().from(deviceAssets).where(eq(deviceAssets.organizationId, organizationId)), + database.select().from(maintenanceTickets).where(eq(maintenanceTickets.organizationId, organizationId)), + database.select().from(notices).where(eq(notices.organizationId, organizationId)), + database.select().from(alertRules).where(eq(alertRules.organizationId, organizationId)), + database.select().from(alertTriggers).where(eq(alertTriggers.organizationId, organizationId)), + database.select().from(familyContacts).where(eq(familyContacts.organizationId, organizationId)), + database.select().from(familyVisitAppointments).where(eq(familyVisitAppointments.organizationId, organizationId)), + database.select().from(familyFeedback).where(eq(familyFeedback.organizationId, organizationId)), + ]); + + const elderIdByName = new Map(elderRows.map((elder) => [elder.name, elder.id])); + const existingDeviceCodes = new Set(existingDeviceRows.map((device) => device.code)); + const existingTicketTitles = new Set(existingTicketRows.map((ticket) => ticket.title)); + const existingNoticeTitles = new Set(existingNoticeRows.map((notice) => notice.title)); + const existingAlertRuleNames = new Set(existingAlertRuleRows.map((rule) => rule.name)); + const existingAlertTriggerTitles = new Set(existingAlertTriggerRows.map((trigger) => trigger.title)); + const existingFamilyContactKeys = new Set(existingFamilyContactRows.map((contact) => getFamilyContactKey(contact.elderId, contact.name))); + const existingFamilyVisitKeys = new Set( + existingFamilyVisitRows + .map((visit) => (visit.contactId ? getFamilyVisitKey(visit.elderId, visit.contactId, visit.notes) : "")) + .filter(Boolean), + ); + const existingFamilyFeedbackKeys = new Set( + existingFamilyFeedbackRows + .map((feedback) => (feedback.contactId ? getFamilyFeedbackKey(feedback.elderId, feedback.contactId, feedback.content) : "")) + .filter(Boolean), + ); + + const getContactId = (contactRows: typeof existingFamilyContactRows, elderId: string, contactName: string): string | undefined => + contactRows.find((contact) => contact.elderId === elderId && contact.name === contactName)?.id; + const getMissingFamilyVisits = (contactRows: typeof existingFamilyContactRows): SeedFamilyVisit[] => + DEFAULT_FAMILY_VISITS.filter((visit) => { + const elderId = elderIdByName.get(visit.elderName); + const contactId = elderId ? getContactId(contactRows, elderId, visit.contactName) : undefined; + return Boolean(elderId && contactId && !existingFamilyVisitKeys.has(getFamilyVisitKey(elderId, contactId, visit.notes))); + }); + const getMissingFamilyFeedback = (contactRows: typeof existingFamilyContactRows): SeedFamilyFeedback[] => + DEFAULT_FAMILY_FEEDBACK.filter((feedback) => { + const elderId = elderIdByName.get(feedback.elderName); + const contactId = elderId ? getContactId(contactRows, elderId, feedback.contactName) : undefined; + return Boolean(elderId && contactId && !existingFamilyFeedbackKeys.has(getFamilyFeedbackKey(elderId, contactId, feedback.content))); + }); + + const missingDevices = DEFAULT_DEVICE_ASSETS.filter((device) => !existingDeviceCodes.has(device.code)); + const missingTickets = DEFAULT_MAINTENANCE_TICKETS.filter((ticket) => !existingTicketTitles.has(ticket.title)); + const missingNotices = DEFAULT_NOTICES.filter((notice) => !existingNoticeTitles.has(notice.title)); + const missingAlertRules = DEFAULT_ALERT_RULES.filter((rule) => !existingAlertRuleNames.has(rule.name)); + const missingAlertTriggers = DEFAULT_ALERT_TRIGGERS.filter((trigger) => !existingAlertTriggerTitles.has(trigger.title)); + const missingFamilyContacts = DEFAULT_FAMILY_CONTACTS.filter((contact) => { + const elderId = elderIdByName.get(contact.elderName); + return Boolean(elderId && !existingFamilyContactKeys.has(getFamilyContactKey(elderId, contact.name))); + }); + const missingFamilyVisits = getMissingFamilyVisits(existingFamilyContactRows); + const missingFamilyFeedback = getMissingFamilyFeedback(existingFamilyContactRows); + + if ( + missingDevices.length === 0 && + missingTickets.length === 0 && + missingNotices.length === 0 && + missingAlertRules.length === 0 && + missingAlertTriggers.length === 0 && + missingFamilyContacts.length === 0 && + missingFamilyVisits.length === 0 && + missingFamilyFeedback.length === 0 + ) { + return; + } + + const now = new Date(); + await database.transaction(async (transaction) => { + const insertedDeviceRows = missingDevices.length > 0 + ? await transaction + .insert(deviceAssets) + .values( + missingDevices.map((device) => ({ + organizationId, + name: device.name, + code: device.code, + category: device.category, + location: device.location, + status: device.status, + lastInspectedAt: device.lastInspectedHoursAgo === undefined ? undefined : new Date(now.getTime() - device.lastInspectedHoursAgo * 60 * 60 * 1000), + notes: device.notes, + })), + ) + .returning() + : []; + if (insertedDeviceRows.length !== missingDevices.length) { + throw new Error("默认设备台账初始化失败"); + } + const deviceIdByCode = new Map([...existingDeviceRows, ...insertedDeviceRows].map((device) => [device.code, device.id])); + + if (missingTickets.length > 0) { + const ticketValues = missingTickets.map((ticket) => { + const deviceId = deviceIdByCode.get(ticket.deviceCode); + if (!deviceId) { + throw new Error("默认维修工单初始化失败"); + } + + return { + organizationId, + deviceId, + title: ticket.title, + description: ticket.description, + priority: ticket.priority, + status: ticket.status, + assigneeLabel: ticket.assigneeLabel, + resolutionNotes: ticket.resolutionNotes, + resolvedAt: ticket.status === "resolved" || ticket.status === "closed" ? now : undefined, + }; + }); + await transaction.insert(maintenanceTickets).values(ticketValues); + } + + if (missingNotices.length > 0) { + await transaction.insert(notices).values( + missingNotices.map((notice) => ({ + organizationId, + title: notice.title, + content: notice.content, + audience: notice.audience, + status: notice.status, + publishedAt: notice.publishedHoursAgo === undefined ? undefined : new Date(now.getTime() - notice.publishedHoursAgo * 60 * 60 * 1000), + })), + ); + } + + const insertedAlertRuleRows = missingAlertRules.length > 0 + ? await transaction.insert(alertRules).values(missingAlertRules.map((rule) => ({ ...rule, organizationId }))).returning() + : []; + if (insertedAlertRuleRows.length !== missingAlertRules.length) { + throw new Error("默认预警规则初始化失败"); + } + const alertRuleIdByName = new Map([...existingAlertRuleRows, ...insertedAlertRuleRows].map((rule) => [rule.name, rule.id])); + + const alertTriggerValues = missingAlertTriggers + .map((trigger) => { + const ruleId = alertRuleIdByName.get(trigger.ruleName); + const elderId = trigger.elderName ? elderIdByName.get(trigger.elderName) : undefined; + if (!ruleId || (trigger.elderName && !elderId)) { + return null; + } + + return { + organizationId, + ruleId, + elderId, + title: trigger.title, + description: trigger.description, + status: trigger.status, + source: trigger.source, + handlingNotes: trigger.handlingNotes, + handledAt: trigger.status === "open" ? undefined : now, + createdAt: new Date(now.getTime() - trigger.createdHoursAgo * 60 * 60 * 1000), + updatedAt: now, + }; + }) + .filter((value): value is Exclude => value !== null); + if (alertTriggerValues.length > 0) { + await transaction.insert(alertTriggers).values(alertTriggerValues); + } + + const insertedFamilyContactRows = missingFamilyContacts.length > 0 + ? await transaction + .insert(familyContacts) + .values( + missingFamilyContacts.map((contact) => { + const elderId = elderIdByName.get(contact.elderName); + if (!elderId) { + throw new Error("默认家属联系人初始化失败"); + } + + return { + organizationId, + elderId, + name: contact.name, + relationship: contact.relationship, + phone: contact.phone, + status: contact.status, + notes: contact.notes, + }; + }), + ) + .returning() + : []; + if (insertedFamilyContactRows.length !== missingFamilyContacts.length) { + throw new Error("默认家属联系人初始化失败"); + } + const allFamilyContactRows = [...existingFamilyContactRows, ...insertedFamilyContactRows]; + + const familyVisitValues = getMissingFamilyVisits(allFamilyContactRows) + .map((visit) => { + const elderId = elderIdByName.get(visit.elderName); + const contactId = elderId ? getContactId(allFamilyContactRows, elderId, visit.contactName) : undefined; + if (!elderId || !contactId) { + return null; + } + + return { + organizationId, + elderId, + contactId, + scheduledAt: new Date(now.getTime() + visit.scheduledHoursOffset * 60 * 60 * 1000), + status: visit.status, + notes: visit.notes, + handledAt: visit.status === "requested" ? undefined : now, + }; + }) + .filter((value): value is Exclude => value !== null); + if (familyVisitValues.length > 0) { + await transaction.insert(familyVisitAppointments).values(familyVisitValues); + } + + const familyFeedbackValues = getMissingFamilyFeedback(allFamilyContactRows) + .map((feedback) => { + const elderId = elderIdByName.get(feedback.elderName); + const contactId = elderId ? getContactId(allFamilyContactRows, elderId, feedback.contactName) : undefined; + if (!elderId || !contactId) { + return null; + } + + return { + organizationId, + elderId, + contactId, + feedbackType: feedback.feedbackType, + content: feedback.content, + status: feedback.status, + responseNotes: feedback.responseNotes, + handledAt: feedback.status === "open" ? undefined : now, + }; + }) + .filter((value): value is Exclude => value !== null); + if (familyFeedbackValues.length > 0) { + await transaction.insert(familyFeedback).values(familyFeedbackValues); + } + }); +} + function hasRows(rows: unknown[]): boolean { return rows.length > 0; } @@ -1524,6 +1789,7 @@ export async function seedDefaultWorkspaceData(organizationId: string): Promise< ]); if ([existingRooms, existingBeds, existingElders, existingAdmissions].some(hasRows)) { + await seedDefaultCollaborationWorkspaceData(organizationId); await seedDefaultAiWorkspaceData(organizationId); return; } @@ -1983,5 +2249,6 @@ export async function seedDefaultWorkspaceData(organizationId: string): Promise< }), ); }); + await seedDefaultCollaborationWorkspaceData(organizationId); await seedDefaultAiWorkspaceData(organizationId); } diff --git a/modules/dashboard/components/DashboardHome.tsx b/modules/dashboard/components/DashboardHome.tsx index 83916be..da7da46 100644 --- a/modules/dashboard/components/DashboardHome.tsx +++ b/modules/dashboard/components/DashboardHome.tsx @@ -1,23 +1,31 @@ import Link from "next/link"; -import { Activity, AlertTriangle, BedDouble, CheckCircle2, HeartPulse, ListChecks, Users } from "lucide-react"; +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" | "beds" | "care" | "health" | "incidents" | "users"; + icon: "admissions" | "ai" | "alerts" | "beds" | "care" | "devices" | "family" | "health" | "incidents" | "notices" | "users"; }; export type DashboardAdmission = { @@ -56,26 +64,82 @@ export type DashboardHealthReview = { 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[]; + careTasks?: DashboardCareTask[]; + deviceTickets?: DashboardDeviceTicket[]; + devicesHref: string; + eldersHref: string; emergencyHref: string; - emergencyIncidents: DashboardIncident[]; + emergencyIncidents?: DashboardIncident[]; + familyFeedback?: DashboardFamilyFeedback[]; + familyHref: string; + familyVisits?: DashboardFamilyVisit[]; healthHref: string; - healthReviews: DashboardHealthReview[]; + healthReviews?: DashboardHealthReview[]; metrics: DashboardMetric[]; - occupancyData: BedOccupancyDatum[]; - recentAdmissions: DashboardAdmission[]; - incidents: DashboardIncident[]; + notices?: DashboardNotice[]; + noticesHref: string; + occupancyData?: BedOccupancyDatum[]; + recentAdmissions?: DashboardAdmission[]; + incidents?: DashboardIncident[]; }; const metricIcons: Record = { admissions: CheckCircle2, + ai: BrainCircuit, + alerts: ShieldAlert, beds: BedDouble, care: ListChecks, + devices: TabletSmartphone, + family: Users, health: HeartPulse, incidents: HeartPulse, + notices: Megaphone, users: Users, }; @@ -107,19 +171,72 @@ function incidentVariant(severity: IncidentSeverity): "secondary" | "warning" | } } +const analysisRiskLabels: Record = { + 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 (
@@ -148,132 +265,247 @@ 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="安全应急" - /> -
+ {hasCoreQueues ? ( +
+ {careTasks !== undefined ? ( + ({ + 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 ? ( + ({ + 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 ? ( + ({ + 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} +
+ ) : null} -
- - - - - - {occupancyData.map((item) => `${bedStatusLabels[item.label as BedStatus] ?? item.label} ${item.count}`).join(" / ")} - - - - - - - - + {hasCollaborationQueues ? ( +
+ {deviceTickets !== undefined ? ( + ({ + 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 ? ( + ({ + 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 ? ( + ({ + 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 ? ( + + ) : null} +
+ ) : null} - - - 最近入住记录 - - -
- - - - 老人 - 床位 - 状态 - 时间 - - - - {recentAdmissions.map((admission) => ( - - {admission.elderName} - {admission.bedLabel} - - - {admissionStatusLabels[admission.status]} - - - - {formatDateTime(admission.dischargedAt ?? admission.admittedAt)} - - - ))} - {recentAdmissions.length === 0 ? ( - - - 暂无入住记录 - - - ) : null} - -
-
-
-
-
+ {occupancyData !== undefined || recentAdmissions !== undefined ? ( +
+ {occupancyData !== undefined ? ( + + + + + + {occupancyData.map((item) => `${bedStatusLabels[item.label as BedStatus] ?? item.label} ${item.count}`).join(" / ")} + + + + + + + + + ) : null} - - - 未关闭故障 - - - {incidents.map((incident) => ( -
-
-
-
- {incident.status} + + + ) : null} +
+ ) : null} + + {aiBoardItems !== undefined ? ( + + +
+ + + 来自已保存老人 AI 分析历史,按数据权限自动脱敏。
- ))} - {incidents.length === 0 ?

暂无未关闭故障

: null} - -
+ + 老人档案 + + + + {aiBoardItems.map((item) => ( + +
+
+

{item.elderName}

+

+ {item.restricted ? "结果受限:缺少一个或多个数据范围权限" : item.result?.summary ?? item.errorReason ?? "暂无摘要"} +

+

+ {item.dataScopes.join(" / ")} / {formatDateTime(item.createdAt)} +

+
+
+ {item.status === "failed" ? "失败" : "已完成"} + {!item.restricted && item.result ? ( + {analysisRiskLabels[item.result.overallRiskLevel]} + ) : null} +
+
+ + ))} + {aiBoardItems.length === 0 ?

暂无智能分析记录

: null} +
+ + ) : null} + + {incidents !== undefined ? ( + + + 未关闭故障 + + + {incidents.map((incident) => ( +
+
+
+
+

+ {incident.source} / {formatDateTime(incident.createdAt)} +

+
+ {incident.status} +
+ ))} + {incidents.length === 0 ?

暂无未关闭故障

: null} +
+
+ ) : null}
); } @@ -284,7 +516,7 @@ type QueueItem = { detail: string; meta: string; title: string; - variant: "danger" | "secondary" | "warning"; + variant: "danger" | "secondary" | "success" | "warning"; }; function QueueCard({ diff --git a/modules/shared/lib/navigation.ts b/modules/shared/lib/navigation.ts index 7b5c0d7..32f95f5 100644 --- a/modules/shared/lib/navigation.ts +++ b/modules/shared/lib/navigation.ts @@ -39,7 +39,7 @@ export const navGroups: NavGroup[] = [ path: "/dashboard", label: "运营看板", icon: "dashboard", - anyPermissions: ["elder:read", "facility:read", "admission:read", "incident:read"], + anyPermissions: ["elder:read", "facility:read", "admission:read", "incident:read", "care:read", "health:read", "device:read", "notice:read", "alert:read", "family:read", "ai:read"], }, { path: "/elders",