diff --git a/.trellis/spec/backend/drizzle-postgres-current.md b/.trellis/spec/backend/drizzle-postgres-current.md index 972d806..424797a 100644 --- a/.trellis/spec/backend/drizzle-postgres-current.md +++ b/.trellis/spec/backend/drizzle-postgres-current.md @@ -377,7 +377,7 @@ await database - Good: use one additive `care_tasks` table for MVP execution records instead of building a full recurring care-plan engine. - Good: preserve `/app/health` separation; care execution is operational and not the health admin settings page. - Base: `elderId` can be nullable for future public-area checks, but seeded MVP examples should use real elders. -- Bad: render fake care metrics in `ModulePage` after the module becomes real. +- Bad: render fake care metrics in a static placeholder after the module becomes real. - Bad: update task status by ID alone without `organizationId`. - Bad: only disable buttons in the UI while leaving Route Handlers on broad elder permissions. diff --git a/.trellis/spec/frontend/components.md b/.trellis/spec/frontend/components.md index 45bc9f0..28d9bc3 100644 --- a/.trellis/spec/frontend/components.md +++ b/.trellis/spec/frontend/components.md @@ -165,44 +165,17 @@ The project Dialog adapter must: ### Static Module Data Contract -Static or reserved module pages must not fabricate operational data. If a module does -not have a Drizzle-backed query/API and real persisted records, render a clear empty or -not-connected state instead of generated counters, fake workflows, sample records, or -mock priority/status rows. +Operational module pages must not fabricate data. If a module does not have a +Drizzle-backed query/API and real persisted records, render a clear empty or +not-connected state inside that module's own route/component instead of generated +counters, fake workflows, sample records, or mock priority/status rows. -`modules/shared/components/ModulePage.tsx` is the shared placeholder for these modules. -Its props should stay descriptive only: +The old shared reserved-module placeholder components were removed after devices, +notices, alerts, family, health, care, and emergency became real persisted workspaces. +Do not reintroduce a generic `ReservedModulePages` layer for operational modules. -```typescript -type ModulePageProps = { - title: string; - eyebrow: string; - description: string; - icon: LucideIcon; - phase: "待接入" | "二期预留" | "三期预留"; -}; -``` - -```typescript -// Bad: static pages pretending to have live business data. - - -// Good: no fabricated records until a real data source exists. - -``` - -When a module becomes real, replace the placeholder with a Server Component that loads -data from the server boundary and pass only persisted, permission-filtered data into +When a module becomes real, implement the route as a Server Component that loads data +from the server boundary and passes only persisted, permission-filtered data into client components. ### App Shell Tenant and Account Menu Contract diff --git a/.trellis/tasks/00-bootstrap-guidelines/prd.md b/.trellis/tasks/archive/2026-07/00-bootstrap-guidelines/prd.md similarity index 98% rename from .trellis/tasks/00-bootstrap-guidelines/prd.md rename to .trellis/tasks/archive/2026-07/00-bootstrap-guidelines/prd.md index dbad710..1c94f8d 100644 --- a/.trellis/tasks/00-bootstrap-guidelines/prd.md +++ b/.trellis/tasks/archive/2026-07/00-bootstrap-guidelines/prd.md @@ -21,9 +21,9 @@ the rest conversationally. ## Status (update the checkboxes as you complete each item) -- [ ] Fill backend guidelines -- [ ] Fill frontend guidelines -- [ ] Add code examples +- [x] Fill backend guidelines +- [x] Fill frontend guidelines +- [x] Add code examples --- diff --git a/.trellis/tasks/00-bootstrap-guidelines/task.json b/.trellis/tasks/archive/2026-07/00-bootstrap-guidelines/task.json similarity index 92% rename from .trellis/tasks/00-bootstrap-guidelines/task.json rename to .trellis/tasks/archive/2026-07/00-bootstrap-guidelines/task.json index 1f79863..14b4b3a 100644 --- a/.trellis/tasks/00-bootstrap-guidelines/task.json +++ b/.trellis/tasks/archive/2026-07/00-bootstrap-guidelines/task.json @@ -3,7 +3,7 @@ "name": "00-bootstrap-guidelines", "title": "Bootstrap Guidelines", "description": "Fill in project development guidelines for AI agents", - "status": "in_progress", + "status": "completed", "dev_type": "docs", "scope": null, "package": null, @@ -11,7 +11,7 @@ "creator": "TalexDreamSoul", "assignee": "TalexDreamSoul", "createdAt": "2026-07-01", - "completedAt": null, + "completedAt": "2026-07-03", "branch": null, "base_branch": null, "worktree_path": null, diff --git a/app/(app)/app/settings/health/page.tsx b/app/(app)/app/settings/health/page.tsx index ee05389..d05c0f9 100644 --- a/app/(app)/app/settings/health/page.tsx +++ b/app/(app)/app/settings/health/page.tsx @@ -1,5 +1,5 @@ -import { renderHealthWorkspacePage } from "@/modules/health/components/HealthWorkspacePage"; +import { renderHealthSettingsPage } from "@/modules/health/components/HealthWorkspacePage"; export default async function HealthSettingsPage(): Promise { - return renderHealthWorkspacePage(); + return renderHealthSettingsPage(); } diff --git a/modules/health/components/HealthWorkspacePage.tsx b/modules/health/components/HealthWorkspacePage.tsx index 95b5da2..16ff9ea 100644 --- a/modules/health/components/HealthWorkspacePage.tsx +++ b/modules/health/components/HealthWorkspacePage.tsx @@ -1,13 +1,52 @@ import { redirect } from "next/navigation"; -import { HeartPulse } from "lucide-react"; +import { Activity, FileText, HeartPulse, ShieldAlert, Stethoscope } 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 { getCurrentAuthContext } from "@/modules/core/server/auth"; import { hasPermission } from "@/modules/core/server/permissions"; import { HealthAdminClient } from "@/modules/health/components/HealthAdminClient"; import { listHealthAdminData } from "@/modules/health/server/operations"; +import type { HealthReviewSeverity, HealthReviewStatus } from "@/modules/health/types"; +import { + CHRONIC_CONDITION_STATUS_LABELS, + HEALTH_REVIEW_SEVERITY_LABELS, + HEALTH_REVIEW_STATUS_LABELS, + VITAL_SOURCE_LABELS, +} from "@/modules/health/types"; import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing"; -export async function renderHealthWorkspacePage(): Promise { +type HealthPageData = { + canManage: boolean; + data: Awaited>; +}; + +function formatDateTime(value: string | undefined): string { + return value ? new Date(value).toLocaleString("zh-CN") : "-"; +} + +function formatTenths(value: number | undefined, suffix = ""): string { + return value === undefined ? "-" : `${(value / 10).toFixed(1)}${suffix}`; +} + +function severityBadgeVariant(severity: HealthReviewSeverity): "secondary" | "warning" | "danger" { + if (severity === "critical") { + return "danger"; + } + + return severity === "warning" ? "warning" : "secondary"; +} + +function reviewStatusBadgeVariant(status: HealthReviewStatus): "success" | "secondary" | "warning" { + if (status === "pending") { + return "warning"; + } + + return status === "resolved" ? "success" : "secondary"; +} + +async function loadHealthPageData(): Promise { const context = await getCurrentAuthContext(); if (!context) { redirect("/login"); @@ -23,6 +62,17 @@ export async function renderHealthWorkspacePage(): Promise { } const data = await listHealthAdminData(organizationId); + return { + canManage: hasPermission(context.permissions, "health:manage"), + data, + }; +} + +export async function renderHealthWorkspacePage(): Promise { + const { data } = await loadHealthPageData(); + const pendingReviews = data.reviews.filter((review) => review.status === "pending").slice(0, 8); + const recentVitals = data.vitals.slice(0, 8); + const activeConditions = data.chronicConditions.filter((condition) => condition.status === "active").slice(0, 8); return (
@@ -33,12 +83,172 @@ export async function renderHealthWorkspacePage(): Promise {

健康照护

-

健康档案、生命体征、慢病管理与异常复核

+

健康概览、重点慢病、生命体征与异常复核队列

- +
+
+ +
+ + + 待复核健康异常 + 按最新异常记录展示护理复核队列。 + + + + + + 老人 + 异常 + 级别 + 状态 + 创建时间 + + + + {pendingReviews.map((review) => ( + + {review.elderName} + +

{review.title}

+

{review.description}

+
+ + + {HEALTH_REVIEW_SEVERITY_LABELS[review.severity]} + + + + + {HEALTH_REVIEW_STATUS_LABELS[review.status]} + + + {formatDateTime(review.createdAt)} +
+ ))} + {pendingReviews.length === 0 ? ( + + + 暂无待复核健康异常 + + + ) : null} +
+
+
+
+ + + + 慢病重点关注 + 持续管理中的慢病记录。 + + + {activeConditions.map((condition) => ( +
+
+
+

{condition.elderName}

+

{condition.name}

+
+ {CHRONIC_CONDITION_STATUS_LABELS[condition.status]} +
+

{condition.followUpNotes || condition.treatmentNotes || "暂无随访备注"}

+
+ ))} + {activeConditions.length === 0 ?

暂无持续管理慢病记录

: null} +
+
+
+ + + + 最近生命体征 + 展示最新录入或采集的生命体征记录。 + + + + + + 老人 + 时间 + 来源 + 血压 + 心率 + 体温 + 血氧 + 备注 + + + + {recentVitals.map((vital) => ( + + {vital.elderName} + {formatDateTime(vital.recordedAt)} + {VITAL_SOURCE_LABELS[vital.source]} + + {vital.systolicBp && vital.diastolicBp ? `${vital.systolicBp}/${vital.diastolicBp}` : "-"} + + {vital.heartRate?.toString() ?? "-"} + {formatTenths(vital.temperatureTenths, "°C")} + {vital.spo2 === undefined ? "-" : `${vital.spo2}%`} + {vital.notes || "-"} + + ))} + {recentVitals.length === 0 ? ( + + + 暂无生命体征记录 + + + ) : null} + +
+
+
); } + +export async function renderHealthSettingsPage(): Promise { + const { canManage, data } = await loadHealthPageData(); + + return ( +
+
+
+ + +
+

健康数据管理

+

维护健康档案、生命体征、慢病记录与异常复核

+
+
+
+ + +
+ ); +} + +function MetricCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: number }): React.ReactElement { + return ( + + + {label} + {icon} + + + {value} + + + ); +} diff --git a/modules/shared/components/AppSidebarNav.tsx b/modules/shared/components/AppSidebarNav.tsx index cfc8ef8..674ed35 100644 --- a/modules/shared/components/AppSidebarNav.tsx +++ b/modules/shared/components/AppSidebarNav.tsx @@ -11,6 +11,7 @@ import { HeartPulse, LayoutDashboard, ListChecks, + LockKeyhole, Radio, Settings, ShieldAlert, @@ -49,7 +50,15 @@ type AppSidebarNavProps = { permissions: Permission[]; }; +type SidebarNavItem = NavItem & { + canAccess: boolean; +}; + function canViewNavItem(item: NavItem, permissions: readonly Permission[]): boolean { + if (permissions.includes("platform:manage")) { + return true; + } + if (item.permission && !permissions.includes(item.permission)) { return false; } @@ -63,12 +72,13 @@ function canViewNavItem(item: NavItem, permissions: readonly Permission[]): bool export function AppSidebarNav({ organizationSlug, permissions }: AppSidebarNavProps): React.ReactElement { const pathname = usePathname(); - const visibleNavGroups = navGroups - .map((group) => ({ - ...group, - items: group.items.filter((item) => canViewNavItem(item, permissions)), - })) - .filter((group) => group.items.length > 0); + const visibleNavGroups = navGroups.map((group): { label: string; items: SidebarNavItem[] } => ({ + ...group, + items: group.items.map((item) => ({ + ...item, + canAccess: canViewNavItem(item, permissions), + })), + })); return (