feat: add health data management workspace

This commit is contained in:
2026-07-03 00:25:11 -07:00
parent dc034b6d85
commit 41807ff557
53 changed files with 7367 additions and 3 deletions

View File

@@ -180,3 +180,93 @@ if (!floor) {
} }
await database.insert(rooms).values({ organizationId, floorId: floor.id, name, code }); await database.insert(rooms).values({ organizationId, floorId: floor.id, name, code });
``` ```
## Scenario: Health Admin Data Management
### 1. Scope / Trigger
- Trigger: adding or changing health profile, vital record, chronic condition, or health anomaly review behavior.
- Applies because health data is persisted in Drizzle tables and rendered through a settings management page, not fabricated in the operational `/app/health` placeholder.
### 2. Signatures
- `GET /api/health/admin`
- `PUT /api/health/profiles/[elderId]`
- `POST /api/health/vitals`
- `POST /api/health/chronic-conditions`
- `PATCH /api/health/reviews/[id]`
- `listHealthAdminData(organizationId: string): Promise<HealthAdminData>`
- Mutations return either the domain DTO or `{ success: false; reason: string; status: number }`.
### 3. Contracts
- Health APIs must call `requirePermission` before reading or mutating:
- reads use `health:read`
- mutations use `health:manage`
- All health rows are scoped by `organizationId`.
- Elder-owned health mutations must validate the elder exists in the active organization.
- `GET /api/health/admin` returns `{ success: true; reason: string; data: HealthAdminData }`; client refresh code must read `result.data`.
- Mutation success responses follow the standard shape:
- profile: `{ success: true; reason: string; profile }`
- vital: `{ success: true; reason: string; vital; review? }`
- chronic condition: `{ success: true; reason: string; condition }`
- review: `{ success: true; reason: string; review }`
- Mutations must write audit logs after successful persistence.
### 4. Validation & Error Matrix
- Missing active organization -> `400` with a Chinese reason instructing the user to select an organization.
- Missing `elderId` for vital/condition -> `400`.
- Invalid date/source/status/numeric field -> `400`.
- Elder not found or belongs to another organization -> `404` / `老人档案不存在`.
- Review not found or belongs to another organization -> `404` / `异常复核记录不存在`.
- Missing permission -> `403` from `requirePermission`; route must not call domain helpers.
- Insert/update returning no row -> `500` mutation failure.
### 5. Good/Base/Bad Cases
- Good: keep `/app/settings/health` as the backend management surface and leave `/app/health` operational until real operational data exists.
- Good: return the admin payload under a `data` key so route output and client refresh types match.
- Good: seed demo health rows only inside the default workspace seeding transaction and tie them to real seeded elders.
- Base: anomaly review rows may be created automatically from MVP vital thresholds.
- Bad: render UI-only health records or counters on static module pages.
- Bad: hide mutation buttons in the UI without enforcing `health:manage` in the Route Handler.
- Bad: update reviews by `id` alone without also filtering by active `organizationId`.
### 6. Tests Required
- `pnpm test` with API assertions for each health route.
- Route tests must cover at least: happy path, permission denial, missing active organization, invalid input, and missing/cross-organization IDs.
- `pnpm db:generate` after schema changes, then review the generated SQL for only additive health enum/table/index/FK changes.
- `pnpm lint`, `pnpm type-check`, and `pnpm build`.
### 7. Wrong vs Correct
#### Wrong
```ts
const data = await listHealthAdminData(organizationId);
return jsonSuccess("健康数据已加载", data);
```
#### Correct
```ts
const data = await listHealthAdminData(organizationId);
return jsonSuccess("健康数据已加载", { data });
```
#### Wrong
```ts
await database.update(healthAnomalyReviews).set({ status }).where(eq(healthAnomalyReviews.id, id));
```
#### Correct
```ts
await database
.update(healthAnomalyReviews)
.set({ status, updatedAt: new Date() })
.where(and(eq(healthAnomalyReviews.id, id), eq(healthAnomalyReviews.organizationId, organizationId)));
```

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,76 @@
# 护理服务执行工作台
## Goal
Replace the current `护理服务` placeholder with a real daily care execution workspace. The MVP unit is a persisted care task / service execution record, not a care-plan generation engine.
## Confirmed Facts
- `/app/care` currently renders `ModulePage` only.
- `/app/{organizationSlug}/care` delegates to the unscoped care page.
- Existing elder, bed, and admission data are persisted in Drizzle.
- Navigation already exposes `护理服务` under the operation group.
- Historical product direction includes care tasks, dispatch,巡检打卡, service records, and service evaluation.
## Requirements
- Add Drizzle-backed care execution records scoped by organization.
- Care records should link to a real elder where applicable.
- Care records should support at least:
- title/name
- care type
- priority
- status
- scheduled time or due time
- assignee label or executor label
- execution notes
- completed time
- Replace `/app/care` and `/app/{organizationSlug}/care` with a real workspace.
- The workspace should show summary metrics, filters, and a dense care task table/list.
- Operators must be able to filter by status, priority, care type, and elder/search terms.
- Authorized operators can mark care items as in progress and completed.
- Completion must persist notes and completion timestamp.
- Mutations must record audit logs.
- Default workspace seed data must include representative care examples covering multiple statuses and care types.
## Permissions
- Prefer explicit care permissions if adding new permissions is practical:
- `care:read`
- `care:manage`
- If scope needs to stay smaller, reuse existing `elder:update` temporarily only if documented in the design.
- Server-side permissions are required for all mutation APIs.
## Acceptance Criteria
- [ ] `/app/care` no longer renders the generic placeholder.
- [ ] `/app/{organizationSlug}/care` renders the same real care workspace.
- [ ] Care records are loaded from Drizzle and scoped to the active organization.
- [ ] Default seeded workspace includes care tasks tied to seeded elders.
- [ ] The UI shows pending, in-progress, completed, and overdue/high-priority examples.
- [ ] Authorized users can move a care item to in-progress and completed from the UI.
- [ ] Completed care items persist execution notes and completion time.
- [ ] Unauthorized mutation attempts return structured failures.
- [ ] Care mutations write audit log entries.
- [ ] `pnpm db:generate` is run if schema changes are made.
- [ ] `pnpm lint` passes.
- [ ] `pnpm type-check` passes.
- [ ] `pnpm build` passes.
## Dependencies
- Can start after or parallel with health management because it primarily depends on existing elder/admission data.
- Dashboard integration should wait until this task is complete.
## Out Of Scope
- Care plan template management.
- Automatic recurring task generation.
- Service billing or pricing.
- Family notifications.
- Mobile QR/NFC check-in.
- AI care recommendations.
## Open Questions
- None currently blocking planning.

View File

@@ -0,0 +1,26 @@
{
"id": "care-execution-workspace",
"name": "care-execution-workspace",
"title": "护理服务执行工作台",
"description": "",
"status": "planning",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "TalexDreamSoul",
"assignee": "TalexDreamSoul",
"createdAt": "2026-07-02",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": "07-02-operations-module-roadmap",
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,191 @@
# Health Care Admin Page Design
## Summary
Add a dedicated management route under `/settings/health` for persisted elder health data. This keeps operational navigation (`/health`) separate from backend data administration, matching the user's direction to split the management surface into its own page.
## Route Boundary
- Unscoped route: `app/(app)/app/settings/health/page.tsx`
- Scoped route: `app/(app)/app/[organizationSlug]/settings/health/page.tsx`
- Optional redirect source: none in MVP. `/app/health` stays independent.
- Navigation: add a management item under `管理系统`.
- Breadcrumbs: add `"/settings/health": "健康数据管理"`.
The page should follow existing settings pages:
1. Server Component loads `getCurrentAuthContext()`.
2. Redirect unauthenticated users to `/login`.
3. Redirect users without `health:read` back to a safe workspace route.
4. Load persisted initial data through server helpers.
5. Render a focused Client Component for filtering and mutation UI.
## Data Model
All tables use `organizationId` for tenant scope and `createdAt` / `updatedAt` timestamps.
### `health_profiles`
- `id`
- `organizationId`
- `elderId`
- `allergyNotes`
- `medicalHistory`
- `medicationNotes`
- `careRestrictions`
- `emergencyNotes`
- `createdAt`
- `updatedAt`
Constraint: unique `(organizationId, elderId)` so each elder has one admin health profile.
### `vital_records`
- `id`
- `organizationId`
- `elderId`
- `recordedAt`
- `source`: `manual | device | import`
- `systolicBp`
- `diastolicBp`
- `heartRate`
- `temperatureTenths`
- `spo2`
- `bloodGlucoseTenths`
- `weightTenths`
- `notes`
- `createdByAccountId`
- `createdAt`
- `updatedAt`
Integer tenths avoid PostgreSQL numeric string handling for MVP decimal values.
### `chronic_conditions`
- `id`
- `organizationId`
- `elderId`
- `name`
- `status`: `active | controlled | resolved`
- `diagnosedAt`
- `treatmentNotes`
- `followUpNotes`
- `createdAt`
- `updatedAt`
### `health_anomaly_reviews`
- `id`
- `organizationId`
- `elderId`
- `vitalRecordId`
- `severity`: `info | warning | critical`
- `status`: `pending | reviewed | resolved`
- `title`
- `description`
- `reviewedByAccountId`
- `reviewedAt`
- `resolutionNotes`
- `createdAt`
- `updatedAt`
`vitalRecordId` is nullable so reviews can also be created directly against an elder health concern.
## Server Helpers
Create `modules/health/`:
- `modules/health/types.ts`
- shared enum values, labels, API DTO types, and validators.
- `modules/health/server/operations.ts`
- `listHealthAdminData(organizationId: string)`
- `upsertHealthProfile(...)`
- `createVitalRecord(...)`
- `createChronicCondition(...)`
- `updateHealthReview(...)`
- `modules/health/components/HealthAdminClient.tsx`
- client-side tabs, filters, dialogs/forms, optimistic-free refresh.
Server reads should batch queries by organization and join elders once. Avoid per-elder database calls.
## API Design
MVP route handlers:
- `GET /api/health/admin`
- permission: `health:read`
- returns metrics, elders, profiles, recent vitals, chronic conditions, reviews.
- `PUT /api/health/profiles/[elderId]`
- permission: `health:manage`
- validates elder belongs to active organization.
- upserts one profile.
- `POST /api/health/vitals`
- permission: `health:manage`
- validates elder belongs to active organization.
- creates vital record.
- may create a pending review if values exceed MVP thresholds.
- `POST /api/health/chronic-conditions`
- permission: `health:manage`
- validates elder belongs to active organization.
- creates chronic condition.
- `PATCH /api/health/reviews/[id]`
- permission: `health:manage`
- validates review belongs to active organization.
- updates status and review metadata.
All responses use `ApiResult`.
## Permission Design
Update `modules/core/types.ts` and `modules/core/server/permissions.ts`:
- Add `health:read`
- Add `health:manage`
- Add permission definitions in category `健康`
- Grant defaults:
- `org_admin`: read/manage
- `manager`: read/manage
- `caregiver`: read/manage unless user chooses stricter admin-only behavior
- `viewer`: read
This avoids overloading `elder:update` for a separate health management page.
## UI Design
The page should look like an internal management console:
- Header: `健康数据管理`, short description, manage badge if allowed.
- Metrics: elders with profiles, vitals today, active chronic conditions, pending reviews.
- Tabs:
- `健康档案`: elder list with profile completeness and edit dialog.
- `生命体征`: recent vitals table and add-vital dialog.
- `慢病记录`: condition table and add-condition dialog.
- `异常复核`: pending/reviewed/resolved queue with review action.
- Filters: elder search, status/severity select, source select where useful.
Keep tables and cards dense; no landing-page composition.
## Compatibility And Rollback
- Existing `/app/health` remains unchanged in MVP, limiting frontend blast radius.
- New tables are additive; rollback is dropping the generated health tables/enums and removing nav/API/page files.
- Existing seed file has uncommitted work. Implementation must read and preserve user changes in `modules/core/server/default-workspace-data.ts` before adding health seed rows.
## Validation
Required:
- `pnpm db:generate`
- Review generated SQL in `drizzle/`
- `pnpm lint`
- `pnpm type-check`
- `pnpm build`
Manual checks:
- login as authorized user and open `/app/{slug}/settings/health`
- create/update a health profile
- create a vital record
- create or review an abnormal item
- verify reload persists records
- verify mutation with insufficient permission returns `403`

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,74 @@
# Implementation Plan
## 1. Align Task Metadata
- Update task title/description if needed so the Trellis task reflects `健康数据管理` instead of care-service workspace wording.
- Keep the existing task directory unless a rename is explicitly requested.
## 2. Schema And Migration
- Add health enums and tables in `modules/core/server/schema.ts`.
- Add indexes/unique constraints:
- profile unique `(organizationId, elderId)`
- recent vital lookups by `(organizationId, elderId, recordedAt)`
- review queues by `(organizationId, status, severity)`
- Run `pnpm db:generate`.
- Review generated migration SQL.
## 3. Types And Permissions
- Add `health:read` and `health:manage` to `modules/core/types.ts`.
- Add permission definitions and default role grants in `modules/core/server/permissions.ts`.
- Add health DTOs, labels, validators, and formatting helpers in `modules/health/types.ts`.
## 4. Server Operations
- Create `modules/health/server/operations.ts`.
- Implement batched list helper for the admin page.
- Implement mutation helpers for:
- profile upsert
- vital create
- chronic condition create
- review status update
- Ensure all helpers require an organization ID and validate elder/review ownership.
## 5. API Routes
- Add `/api/health/admin`.
- Add `/api/health/profiles/[elderId]`.
- Add `/api/health/vitals`.
- Add `/api/health/chronic-conditions`.
- Add `/api/health/reviews/[id]`.
- Use `requirePermission`, structured `jsonSuccess` / `jsonFailure`, and audit logs.
## 6. Admin Page UI
- Add `app/(app)/app/settings/health/page.tsx`.
- Add `app/(app)/app/[organizationSlug]/settings/health/page.tsx` delegating to the unscoped route pattern.
- Add `modules/health/components/HealthAdminClient.tsx`.
- Use existing UI adapters for buttons, cards, dialogs, inputs, selects, tables, and badges.
- Add management nav and breadcrumbs.
## 7. Default Workspace Data
- Carefully inspect current uncommitted `modules/core/server/default-workspace-data.ts`.
- Add health seed records only if compatible with existing default seed flow.
- Tie seed records to seeded elders and organization.
## 8. Verification
- Run `pnpm lint`.
- Run `pnpm type-check`.
- Run `pnpm build`.
- If a database is available, run `pnpm db:migrate` and manually test:
- scoped management page loads.
- health profile upsert persists.
- vital record create persists.
- abnormal review update persists.
- insufficient permission returns `403`.
## Rollback Points
- Before migration generation: schema changes can be removed directly.
- After migration generation: remove generated migration files and schema changes together.
- After UI/API addition: remove `modules/health`, health API routes, settings health pages, nav/breadcrumb additions, and health permissions.

View File

@@ -0,0 +1,117 @@
# Health Care Admin Page
## Goal
Build a separate backend management page for health care data instead of turning the existing operational `健康照护` module into a management surface. The first usable slice should let authorized staff manage real persisted health profiles, vital signs, chronic conditions, and abnormal-review records for elders in the active organization.
## Background And Confirmed Facts
- The current `健康照护` operational route renders the generic placeholder only: `app/(app)/app/health/page.tsx`.
- The scoped `健康照护` route delegates to the unscoped placeholder: `app/(app)/app/[organizationSlug]/health/page.tsx`.
- The placeholder contract explicitly forbids fabricated operational records until real Drizzle-backed data exists: `.trellis/spec/frontend/components.md`.
- The sidebar already has a separate `管理系统` group for backend management pages: `modules/shared/lib/navigation.ts`.
- Existing management routes live under `/settings/...` and have scoped mirrors under `/app/[organizationSlug]/settings/...`.
- Breadcrumbs already treat `/settings/...` as the management area: `modules/shared/components/AppBreadcrumbs.tsx`.
- The current Drizzle schema includes organizations, elders, rooms, beds, admissions, audit logs, incidents, permissions, roles, and sessions, but no health profile, vital sign, chronic condition, or health review tables: `modules/core/server/schema.ts`.
- Existing persisted feature pages load data in Server Components and pass initial records into Client Components, for example `app/(app)/app/elders/page.tsx` and `app/(app)/app/beds/page.tsx`.
- The user explicitly clarified that this should be a separate backend management page, split from the current operational health page.
## Requirements
### Route And Navigation
- Add a new backend management route under the management area.
- Recommended route: `/app/settings/health` with scoped mirror `/app/{organizationSlug}/settings/health`.
- Add a `管理系统` navigation item labeled `健康数据管理`.
- Keep the existing `/app/health` operational page separate from this management page for this task.
- Breadcrumbs must show the new route under `工作台 > 管理系统 > 健康数据管理`.
### Data Model And Persistence
- Add Drizzle-backed tables for the health-care admin MVP.
- Health data must be scoped by `organizationId`.
- Health data that belongs to an elder must reference a real elder in the same organization.
- Minimum persisted domains:
- health profile: allergy notes, medical history, medication notes, restrictions, emergency health note.
- vital sign record: recorded time, source, blood pressure, heart rate, temperature, SpO2, blood glucose, weight, notes.
- chronic condition: condition name, status, diagnosed date or note, treatment/follow-up notes.
- abnormal review: severity, status, source vital record or elder, reviewer, reviewed time, handling notes.
- Generated migrations must stay coherent with `modules/core/server/schema.ts`.
- New mutations must use Drizzle queries or transactions through server boundaries, not legacy write helpers.
### Permissions
- Add explicit health permissions unless implementation review finds an existing permission is a better fit:
- `health:read`
- `health:manage`
- The new management navigation item should require `health:read`.
- Mutations should require `health:manage`.
- Default role seeding should grant sensible permissions:
- platform admin: all permissions through the existing all-permissions pattern.
- organization admin and manager: read/manage.
- caregiver: read/manage for health records if the product treats caregivers as health-data operators.
- viewer: read only.
### API And Server Boundaries
- Add health-specific Route Handlers under `/api/health/...`.
- All health APIs must call `requirePermission`.
- All APIs must validate active organization presence before reading or mutating organization-scoped data.
- Invalid or cross-organization IDs must return structured failures.
- Mutations must record audit logs for create/update/review actions.
- API responses must follow the existing `{ success, reason, ... }` shape.
### Admin UI
- The new page must be dense and operational, not a hero or marketing page.
- The first screen should show summary metrics and data management controls.
- Expected tabs or sections:
- 健康档案
- 生命体征
- 慢病记录
- 异常复核
- The page should support filtering by elder, status/severity, and search terms where useful.
- Authorized users should be able to create or update health records from the UI.
- Users without manage permission may view records but must not see working mutation paths.
- Use project UI adapters from `components/ui/*`.
- Do not fabricate UI-only health records.
### Default Workspace Data
- If seeded workspace data is used for first-run development/demo, it must create real persisted health records tied to real seeded elders.
- Seed records should cover normal vitals, abnormal vitals, chronic conditions, and at least one pending abnormal review.
## Acceptance Criteria
- [ ] `/app/settings/health` renders a real backend health management page.
- [ ] `/app/{organizationSlug}/settings/health` renders the same management page in the active workspace.
- [ ] The new management page is reachable from the `管理系统` navigation group.
- [ ] Existing `/app/health` remains separate from the backend management page.
- [ ] Health admin data is loaded from Drizzle-backed tables and scoped to the active organization.
- [ ] Health profiles, vital signs, chronic conditions, and abnormal reviews have persisted list views.
- [ ] Authorized users can create or update at least the MVP health records from the UI.
- [ ] Users without `health:manage` cannot mutate health records through the API.
- [ ] Health mutations write audit log entries.
- [ ] Invalid input and cross-organization references return structured failure responses.
- [ ] Seeded examples, if added, are real database rows tied to seeded elders.
- [ ] `pnpm db:generate` is run after schema changes and generated SQL is reviewed.
- [ ] `pnpm lint` passes.
- [ ] `pnpm type-check` passes.
- [ ] `pnpm build` passes.
## Out Of Scope
- Replacing the existing operational `/app/health` placeholder with a full clinical dashboard.
- Device telemetry integration.
- External medical system integration.
- AI-generated health recommendations.
- Complex clinical decision support.
- Full recurring measurement schedule engine.
- Family app notifications.
- Billing, pricing, or insurance flows.
- Replacing Route Handlers with oRPC.
- Replacing the current authentication/session implementation.
## Open Questions
- None currently blocking planning.

View File

@@ -0,0 +1,26 @@
{
"id": "care-service-workspace",
"name": "care-service-workspace",
"title": "新增健康照护后台管理页",
"description": "新增独立的健康数据管理后台页面,与现有运营健康照护入口分开。",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "TalexDreamSoul",
"assignee": "TalexDreamSoul",
"createdAt": "2026-07-02",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": "07-02-operations-module-roadmap",
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,55 @@
# 老人床位联动优化
## Goal
Improve the existing elder and bed workspaces so operators can see relevant cross-module context without leaving the workflow: current admission, bed, recent care tasks, health abnormal records, and safety incidents.
## Confirmed Facts
- `老人档案` already supports persisted elder CRUD.
- `床位房间` already supports persisted room, bed, admission, transfer, and discharge workflows.
- Elder rows already show current active bed labels from admissions.
- Bed rows already show current elder names where occupied.
- Health, care, and emergency modules will add additional context after their child tasks.
## Requirements
- Add cross-module context to elder and bed surfaces only after the relevant underlying persisted data exists.
- Elder detail/edit surfaces should expose concise recent context:
- current bed/admission state
- recent care execution records
- recent health abnormalities or latest vital summary
- open safety/emergency incidents tied to the elder when available
- Bed workspace should expose concise current occupant context:
- elder status and care level
- active/recent care tasks for the occupant
- open safety/emergency incidents for the bed/occupant when available
- The UI should remain dense and operational; avoid nested cards or large explanatory blocks.
- Cross-module data must be read-only context unless the user is in that module's own workflow.
- Server data loading should stay scoped by active organization.
## Acceptance Criteria
- [ ] Elder pages show current bed/admission context from persisted data.
- [ ] Elder pages show recent health/care/emergency context once those records exist.
- [ ] Bed workspace shows current occupant context without requiring manual lookup.
- [ ] Cross-module context does not fabricate records when a module has no data.
- [ ] Unauthorized users do not see context that they lack permission to read.
- [ ] Existing elder CRUD and bed/admission workflows still work.
- [ ] `pnpm lint` passes.
- [ ] `pnpm type-check` passes.
- [ ] `pnpm build` passes.
## Dependencies
- Should run after health data management, care execution workspace, and emergency incident workspace have shipped basic persisted data.
## Out Of Scope
- Moving all workflows into a single giant elder detail page.
- Editing health/care/emergency records from elder or bed context panels.
- Replacing existing elder or bed workspace architecture.
## Open Questions
- None currently blocking planning.

View File

@@ -0,0 +1,26 @@
{
"id": "elder-bed-context-optimization",
"name": "elder-bed-context-optimization",
"title": "老人床位联动优化",
"description": "",
"status": "planning",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "TalexDreamSoul",
"assignee": "TalexDreamSoul",
"createdAt": "2026-07-02",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": "07-02-operations-module-roadmap",
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,65 @@
# 安全应急事件工作台
## Goal
Replace the current `安全应急` placeholder with a real emergency and safety event workspace. The MVP should let operators view, triage, update, and close persisted incidents inside the active organization.
## Confirmed Facts
- `/app/emergency` currently renders `ModulePage` only.
- `/app/{organizationSlug}/emergency` delegates to the unscoped emergency page.
- `systemIncidents` already exists in the Drizzle schema and supports severity, status, title, description, source, acknowledgement, resolution, and organization scoping.
- `app/api/system/incidents/[id]/route.ts` already supports incident status updates with audit logs.
- Settings status pages already use incident status actions.
## Requirements
- Replace `/app/emergency` and `/app/{organizationSlug}/emergency` with a real emergency workspace.
- Use existing persisted incident data as the starting model unless implementation analysis proves a separate emergency table is required.
- Show summary metrics by severity and status.
- Show an operational incident list/table with search and status/severity filters.
- Support status transitions for open, acknowledged, resolved, and closed events.
- Support creating a manual emergency/safety event if practical in the MVP.
- Link incidents to organization and optionally to elder/bed context in a future-compatible way.
- Mutations must record audit logs.
- Default workspace seed data must include representative safety/emergency events.
## Permissions
- Use existing `incident:read` for viewing.
- Use existing `incident:manage` for create/update/status transitions.
- Server-side permissions are required for all mutation APIs.
## Acceptance Criteria
- [ ] `/app/emergency` no longer renders the generic placeholder.
- [ ] `/app/{organizationSlug}/emergency` renders the same real emergency workspace.
- [ ] Emergency events are loaded from Drizzle and scoped to the active organization.
- [ ] The workspace shows severity/status metrics and a searchable event list.
- [ ] Authorized users can transition event status from the UI.
- [ ] Event status updates persist and survive reload.
- [ ] Authorized users can create a manual safety/emergency event if included in MVP implementation.
- [ ] Unauthorized mutation attempts return structured failures.
- [ ] Emergency mutations write audit log entries.
- [ ] Default seeded workspace includes safety/emergency events.
- [ ] `pnpm lint` passes.
- [ ] `pnpm type-check` passes.
- [ ] `pnpm build` passes.
## Dependencies
- Can start independently because `systemIncidents` already exists.
- Elder/bed context optimization may later add richer links from incidents to elders or beds.
- Dashboard integration should wait until this task is complete.
## Out Of Scope
- Device telemetry ingestion.
- Real-time alarm channels.
- SMS, phone, or push notification delivery.
- Dispatch routing engine.
- Post-incident regulatory reporting.
## Open Questions
- None currently blocking planning.

View File

@@ -0,0 +1,26 @@
{
"id": "emergency-incident-workspace",
"name": "emergency-incident-workspace",
"title": "安全应急事件工作台",
"description": "",
"status": "planning",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "TalexDreamSoul",
"assignee": "TalexDreamSoul",
"createdAt": "2026-07-02",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": "07-02-operations-module-roadmap",
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,57 @@
# 运营看板整合优化
## Goal
Upgrade the existing operation dashboard into a cross-module summary after health, care, emergency, elder, bed, and admission data are available. The dashboard should summarize and route operators to work, not become a CRUD surface.
## Confirmed Facts
- The dashboard already uses real persisted elders, beds, admissions, and incidents.
- The dashboard currently does not include dedicated health or care-service metrics because those data sources do not exist yet.
- Recharts is already installed and the dashboard already uses a data-backed bed occupancy chart.
## Requirements
- Keep the dashboard as a Server Component data-loading surface with presentational components.
- Add health metrics after health data management exists:
- recent abnormal vital signs
- pending abnormal reviews
- chronic-condition coverage or counts where useful
- Add care metrics after care execution exists:
- today's care tasks
- pending/in-progress/completed care counts
- overdue/high-priority care count
- Add emergency metrics after emergency workspace exists:
- open safety events
- critical events
- recent resolved/closed events where useful
- Keep existing elder, bed, and admission summaries working.
- Use data-backed charts only; do not add fake visualization rows.
- Link dashboard items to the appropriate operational module route where practical.
## Acceptance Criteria
- [ ] Dashboard still loads persisted elder, bed, admission, and incident data.
- [ ] Dashboard includes persisted health summaries after health data is available.
- [ ] Dashboard includes persisted care execution summaries after care data is available.
- [ ] Dashboard includes persisted safety/emergency summaries after emergency data is available.
- [ ] Charts and tables are data-backed.
- [ ] Dashboard links route users into the relevant module instead of duplicating full workflows.
- [ ] `pnpm lint` passes.
- [ ] `pnpm type-check` passes.
- [ ] `pnpm build` passes.
## Dependencies
- Should run after health data management, care execution workspace, and emergency incident workspace are implemented.
- Should run after elder/bed context optimization if dashboard links depend on new detail surfaces.
## Out Of Scope
- Creating or editing records directly from the dashboard.
- Adding fake summary cards before backing data exists.
- Replacing the dashboard with a marketing landing page.
## Open Questions
- None currently blocking planning.

View File

@@ -0,0 +1,26 @@
{
"id": "operations-dashboard-integration",
"name": "operations-dashboard-integration",
"title": "运营看板整合优化",
"description": "",
"status": "planning",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "TalexDreamSoul",
"assignee": "TalexDreamSoul",
"createdAt": "2026-07-02",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": "07-02-operations-module-roadmap",
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,47 @@
# Operations Module Roadmap Design
## Boundary
This parent task is a coordination artifact. It should not directly implement schema, API, or UI changes unless a later requirement explicitly adds parent-level integration work.
Implementation belongs to child tasks:
- `07-02-care-service-workspace`: health data management page.
- `07-02-care-execution-workspace`: care execution workspace.
- `07-02-emergency-incident-workspace`: emergency event workspace.
- `07-02-elder-bed-context-optimization`: cross-module context on elder and bed workflows.
- `07-02-operations-dashboard-integration`: final dashboard summaries.
## Dependency Shape
The modules should be delivered in foundation-to-summary order:
1. Health data management creates health records.
2. Care execution creates care task/service records.
3. Emergency workspace operationalizes incident records.
4. Elder/bed context reads the new health/care/emergency records.
5. Dashboard integration summarizes all persisted data.
## Shared Technical Contracts
- Drizzle schema remains the persistence source of truth.
- New tables are additive migrations.
- Organization scoping is mandatory.
- Elder-linked records validate same-organization ownership.
- API responses use `{ success, reason, ... }`.
- Route handlers call `requirePermission`.
- Mutations record audit logs.
- Default workspace data creates real rows, not UI-only fixtures.
## UI Contract
- Operational pages should use dense work-focused layouts.
- Backend management pages belong under `管理系统`.
- Existing `/app/health` remains separate from backend `健康数据管理`.
- `护理服务` is daily execution work, not health data management.
- `安全应急` is event triage and closure.
- `运营看板` summarizes and routes; it is not a CRUD surface.
## Rollback
Rollback should happen per child task. Parent rollback means unlinking or archiving the roadmap task; child migrations and source changes must be reverted in their own rollback plans.

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,42 @@
# Implementation Plan
## Execution Order
1. Start and implement `07-02-care-service-workspace` (`新增健康照护后台管理页`).
2. Start and implement `07-02-care-execution-workspace` (`护理服务执行工作台`).
3. Start and implement `07-02-emergency-incident-workspace` (`安全应急事件工作台`).
4. Start and implement `07-02-elder-bed-context-optimization` (`老人床位联动优化`).
5. Start and implement `07-02-operations-dashboard-integration` (`运营看板整合优化`).
6. Review parent acceptance criteria after all children are complete.
## Per-Child Gate
Before starting each child:
- Read its `prd.md`.
- For complex children, add or refresh `design.md` and `implement.md`.
- Load `trellis-before-dev` and relevant backend/frontend/shared specs.
Before finishing each child:
- Run `pnpm lint`.
- Run `pnpm type-check`.
- Run `pnpm build`.
- Run `pnpm db:generate` and review SQL when schema changes are made.
- Confirm default workspace seed data is real persisted data when seed examples are added.
## Parent Completion Gate
The parent task is complete only after:
- Each child task is completed or explicitly moved out of scope by the user.
- The dashboard reflects completed module data.
- Existing elder, bed, admission, auth, settings, and audit workflows still pass validation.
- Final `pnpm lint`, `pnpm type-check`, and `pnpm build` pass.
## Risk Notes
- Schema migrations across multiple children can conflict if implemented out of order.
- Default seed file already has active edits; every child that touches it must read it before editing.
- Permission additions must keep role seeding idempotent.
- Dashboard integration should not start before the data sources exist.

View File

@@ -0,0 +1,106 @@
# 运营模块完善总计划
## Goal
Turn the six operation-side sidebar entries into a coherent persisted operating system for the pension workspace:
1. 运营看板
2. 老人档案
3. 床位房间
4. 健康照护
5. 护理服务
6. 安全应急
The work should be delivered as separate, independently verifiable child tasks, then integrated back into the dashboard. This parent task owns scope, ordering, and cross-module acceptance criteria. Implementation should happen in child tasks.
## Confirmed Facts
- `运营看板` already renders real Drizzle-backed elders, beds, admissions, and incidents data.
- `老人档案` already supports real persisted elder CRUD.
- `床位房间` already has a real bed/admission workspace backed by rooms, beds, elders, and admissions.
- `健康照护`, `护理服务`, and `安全应急` currently render placeholders or are not complete operational modules.
- The current Drizzle schema has core operational tables but no dedicated health or care-service tables yet.
- `systemIncidents` already provides a useful base for safety/emergency event state flow.
- Static module pages must not fabricate operational data before a real persisted source exists.
- The user wants all of these operation modules planned and completed, not only the currently selected sidebar item.
## Task Map
### Child 1: 新增健康照护后台管理页
- Path: `.trellis/tasks/07-02-care-service-workspace`
- Note: The slug is historical, but the task title and artifacts now describe the health data management page.
- Scope: Add backend management page for health profiles, vital signs, chronic conditions, and abnormal reviews.
- Dependency: None. This can be implemented first because it creates the health data foundation.
### Child 2: 护理服务执行工作台
- Path: `.trellis/tasks/07-02-care-execution-workspace`
- Scope: Add care task/service execution records, status flow, completion notes, and seeded examples.
- Dependency: Can use existing elder/admission data. Does not depend on health admin unless a care item links to abnormal health reviews later.
### Child 3: 安全应急事件工作台
- Path: `.trellis/tasks/07-02-emergency-incident-workspace`
- Scope: Replace the placeholder with an emergency event workspace using and extending persisted incident data.
- Dependency: Can build on `systemIncidents` and existing incident status actions.
### Child 4: 老人床位联动优化
- Path: `.trellis/tasks/07-02-elder-bed-context-optimization`
- Scope: Improve elder and bed screens so operators can see linked health, care, admission, and incident context where available.
- Dependency: Should run after health/care/emergency have at least basic persisted data.
### Child 5: 运营看板整合优化
- Path: `.trellis/tasks/07-02-operations-dashboard-integration`
- Scope: Integrate health, care, emergency, elder, bed, and admission summaries into the dashboard.
- Dependency: Should run after the relevant child data surfaces exist.
## Delivery Order
1. Health data management page.
2. Care execution workspace.
3. Emergency event workspace.
4. Elder/bed cross-module context improvements.
5. Dashboard integration.
This order creates data foundations first, then links records to existing operator workflows, then summarizes everything in the dashboard.
## Cross-Module Requirements
- All new operational records must be Drizzle/PostgreSQL backed.
- New data must be scoped by active organization.
- New data tied to elders must reference real elders in the same organization.
- Default workspace seed data must create real persisted rows, not UI-only demo objects.
- All API responses must follow `{ success, reason, ... }`.
- Permission checks must happen on the server.
- Mutations must record audit logs.
- Navigation labels should remain clear:
- `健康照护` remains the operational health entry.
- backend health management can live under `管理系统` as `健康数据管理`.
- `护理服务` remains daily service execution.
- `安全应急` remains event response and closure.
- The app must not show hero/marketing-style module introductions for operational pages.
## Acceptance Criteria
- [ ] Each child task has its own PRD and implementation scope.
- [ ] Health, care, and emergency modules no longer rely on fabricated UI-only data.
- [ ] Existing elder and bed workflows remain functional after new modules are added.
- [ ] Default workspace seed data covers elders, beds, health, care, and incidents once all child tasks are complete.
- [ ] Dashboard metrics include the new persisted health/care/emergency data after integration.
- [ ] `pnpm lint` passes for each implementation child.
- [ ] `pnpm type-check` passes for each implementation child.
- [ ] `pnpm build` passes before the parent task is considered complete.
## Out Of Scope
- Doing all child tasks in a single implementation pass.
- Replacing Route Handlers with oRPC.
- Replacing current auth/session implementation.
- Billing, family app notifications, device telemetry integrations, and AI recommendations unless created as separate future tasks.
## Open Questions
- None blocking the plan. Implementation should start with Child 1 unless the user explicitly changes priority.

View File

@@ -0,0 +1,32 @@
{
"id": "operations-module-roadmap",
"name": "operations-module-roadmap",
"title": "运营模块完善总计划",
"description": "",
"status": "planning",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "TalexDreamSoul",
"assignee": "TalexDreamSoul",
"createdAt": "2026-07-02",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [
"07-02-care-service-workspace",
"07-02-emergency-incident-workspace",
"07-02-elder-bed-context-optimization",
"07-02-care-execution-workspace",
"07-02-operations-dashboard-integration"
],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -0,0 +1,5 @@
import HealthSettingsPage from "../../../settings/health/page";
export default async function ScopedHealthSettingsPage(): Promise<React.ReactElement> {
return HealthSettingsPage();
}

View File

@@ -0,0 +1,27 @@
import { redirect } from "next/navigation";
import { getCurrentAuthContext } from "@/modules/core/server/auth";
import { hasPermission } from "@/modules/core/server/permissions";
import { listHealthAdminData } from "@/modules/health/server/operations";
import { HealthAdminClient } from "@/modules/health/components/HealthAdminClient";
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
export default async function HealthSettingsPage(): Promise<React.ReactElement> {
const context = await getCurrentAuthContext();
if (!context) {
redirect("/login");
}
if (!hasPermission(context.permissions, "health:read")) {
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
}
const organizationId = context.organization?.id;
if (!organizationId) {
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
}
const data = await listHealthAdminData(organizationId);
return <HealthAdminClient canManage={hasPermission(context.permissions, "health:manage")} initialData={data} />;
}

View File

@@ -0,0 +1,22 @@
import { jsonFailure, jsonSuccess } from "@/modules/core/server/api";
import { requirePermission } from "@/modules/core/server/auth";
import { listHealthAdminData } from "@/modules/health/server/operations";
export async function GET(): Promise<Response> {
const auth = await requirePermission("health:read", {
action: "health.admin.list",
targetType: "health",
});
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后查看健康数据", 400);
}
const data = await listHealthAdminData(organizationId);
return jsonSuccess("健康数据已加载", { data });
}

View File

@@ -0,0 +1,43 @@
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
import { createChronicCondition, isMutationFailure } from "@/modules/health/server/operations";
import { validateChronicConditionInput } from "@/modules/health/types";
export async function POST(request: Request): Promise<Response> {
const auth = await requirePermission("health:manage", {
action: "health.condition.create",
targetType: "chronic_condition",
});
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后维护慢病记录", 400);
}
const input = validateChronicConditionInput(await readJsonBody(request));
if (!input.success) {
return jsonFailure(input.reason);
}
const condition = await createChronicCondition({ ...input.data, organizationId });
if (isMutationFailure(condition)) {
return jsonFailure(condition.reason, condition.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId,
action: "health.condition.create",
targetType: "chronic_condition",
targetId: condition.id,
result: "success",
reason: `新增慢病记录:${condition.elderName} / ${condition.name}`,
});
return jsonSuccess("慢病记录已创建", { condition }, 201);
}

View File

@@ -0,0 +1,372 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
import type { AuthContext } from "@/modules/core/types";
import {
createChronicCondition,
createVitalRecord,
listHealthAdminData,
updateHealthReview,
upsertHealthProfile,
} from "@/modules/health/server/operations";
vi.mock("@/modules/core/server/auth", () => ({
requirePermission: vi.fn(),
}));
vi.mock("@/modules/core/server/audit", () => ({
recordAuditLog: vi.fn(),
}));
vi.mock("@/modules/health/server/operations", () => ({
createChronicCondition: vi.fn(),
createVitalRecord: vi.fn(),
isMutationFailure: vi.fn((value: unknown) => typeof value === "object" && value !== null && "success" in value && value.success === false),
listHealthAdminData: vi.fn(),
updateHealthReview: vi.fn(),
upsertHealthProfile: vi.fn(),
}));
const account: AuthContext["account"] = {
id: "account-1",
name: "Admin",
email: "admin@example.com",
avatarUrl: "",
role: "org_admin",
status: "active",
createdAt: "2026-07-02T00:00:00.000Z",
updatedAt: "2026-07-02T00:00:00.000Z",
};
function createAuthContext(overrides: Partial<AuthContext> = {}): AuthContext {
return {
account,
organization: {
id: "org-1",
name: "Demo Org",
slug: "demo-org",
status: "active",
registrationEnabled: true,
oidcEnabled: false,
oidcIssuerUrl: "",
oidcClientId: "",
oidcHasClientSecret: false,
oidcScopes: "openid profile email",
oidcRedirectUri: "",
oidcAvatarClaim: "picture",
oidcAutoProvision: false,
createdAt: "2026-07-02T00:00:00.000Z",
updatedAt: "2026-07-02T00:00:00.000Z",
},
organizations: [],
permissions: ["health:read", "health:manage"],
session: {
id: "session-1",
accountId: "account-1",
activeOrganizationId: "org-1",
createdAt: "2026-07-02T00:00:00.000Z",
expiresAt: "2026-07-09T00:00:00.000Z",
},
...overrides,
};
}
const authContext = createAuthContext();
const healthAdminData = {
chronicConditions: [],
elders: [],
metrics: {
eldersWithProfiles: 0,
pendingReviews: 0,
activeChronicConditions: 0,
vitalsToday: 0,
},
profiles: [],
reviews: [],
vitals: [],
};
function allowAuth(): void {
vi.mocked(requirePermission).mockResolvedValue({
success: true,
context: authContext,
});
}
function jsonRequest(body: Record<string, unknown>): Request {
return new Request("http://localhost/api/health", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
async function readJson(response: Response): Promise<Record<string, unknown>> {
return (await response.json()) as Record<string, unknown>;
}
beforeEach(() => {
vi.clearAllMocks();
allowAuth();
});
describe("health admin API routes", () => {
it("loads health admin data for the active organization", async () => {
vi.mocked(listHealthAdminData).mockResolvedValue(healthAdminData);
const { GET } = await import("./admin/route");
const response = await GET();
const body = await readJson(response);
expect(response.status).toBe(200);
expect(body.success).toBe(true);
expect(body.data).toEqual(healthAdminData);
expect(listHealthAdminData).toHaveBeenCalledWith("org-1");
});
it("returns permission failures before loading admin data", async () => {
const denied = Response.json({ success: false, reason: "权限不足" }, { status: 403 });
vi.mocked(requirePermission).mockResolvedValue({ success: false, response: denied });
const { GET } = await import("./admin/route");
const response = await GET();
const body = await readJson(response);
expect(response.status).toBe(403);
expect(body.reason).toBe("权限不足");
expect(listHealthAdminData).not.toHaveBeenCalled();
});
it("requires an active organization before loading admin data", async () => {
vi.mocked(requirePermission).mockResolvedValue({
success: true,
context: createAuthContext({ organization: undefined }),
});
const { GET } = await import("./admin/route");
const response = await GET();
const body = await readJson(response);
expect(response.status).toBe(400);
expect(body.reason).toBe("请选择机构后查看健康数据");
expect(listHealthAdminData).not.toHaveBeenCalled();
});
it("upserts one elder health profile", async () => {
vi.mocked(upsertHealthProfile).mockResolvedValue({
allergyNotes: "青霉素过敏",
careRestrictions: "低盐饮食",
createdAt: "2026-07-02T00:00:00.000Z",
elderId: "elder-1",
elderName: "王桂兰",
emergencyNotes: "胸闷需立即复核",
id: "profile-1",
medicalHistory: "高血压",
medicationNotes: "晨服降压药",
organizationId: "org-1",
updatedAt: "2026-07-02T00:00:00.000Z",
});
const { PUT } = await import("./profiles/[elderId]/route");
const response = await PUT(jsonRequest({ allergyNotes: "青霉素过敏" }), {
params: Promise.resolve({ elderId: "elder-1" }),
});
const body = await readJson(response);
expect(response.status).toBe(200);
expect(body.success).toBe(true);
expect(upsertHealthProfile).toHaveBeenCalledWith(
expect.objectContaining({
elderId: "elder-1",
organizationId: "org-1",
}),
);
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "health.profile.upsert" }));
});
it("rejects invalid vital input", async () => {
const { POST } = await import("./vitals/route");
const response = await POST(jsonRequest({ elderId: "elder-1", recordedAt: "bad-date", source: "manual" }));
const body = await readJson(response);
expect(response.status).toBe(400);
expect(body.reason).toBe("记录时间无效");
expect(createVitalRecord).not.toHaveBeenCalled();
expect(recordAuditLog).not.toHaveBeenCalled();
});
it("creates a vital record", async () => {
vi.mocked(createVitalRecord).mockResolvedValue({
review: {
createdAt: "2026-07-02T00:00:00.000Z",
description: "血压偏高",
elderId: "elder-1",
elderName: "王桂兰",
id: "review-1",
organizationId: "org-1",
resolutionNotes: "",
reviewedAt: undefined,
reviewedByAccountId: undefined,
reviewedByName: undefined,
severity: "warning",
status: "pending",
title: "血压异常",
updatedAt: "2026-07-02T00:00:00.000Z",
vitalRecordId: "vital-1",
},
vital: {
bloodGlucoseTenths: 62,
createdAt: "2026-07-02T00:00:00.000Z",
createdByAccountId: "account-1",
createdByName: "Admin",
diastolicBp: 96,
elderId: "elder-1",
elderName: "王桂兰",
heartRate: 88,
id: "vital-1",
notes: "复测",
organizationId: "org-1",
recordedAt: "2026-07-02T09:00:00.000Z",
source: "manual",
spo2: 96,
systolicBp: 158,
temperatureTenths: 367,
updatedAt: "2026-07-02T00:00:00.000Z",
weightTenths: 612,
},
});
const { POST } = await import("./vitals/route");
const response = await POST(
jsonRequest({
elderId: "elder-1",
recordedAt: "2026-07-02T09:00:00.000Z",
source: "manual",
systolicBp: 158,
diastolicBp: 96,
}),
);
const body = await readJson(response);
expect(response.status).toBe(201);
expect(body.success).toBe(true);
expect(createVitalRecord).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "account-1",
organizationId: "org-1",
}),
);
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "health.vital.create" }));
});
it("creates a chronic condition", async () => {
vi.mocked(createChronicCondition).mockResolvedValue({
createdAt: "2026-07-02T00:00:00.000Z",
diagnosedAt: "2024-01-01T00:00:00.000Z",
elderId: "elder-1",
elderName: "王桂兰",
followUpNotes: "每周复查",
id: "condition-1",
name: "高血压",
organizationId: "org-1",
status: "active",
treatmentNotes: "规律用药",
updatedAt: "2026-07-02T00:00:00.000Z",
});
const { POST } = await import("./chronic-conditions/route");
const response = await POST(
jsonRequest({
diagnosedAt: "2024-01-01T00:00:00.000Z",
elderId: "elder-1",
name: "高血压",
status: "active",
}),
);
const body = await readJson(response);
expect(response.status).toBe(201);
expect(body.success).toBe(true);
expect(createChronicCondition).toHaveBeenCalledWith(expect.objectContaining({ organizationId: "org-1" }));
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "health.condition.create" }));
});
it("returns structured failures for missing or cross-organization chronic condition elders", async () => {
vi.mocked(createChronicCondition).mockResolvedValue({
success: false,
reason: "老人档案不存在",
status: 404,
});
const { POST } = await import("./chronic-conditions/route");
const response = await POST(
jsonRequest({
elderId: "elder-from-other-org",
name: "高血压",
status: "active",
}),
);
const body = await readJson(response);
expect(response.status).toBe(404);
expect(body.reason).toBe("老人档案不存在");
expect(recordAuditLog).not.toHaveBeenCalled();
});
it("updates an anomaly review", async () => {
vi.mocked(updateHealthReview).mockResolvedValue({
createdAt: "2026-07-02T00:00:00.000Z",
description: "血压偏高",
elderId: "elder-1",
elderName: "王桂兰",
id: "review-1",
organizationId: "org-1",
resolutionNotes: "已复测并通知医生",
reviewedAt: "2026-07-02T10:00:00.000Z",
reviewedByAccountId: "account-1",
reviewedByName: "Admin",
severity: "warning",
status: "reviewed",
title: "血压异常",
updatedAt: "2026-07-02T10:00:00.000Z",
vitalRecordId: "vital-1",
});
const { PATCH } = await import("./reviews/[id]/route");
const response = await PATCH(jsonRequest({ status: "reviewed", resolutionNotes: "已复测并通知医生" }), {
params: Promise.resolve({ id: "review-1" }),
});
const body = await readJson(response);
expect(response.status).toBe(200);
expect(body.success).toBe(true);
expect(updateHealthReview).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "account-1",
id: "review-1",
organizationId: "org-1",
}),
);
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "health.review.update" }));
});
it("returns structured failures for missing or cross-organization reviews", async () => {
vi.mocked(updateHealthReview).mockResolvedValue({
success: false,
reason: "异常复核记录不存在",
status: 404,
});
const { PATCH } = await import("./reviews/[id]/route");
const response = await PATCH(jsonRequest({ status: "reviewed", resolutionNotes: "已复核" }), {
params: Promise.resolve({ id: "review-from-other-org" }),
});
const body = await readJson(response);
expect(response.status).toBe(404);
expect(body.reason).toBe("异常复核记录不存在");
expect(recordAuditLog).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,51 @@
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
import { isMutationFailure, upsertHealthProfile } from "@/modules/health/server/operations";
import { validateHealthProfileInput } from "@/modules/health/types";
type RouteContext = {
params: Promise<{
elderId: string;
}>;
};
export async function PUT(request: Request, context: RouteContext): Promise<Response> {
const { elderId } = await context.params;
const auth = await requirePermission("health:manage", {
action: "health.profile.upsert",
targetType: "elder",
targetId: elderId,
});
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后维护健康档案", 400);
}
const input = validateHealthProfileInput(await readJsonBody(request));
if (!input.success) {
return jsonFailure(input.reason);
}
const profile = await upsertHealthProfile({ ...input.data, elderId, organizationId });
if (isMutationFailure(profile)) {
return jsonFailure(profile.reason, profile.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId,
action: "health.profile.upsert",
targetType: "elder",
targetId: elderId,
result: "success",
reason: `维护健康档案:${profile.elderName}`,
});
return jsonSuccess("健康档案已保存", { profile });
}

View File

@@ -0,0 +1,56 @@
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
import { isMutationFailure, updateHealthReview } from "@/modules/health/server/operations";
import { validateHealthReviewUpdateInput } from "@/modules/health/types";
type RouteContext = {
params: Promise<{
id: string;
}>;
};
export async function PATCH(request: Request, context: RouteContext): Promise<Response> {
const { id } = await context.params;
const auth = await requirePermission("health:manage", {
action: "health.review.update",
targetType: "health_review",
targetId: id,
});
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后处理异常复核", 400);
}
const input = validateHealthReviewUpdateInput(await readJsonBody(request));
if (!input.success) {
return jsonFailure(input.reason);
}
const review = await updateHealthReview({
...input.data,
id,
organizationId,
accountId: auth.context.account.id,
});
if (isMutationFailure(review)) {
return jsonFailure(review.reason, review.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId,
action: "health.review.update",
targetType: "health_review",
targetId: review.id,
result: "success",
reason: `处理健康异常复核:${review.elderName}`,
});
return jsonSuccess("异常复核已更新", { review });
}

View File

@@ -0,0 +1,43 @@
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
import { createVitalRecord, isMutationFailure } from "@/modules/health/server/operations";
import { validateVitalRecordInput } from "@/modules/health/types";
export async function POST(request: Request): Promise<Response> {
const auth = await requirePermission("health:manage", {
action: "health.vital.create",
targetType: "vital_record",
});
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后录入生命体征", 400);
}
const input = validateVitalRecordInput(await readJsonBody(request));
if (!input.success) {
return jsonFailure(input.reason);
}
const created = await createVitalRecord({ ...input.data, organizationId, accountId: auth.context.account.id });
if (isMutationFailure(created)) {
return jsonFailure(created.reason, created.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId,
action: "health.vital.create",
targetType: "vital_record",
targetId: created.vital.id,
result: "success",
reason: `录入生命体征:${created.vital.elderName}`,
});
return jsonSuccess("生命体征已录入", created, 201);
}

View File

@@ -0,0 +1,79 @@
CREATE TYPE "public"."chronic_condition_status" AS ENUM('active', 'controlled', 'resolved');--> statement-breakpoint
CREATE TYPE "public"."health_review_status" AS ENUM('pending', 'reviewed', 'resolved');--> statement-breakpoint
CREATE TYPE "public"."vital_record_source" AS ENUM('manual', 'device', 'import');--> statement-breakpoint
CREATE TABLE "chronic_conditions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"elder_id" uuid NOT NULL,
"name" text NOT NULL,
"status" "chronic_condition_status" DEFAULT 'active' NOT NULL,
"diagnosed_at" timestamp with time zone,
"treatment_notes" text DEFAULT '' NOT NULL,
"follow_up_notes" text DEFAULT '' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "health_anomaly_reviews" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"elder_id" uuid NOT NULL,
"vital_record_id" uuid,
"severity" "incident_severity" NOT NULL,
"status" "health_review_status" DEFAULT 'pending' NOT NULL,
"title" text NOT NULL,
"description" text NOT NULL,
"reviewed_by_account_id" uuid,
"reviewed_at" timestamp with time zone,
"resolution_notes" text DEFAULT '' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "health_profiles" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"elder_id" uuid NOT NULL,
"allergy_notes" text DEFAULT '' NOT NULL,
"medical_history" text DEFAULT '' NOT NULL,
"medication_notes" text DEFAULT '' NOT NULL,
"care_restrictions" text DEFAULT '' NOT NULL,
"emergency_notes" text DEFAULT '' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "vital_records" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"elder_id" uuid NOT NULL,
"recorded_at" timestamp with time zone NOT NULL,
"source" "vital_record_source" DEFAULT 'manual' NOT NULL,
"systolic_bp" integer,
"diastolic_bp" integer,
"heart_rate" integer,
"temperature_tenths" integer,
"spo2" integer,
"blood_glucose_tenths" integer,
"weight_tenths" integer,
"notes" text DEFAULT '' NOT NULL,
"created_by_account_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "chronic_conditions" ADD CONSTRAINT "chronic_conditions_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chronic_conditions" ADD CONSTRAINT "chronic_conditions_elder_id_elders_id_fk" FOREIGN KEY ("elder_id") REFERENCES "public"."elders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "health_anomaly_reviews" ADD CONSTRAINT "health_anomaly_reviews_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "health_anomaly_reviews" ADD CONSTRAINT "health_anomaly_reviews_elder_id_elders_id_fk" FOREIGN KEY ("elder_id") REFERENCES "public"."elders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "health_anomaly_reviews" ADD CONSTRAINT "health_anomaly_reviews_vital_record_id_vital_records_id_fk" FOREIGN KEY ("vital_record_id") REFERENCES "public"."vital_records"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "health_anomaly_reviews" ADD CONSTRAINT "health_anomaly_reviews_reviewed_by_account_id_accounts_id_fk" FOREIGN KEY ("reviewed_by_account_id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "health_profiles" ADD CONSTRAINT "health_profiles_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "health_profiles" ADD CONSTRAINT "health_profiles_elder_id_elders_id_fk" FOREIGN KEY ("elder_id") REFERENCES "public"."elders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "vital_records" ADD CONSTRAINT "vital_records_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "vital_records" ADD CONSTRAINT "vital_records_elder_id_elders_id_fk" FOREIGN KEY ("elder_id") REFERENCES "public"."elders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "vital_records" ADD CONSTRAINT "vital_records_created_by_account_id_accounts_id_fk" FOREIGN KEY ("created_by_account_id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "chronic_conditions_elder_status_lookup" ON "chronic_conditions" USING btree ("organization_id","elder_id","status");--> statement-breakpoint
CREATE INDEX "health_anomaly_reviews_queue_lookup" ON "health_anomaly_reviews" USING btree ("organization_id","status","severity");--> statement-breakpoint
CREATE UNIQUE INDEX "health_profiles_organization_elder_unique" ON "health_profiles" USING btree ("organization_id","elder_id");--> statement-breakpoint
CREATE INDEX "vital_records_recent_lookup" ON "vital_records" USING btree ("organization_id","elder_id","recorded_at");

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,13 @@
"when": 1782998189463, "when": 1782998189463,
"tag": "0002_equal_bishop", "tag": "0002_equal_bishop",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1783063091807,
"tag": "0003_gray_dust",
"breakpoints": true
} }
] ]
} }

View File

@@ -5,6 +5,7 @@ import { cookies } from "next/headers";
import { jsonFailure } from "@/modules/core/server/api"; import { jsonFailure } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit"; import { recordAuditLog } from "@/modules/core/server/audit";
import { seedDefaultWorkspaceData } from "@/modules/core/server/default-workspace-data";
import { getDatabase } from "@/modules/core/server/db"; import { getDatabase } from "@/modules/core/server/db";
import { getPermissionsForRole, hasPermission, seedOrganizationRoles } from "@/modules/core/server/permissions"; import { getPermissionsForRole, hasPermission, seedOrganizationRoles } from "@/modules/core/server/permissions";
import { import {
@@ -275,6 +276,7 @@ export async function setupFirstPlatformAdmin(input: {
}) })
.onConflictDoNothing(); .onConflictDoNothing();
} }
await seedDefaultWorkspaceData(created.organization.id);
const organization = toOrganization(created.organization); const organization = toOrganization(created.organization);
const publicAccount = toPublicAccount(created.account, created.platformRole.key, organization); const publicAccount = toPublicAccount(created.account, created.platformRole.key, organization);

View File

@@ -0,0 +1,677 @@
import { eq } from "drizzle-orm";
import { getDatabase } from "@/modules/core/server/db";
import {
admissions,
beds,
buildings,
campuses,
chronicConditions,
elders,
floors,
healthAnomalyReviews,
healthProfiles,
rooms,
systemIncidents,
vitalRecords,
} from "@/modules/core/server/schema";
type SeedRoom = {
buildingName: string;
campusName: string;
code: string;
floorLevel: number;
floorName: string;
name: string;
capacity: number;
};
type SeedBed = {
code: string;
notes: string;
roomCode: string;
status: "available" | "occupied" | "maintenance" | "disabled";
};
type SeedElder = {
age: number;
careLevel: "self-care" | "semi-assisted" | "assisted" | "intensive";
gender: "male" | "female" | "other";
medicalNotes: string;
name: string;
phone: string;
primaryContact: string;
status: "active" | "pending" | "discharged";
};
type SeedAdmission = {
bedCode: string;
elderName: string;
notes: string;
status: "active" | "transferred" | "discharged";
};
type SeedHealthProfile = {
allergyNotes: string;
careRestrictions: string;
elderName: string;
emergencyNotes: string;
medicalHistory: string;
medicationNotes: string;
};
type SeedVitalRecord = {
bloodGlucoseTenths?: number;
diastolicBp?: number;
elderName: string;
heartRate?: number;
notes: string;
recordedHoursAgo: number;
source: "manual" | "device" | "import";
spo2?: number;
systolicBp?: number;
temperatureTenths?: number;
weightTenths?: number;
};
type SeedChronicCondition = {
elderName: string;
followUpNotes: string;
name: string;
status: "active" | "controlled" | "resolved";
treatmentNotes: string;
};
type SeedHealthReview = {
description: string;
elderName: string;
resolutionNotes: string;
severity: "info" | "warning" | "critical";
status: "pending" | "reviewed" | "resolved";
title: string;
vitalIndex: number;
};
const DEFAULT_ROOMS: SeedRoom[] = [
{ campusName: "主院区", buildingName: "护理楼", floorName: "一层", floorLevel: 1, code: "A101", name: "阳光房", capacity: 2 },
{ campusName: "主院区", buildingName: "护理楼", floorName: "一层", floorLevel: 1, code: "A102", name: "康养房", capacity: 2 },
{ campusName: "主院区", buildingName: "护理楼", floorName: "二层", floorLevel: 2, code: "A201", name: "记忆照护房", capacity: 2 },
{ campusName: "主院区", buildingName: "护理楼", floorName: "二层", floorLevel: 2, code: "A202", name: "重点照护房", capacity: 2 },
{ campusName: "主院区", buildingName: "康复楼", floorName: "一层", floorLevel: 1, code: "R101", name: "康复观察房", capacity: 2 },
{ campusName: "主院区", buildingName: "康复楼", floorName: "一层", floorLevel: 1, code: "R102", name: "短住评估房", capacity: 2 },
{ campusName: "北苑院区", buildingName: "颐养楼", floorName: "一层", floorLevel: 1, code: "B101", name: "自理房", capacity: 2 },
{ campusName: "北苑院区", buildingName: "颐养楼", floorName: "二层", floorLevel: 2, code: "B201", name: "亲情套房", capacity: 2 },
];
const DEFAULT_BEDS: SeedBed[] = [
{ code: "A101-1", roomCode: "A101", status: "occupied", notes: "靠窗,已完成入住评估" },
{ code: "A101-2", roomCode: "A101", status: "available", notes: "可安排半护理老人入住" },
{ code: "A102-1", roomCode: "A102", status: "occupied", notes: "夜间巡护重点关注" },
{ code: "A102-2", roomCode: "A102", status: "maintenance", notes: "床头呼叫器检修中" },
{ code: "A201-1", roomCode: "A201", status: "occupied", notes: "记忆照护专区" },
{ code: "A201-2", roomCode: "A201", status: "available", notes: "靠近护理站" },
{ code: "A202-1", roomCode: "A202", status: "occupied", notes: "压疮风险重点巡查" },
{ code: "A202-2", roomCode: "A202", status: "disabled", notes: "待更换护栏" },
{ code: "R101-1", roomCode: "R101", status: "occupied", notes: "康复训练后休息床位" },
{ code: "R101-2", roomCode: "R101", status: "available", notes: "临时观察床位" },
{ code: "R102-1", roomCode: "R102", status: "available", notes: "短住评估床位" },
{ code: "R102-2", roomCode: "R102", status: "available", notes: "短住评估床位" },
{ code: "B101-1", roomCode: "B101", status: "occupied", notes: "自理老人长期床位" },
{ code: "B101-2", roomCode: "B101", status: "available", notes: "低照护需求床位" },
{ code: "B201-1", roomCode: "B201", status: "occupied", notes: "亲属探访便利" },
{ code: "B201-2", roomCode: "B201", status: "available", notes: "亲情套房备用床" },
];
const DEFAULT_ELDERS: SeedElder[] = [
{
name: "王桂兰",
gender: "female",
age: 82,
careLevel: "intensive",
status: "active",
primaryContact: "张敏",
phone: "13800000001",
medicalNotes: "高血压,需晨晚血压记录。",
},
{
name: "李建国",
gender: "male",
age: 76,
careLevel: "semi-assisted",
status: "active",
primaryContact: "李明",
phone: "13800000002",
medicalNotes: "餐后散步陪同,关注睡眠情况。",
},
{
name: "周玉珍",
gender: "female",
age: 88,
careLevel: "assisted",
status: "active",
primaryContact: "周强",
phone: "13800000003",
medicalNotes: "糖尿病饮食,餐前血糖提醒。",
},
{
name: "赵国华",
gender: "male",
age: 91,
careLevel: "intensive",
status: "active",
primaryContact: "赵琳",
phone: "13800000004",
medicalNotes: "夜间翻身,两小时巡房。",
},
{
name: "孙秀梅",
gender: "female",
age: 73,
careLevel: "semi-assisted",
status: "active",
primaryContact: "孙磊",
phone: "13800000005",
medicalNotes: "膝关节康复训练,每日下午一次。",
},
{
name: "刘长青",
gender: "male",
age: 68,
careLevel: "self-care",
status: "active",
primaryContact: "刘洋",
phone: "13800000006",
medicalNotes: "自理,需定期复查用药。",
},
{
name: "陈秀英",
gender: "female",
age: 69,
careLevel: "self-care",
status: "pending",
primaryContact: "陈佳",
phone: "13800000007",
medicalNotes: "待完成入住评估。",
},
{
name: "何德明",
gender: "male",
age: 84,
careLevel: "assisted",
status: "pending",
primaryContact: "何静",
phone: "13800000008",
medicalNotes: "家属已提交体检资料,等待床位确认。",
},
{
name: "吴佩兰",
gender: "female",
age: 79,
careLevel: "semi-assisted",
status: "discharged",
primaryContact: "吴昊",
phone: "13800000009",
medicalNotes: "已退住,保留历史档案。",
},
{
name: "郑文远",
gender: "male",
age: 72,
careLevel: "self-care",
status: "discharged",
primaryContact: "郑洁",
phone: "13800000010",
medicalNotes: "短住康复结束后退住。",
},
];
const DEFAULT_ADMISSIONS: SeedAdmission[] = [
{ elderName: "王桂兰", bedCode: "A101-1", status: "active", notes: "默认工作区入住记录" },
{ elderName: "李建国", bedCode: "A102-1", status: "active", notes: "默认工作区入住记录" },
{ elderName: "周玉珍", bedCode: "A201-1", status: "active", notes: "记忆照护专区入住" },
{ elderName: "赵国华", bedCode: "A202-1", status: "active", notes: "重点照护入住" },
{ elderName: "孙秀梅", bedCode: "R101-1", status: "active", notes: "康复观察入住" },
{ elderName: "刘长青", bedCode: "B101-1", status: "active", notes: "自理区入住" },
{ elderName: "吴佩兰", bedCode: "B201-1", status: "discharged", notes: "历史退住记录" },
];
const DEFAULT_HEALTH_PROFILES: SeedHealthProfile[] = [
{
elderName: "王桂兰",
allergyNotes: "青霉素过敏",
medicalHistory: "高血压十余年,偶发胸闷。",
medicationNotes: "晨服降压药,晚间复核血压。",
careRestrictions: "低盐饮食,避免剧烈活动。",
emergencyNotes: "胸闷或血压持续升高时立即联系医生。",
},
{
elderName: "周玉珍",
allergyNotes: "无明确药物过敏史",
medicalHistory: "2 型糖尿病,轻度认知障碍。",
medicationNotes: "餐前血糖监测,按医嘱用药。",
careRestrictions: "控糖饮食,夜间加强巡护。",
emergencyNotes: "低血糖症状时立即补糖并记录。",
},
{
elderName: "赵国华",
allergyNotes: "海鲜过敏",
medicalHistory: "脑梗后遗症,长期卧床风险。",
medicationNotes: "抗凝药按时服用。",
careRestrictions: "两小时翻身,防跌倒,防压疮。",
emergencyNotes: "意识异常或血氧下降时立即启动应急流程。",
},
{
elderName: "刘长青",
allergyNotes: "",
medicalHistory: "冠心病随访。",
medicationNotes: "自主管理用药,护理员每日提醒。",
careRestrictions: "避免爬楼和长时间站立。",
emergencyNotes: "胸痛需立即报告。",
},
];
const DEFAULT_VITAL_RECORDS: SeedVitalRecord[] = [
{
elderName: "王桂兰",
recordedHoursAgo: 2,
source: "manual",
systolicBp: 158,
diastolicBp: 96,
heartRate: 88,
temperatureTenths: 367,
spo2: 96,
bloodGlucoseTenths: 62,
weightTenths: 612,
notes: "血压偏高,已安排复测。",
},
{
elderName: "李建国",
recordedHoursAgo: 5,
source: "device",
systolicBp: 128,
diastolicBp: 78,
heartRate: 76,
temperatureTenths: 365,
spo2: 98,
notes: "体征平稳。",
},
{
elderName: "周玉珍",
recordedHoursAgo: 7,
source: "manual",
systolicBp: 134,
diastolicBp: 82,
heartRate: 80,
temperatureTenths: 366,
spo2: 97,
bloodGlucoseTenths: 118,
notes: "餐后血糖偏高,已记录饮食。",
},
{
elderName: "赵国华",
recordedHoursAgo: 12,
source: "manual",
systolicBp: 146,
diastolicBp: 88,
heartRate: 92,
temperatureTenths: 381,
spo2: 91,
notes: "体温和血氧异常,需复核。",
},
];
const DEFAULT_CHRONIC_CONDITIONS: SeedChronicCondition[] = [
{
elderName: "王桂兰",
name: "高血压",
status: "active",
treatmentNotes: "规律服药,晨晚血压监测。",
followUpNotes: "每周汇总血压趋势。",
},
{
elderName: "周玉珍",
name: "糖尿病",
status: "active",
treatmentNotes: "控糖饮食,餐前餐后血糖记录。",
followUpNotes: "异常血糖进入复核队列。",
},
{
elderName: "刘长青",
name: "冠心病",
status: "controlled",
treatmentNotes: "按医嘱服药,避免劳累。",
followUpNotes: "月度复诊提醒。",
},
];
const DEFAULT_HEALTH_REVIEWS: SeedHealthReview[] = [
{
elderName: "王桂兰",
vitalIndex: 0,
severity: "warning",
status: "pending",
title: "血压异常",
description: "收缩压和舒张压偏高,需要复测并确认用药。",
resolutionNotes: "",
},
{
elderName: "赵国华",
vitalIndex: 3,
severity: "critical",
status: "pending",
title: "血氧和体温异常",
description: "血氧低于阈值且体温偏高,需要立即复核。",
resolutionNotes: "",
},
{
elderName: "周玉珍",
vitalIndex: 2,
severity: "info",
status: "reviewed",
title: "餐后血糖偏高",
description: "餐后血糖高于日常水平。",
resolutionNotes: "已调整晚餐主食并安排次日复测。",
},
];
function hasRows(rows: unknown[]): boolean {
return rows.length > 0;
}
export async function seedDefaultWorkspaceData(organizationId: string): Promise<void> {
const database = getDatabase();
const [existingRooms, existingBeds, existingElders, existingAdmissions] = await Promise.all([
database.select({ id: rooms.id }).from(rooms).where(eq(rooms.organizationId, organizationId)).limit(1),
database.select({ id: beds.id }).from(beds).where(eq(beds.organizationId, organizationId)).limit(1),
database.select({ id: elders.id }).from(elders).where(eq(elders.organizationId, organizationId)).limit(1),
database.select({ id: admissions.id }).from(admissions).where(eq(admissions.organizationId, organizationId)).limit(1),
]);
if ([existingRooms, existingBeds, existingElders, existingAdmissions].some(hasRows)) {
return;
}
await database.transaction(async (transaction) => {
const uniqueCampusNames = Array.from(new Set(DEFAULT_ROOMS.map((room) => room.campusName)));
const campusRows = await transaction
.insert(campuses)
.values(
uniqueCampusNames.map((name) => ({
organizationId,
name,
address: `${name}养老服务院区`,
})),
)
.returning();
if (campusRows.length !== uniqueCampusNames.length) {
throw new Error("默认院区初始化失败");
}
const campusIdByName = new Map(campusRows.map((campus) => [campus.name, campus.id]));
const uniqueBuildings = Array.from(
new Map(DEFAULT_ROOMS.map((room) => [`${room.campusName}/${room.buildingName}`, room])).values(),
);
const buildingRows = await transaction
.insert(buildings)
.values(
uniqueBuildings.map((building) => {
const campusId = campusIdByName.get(building.campusName);
if (!campusId) {
throw new Error("默认楼栋初始化失败");
}
return {
organizationId,
campusId,
name: building.buildingName,
};
}),
)
.returning();
if (buildingRows.length !== uniqueBuildings.length) {
throw new Error("默认楼栋初始化失败");
}
const buildingIdByKey = new Map(
buildingRows.map((building, index) => {
const source = uniqueBuildings[index];
if (!source) {
throw new Error("默认楼栋初始化失败");
}
return [`${source.campusName}/${source.buildingName}`, building.id];
}),
);
const uniqueFloors = Array.from(
new Map(
DEFAULT_ROOMS.map((room) => [
`${room.campusName}/${room.buildingName}/${room.floorLevel}`,
room,
]),
).values(),
);
const floorRows = await transaction
.insert(floors)
.values(
uniqueFloors.map((floor) => {
const buildingId = buildingIdByKey.get(`${floor.campusName}/${floor.buildingName}`);
if (!buildingId) {
throw new Error("默认楼层初始化失败");
}
return {
organizationId,
buildingId,
name: floor.floorName,
level: floor.floorLevel,
};
}),
)
.returning();
if (floorRows.length !== uniqueFloors.length) {
throw new Error("默认楼层初始化失败");
}
const floorIdByKey = new Map(
floorRows.map((floor, index) => {
const source = uniqueFloors[index];
if (!source) {
throw new Error("默认楼层初始化失败");
}
return [`${source.campusName}/${source.buildingName}/${source.floorLevel}`, floor.id];
}),
);
const roomRows = await transaction
.insert(rooms)
.values(
DEFAULT_ROOMS.map((room) => {
const floorId = floorIdByKey.get(`${room.campusName}/${room.buildingName}/${room.floorLevel}`);
if (!floorId) {
throw new Error("默认房间初始化失败");
}
return {
organizationId,
floorId,
name: room.name,
code: room.code,
capacity: room.capacity,
};
}),
)
.returning();
if (roomRows.length !== DEFAULT_ROOMS.length) {
throw new Error("默认房间初始化失败");
}
const roomIdByCode = new Map(roomRows.map((room) => [room.code, room.id]));
const bedRows = await transaction
.insert(beds)
.values(
DEFAULT_BEDS.map((bed) => {
const roomId = roomIdByCode.get(bed.roomCode);
if (!roomId) {
throw new Error("默认床位初始化失败");
}
return {
organizationId,
roomId,
code: bed.code,
status: bed.status,
notes: bed.notes,
};
}),
)
.returning();
if (bedRows.length !== DEFAULT_BEDS.length) {
throw new Error("默认床位初始化失败");
}
const elderRows = await transaction
.insert(elders)
.values(DEFAULT_ELDERS.map((elder) => ({ ...elder, organizationId })))
.returning();
if (elderRows.length !== DEFAULT_ELDERS.length) {
throw new Error("默认老人档案初始化失败");
}
const elderIdByName = new Map(elderRows.map((elder) => [elder.name, elder.id]));
const bedIdByCode = new Map(bedRows.map((bed) => [bed.code, bed.id]));
await transaction.insert(admissions).values(
DEFAULT_ADMISSIONS.map((admission) => {
const elderId = elderIdByName.get(admission.elderName);
const bedId = bedIdByCode.get(admission.bedCode);
if (!elderId || !bedId) {
throw new Error("默认入住初始化失败");
}
return {
organizationId,
elderId,
bedId,
status: admission.status,
dischargedAt: admission.status === "discharged" ? new Date() : undefined,
notes: admission.notes,
};
}),
);
await transaction.insert(healthProfiles).values(
DEFAULT_HEALTH_PROFILES.map((profile) => {
const elderId = elderIdByName.get(profile.elderName);
if (!elderId) {
throw new Error("默认健康档案初始化失败");
}
return {
organizationId,
elderId,
allergyNotes: profile.allergyNotes,
medicalHistory: profile.medicalHistory,
medicationNotes: profile.medicationNotes,
careRestrictions: profile.careRestrictions,
emergencyNotes: profile.emergencyNotes,
};
}),
);
const now = new Date();
const vitalRows = await transaction
.insert(vitalRecords)
.values(
DEFAULT_VITAL_RECORDS.map((vital) => {
const elderId = elderIdByName.get(vital.elderName);
if (!elderId) {
throw new Error("默认生命体征初始化失败");
}
return {
organizationId,
elderId,
recordedAt: new Date(now.getTime() - vital.recordedHoursAgo * 60 * 60 * 1000),
source: vital.source,
systolicBp: vital.systolicBp,
diastolicBp: vital.diastolicBp,
heartRate: vital.heartRate,
temperatureTenths: vital.temperatureTenths,
spo2: vital.spo2,
bloodGlucoseTenths: vital.bloodGlucoseTenths,
weightTenths: vital.weightTenths,
notes: vital.notes,
};
}),
)
.returning();
if (vitalRows.length !== DEFAULT_VITAL_RECORDS.length) {
throw new Error("默认生命体征初始化失败");
}
await transaction.insert(chronicConditions).values(
DEFAULT_CHRONIC_CONDITIONS.map((condition) => {
const elderId = elderIdByName.get(condition.elderName);
if (!elderId) {
throw new Error("默认慢病记录初始化失败");
}
return {
organizationId,
elderId,
name: condition.name,
status: condition.status,
treatmentNotes: condition.treatmentNotes,
followUpNotes: condition.followUpNotes,
};
}),
);
await transaction.insert(healthAnomalyReviews).values(
DEFAULT_HEALTH_REVIEWS.map((review) => {
const elderId = elderIdByName.get(review.elderName);
const vital = vitalRows[review.vitalIndex];
if (!elderId || !vital) {
throw new Error("默认健康异常复核初始化失败");
}
return {
organizationId,
elderId,
vitalRecordId: vital.id,
severity: review.severity,
status: review.status,
title: review.title,
description: review.description,
resolutionNotes: review.resolutionNotes,
};
}),
);
await transaction.insert(systemIncidents).values([
{
organizationId,
severity: "warning",
status: "open",
title: "床头呼叫器待检修",
description: "A102-2 床位呼叫器处于检修状态,完成后可恢复使用。",
source: "默认工作区初始化",
},
{
organizationId,
severity: "info",
status: "acknowledged",
title: "本周护理评估待完成",
description: "陈秀英、何德明等待入住评估,可在老人档案和床位工作区继续处理。",
source: "默认工作区初始化",
},
{
organizationId,
severity: "critical",
status: "resolved",
title: "夜间巡房异常已处理",
description: "重点照护房夜间巡房提醒已恢复,保留事件用于状态页验证。",
source: "默认工作区初始化",
},
]);
});
}

View File

@@ -25,6 +25,8 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
{ id: "audit:read", label: "查看审计", category: "审计", description: "查看关键操作审计日志。" }, { id: "audit:read", label: "查看审计", category: "审计", description: "查看关键操作审计日志。" },
{ id: "incident:read", label: "查看故障", category: "运维", description: "查看系统健康和故障中心。" }, { id: "incident:read", label: "查看故障", category: "运维", description: "查看系统健康和故障中心。" },
{ id: "incident:manage", label: "处理故障", category: "运维", description: "确认、处理和关闭故障事件。" }, { id: "incident:manage", label: "处理故障", category: "运维", description: "确认、处理和关闭故障事件。" },
{ id: "health:read", label: "查看健康数据", category: "健康", description: "查看老人健康档案、生命体征和异常复核。" },
{ id: "health:manage", label: "管理健康数据", category: "健康", description: "维护健康档案、生命体征、慢病和异常复核。" },
{ id: "facility:read", label: "查看房间床位", category: "床位", description: "查看房间、床位和占用状态。" }, { id: "facility:read", label: "查看房间床位", category: "床位", description: "查看房间、床位和占用状态。" },
{ id: "facility:manage", label: "管理房间床位", category: "床位", description: "维护院区、房间、床位和床位状态。" }, { id: "facility:manage", label: "管理房间床位", category: "床位", description: "维护院区、房间、床位和床位状态。" },
{ id: "admission:read", label: "查看入住", category: "入住", description: "查看入住、换床、退住历史。" }, { id: "admission:read", label: "查看入住", category: "入住", description: "查看入住、换床、退住历史。" },
@@ -89,6 +91,8 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [
"permission:read", "permission:read",
"audit:read", "audit:read",
"incident:read", "incident:read",
"health:read",
"health:manage",
"facility:read", "facility:read",
"facility:manage", "facility:manage",
"admission:read", "admission:read",
@@ -107,6 +111,8 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [
"account:read", "account:read",
"role:read", "role:read",
"audit:read", "audit:read",
"health:read",
"health:manage",
"facility:read", "facility:read",
"facility:manage", "facility:manage",
"admission:read", "admission:read",
@@ -121,13 +127,13 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [
key: "caregiver", key: "caregiver",
scope: "organization", scope: "organization",
description: "照护人员,查看床位和维护老人照护信息。", description: "照护人员,查看床位和维护老人照护信息。",
permissions: ["facility:read", "admission:read", "elder:read", "elder:update"], permissions: ["health:read", "health:manage", "facility:read", "admission:read", "elder:read", "elder:update"],
}, },
{ {
key: "viewer", key: "viewer",
scope: "organization", scope: "organization",
description: "普通用户,只读查看老人、床位和入住信息。", description: "普通用户,只读查看老人、床位和入住信息。",
permissions: ["facility:read", "admission:read", "elder:read"], permissions: ["health:read", "facility:read", "admission:read", "elder:read"],
}, },
{ {
key: "family", key: "family",

View File

@@ -1,5 +1,6 @@
import { import {
boolean, boolean,
index,
integer, integer,
pgEnum, pgEnum,
pgTable, pgTable,
@@ -23,6 +24,9 @@ export const auditResultEnum = pgEnum("audit_result", ["success", "denied", "fai
export const incidentStatusEnum = pgEnum("incident_status", ["open", "acknowledged", "resolved", "closed"]); export const incidentStatusEnum = pgEnum("incident_status", ["open", "acknowledged", "resolved", "closed"]);
export const incidentSeverityEnum = pgEnum("incident_severity", ["info", "warning", "critical"]); export const incidentSeverityEnum = pgEnum("incident_severity", ["info", "warning", "critical"]);
export const joinRequestStatusEnum = pgEnum("join_request_status", ["pending", "approved", "rejected"]); export const joinRequestStatusEnum = pgEnum("join_request_status", ["pending", "approved", "rejected"]);
export const vitalRecordSourceEnum = pgEnum("vital_record_source", ["manual", "device", "import"]);
export const chronicConditionStatusEnum = pgEnum("chronic_condition_status", ["active", "controlled", "resolved"]);
export const healthReviewStatusEnum = pgEnum("health_review_status", ["pending", "reviewed", "resolved"]);
export const systemSettings = pgTable("system_settings", { export const systemSettings = pgTable("system_settings", {
id: text("id").primaryKey().default("global"), id: text("id").primaryKey().default("global"),
@@ -226,6 +230,75 @@ export const elders = pgTable("elders", {
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}); });
export const healthProfiles = pgTable("health_profiles", {
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
elderId: uuid("elder_id").notNull().references(() => elders.id, { onDelete: "cascade" }),
allergyNotes: text("allergy_notes").notNull().default(""),
medicalHistory: text("medical_history").notNull().default(""),
medicationNotes: text("medication_notes").notNull().default(""),
careRestrictions: text("care_restrictions").notNull().default(""),
emergencyNotes: text("emergency_notes").notNull().default(""),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
elderUnique: uniqueIndex("health_profiles_organization_elder_unique").on(table.organizationId, table.elderId),
}));
export const vitalRecords = pgTable("vital_records", {
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
elderId: uuid("elder_id").notNull().references(() => elders.id, { onDelete: "cascade" }),
recordedAt: timestamp("recorded_at", { withTimezone: true }).notNull(),
source: vitalRecordSourceEnum("source").notNull().default("manual"),
systolicBp: integer("systolic_bp"),
diastolicBp: integer("diastolic_bp"),
heartRate: integer("heart_rate"),
temperatureTenths: integer("temperature_tenths"),
spo2: integer("spo2"),
bloodGlucoseTenths: integer("blood_glucose_tenths"),
weightTenths: integer("weight_tenths"),
notes: text("notes").notNull().default(""),
createdByAccountId: uuid("created_by_account_id").references(() => accounts.id),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
recentLookup: index("vital_records_recent_lookup").on(table.organizationId, table.elderId, table.recordedAt),
}));
export const chronicConditions = pgTable("chronic_conditions", {
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
elderId: uuid("elder_id").notNull().references(() => elders.id, { onDelete: "cascade" }),
name: text("name").notNull(),
status: chronicConditionStatusEnum("status").notNull().default("active"),
diagnosedAt: timestamp("diagnosed_at", { withTimezone: true }),
treatmentNotes: text("treatment_notes").notNull().default(""),
followUpNotes: text("follow_up_notes").notNull().default(""),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
elderStatusLookup: index("chronic_conditions_elder_status_lookup").on(table.organizationId, table.elderId, table.status),
}));
export const healthAnomalyReviews = pgTable("health_anomaly_reviews", {
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
elderId: uuid("elder_id").notNull().references(() => elders.id, { onDelete: "cascade" }),
vitalRecordId: uuid("vital_record_id").references(() => vitalRecords.id, { onDelete: "set null" }),
severity: incidentSeverityEnum("severity").notNull(),
status: healthReviewStatusEnum("status").notNull().default("pending"),
title: text("title").notNull(),
description: text("description").notNull(),
reviewedByAccountId: uuid("reviewed_by_account_id").references(() => accounts.id),
reviewedAt: timestamp("reviewed_at", { withTimezone: true }),
resolutionNotes: text("resolution_notes").notNull().default(""),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
reviewQueueLookup: index("health_anomaly_reviews_queue_lookup").on(table.organizationId, table.status, table.severity),
}));
export const admissions = pgTable("admissions", { export const admissions = pgTable("admissions", {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),

View File

@@ -39,6 +39,8 @@ export const PERMISSIONS = [
"audit:read", "audit:read",
"incident:read", "incident:read",
"incident:manage", "incident:manage",
"health:read",
"health:manage",
"facility:read", "facility:read",
"facility:manage", "facility:manage",
"admission:read", "admission:read",

View File

@@ -0,0 +1,822 @@
"use client";
import { FormEvent, useMemo, useState } from "react";
import { Activity, ClipboardPlus, FileText, HeartPulse, Search, ShieldAlert, Stethoscope } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog } from "@/components/ui/dialog";
import { Input, Textarea } from "@/components/ui/input";
import { Select, type SelectOption } from "@/components/ui/select";
import { Table } from "@/components/ui/table";
import type { ApiResult } from "@/modules/core/server/api";
import type {
ChronicCondition,
ChronicConditionStatus,
HealthAdminData,
HealthAnomalyReview,
HealthProfile,
HealthReviewSeverity,
HealthReviewStatus,
VitalRecord,
VitalSource,
} from "@/modules/health/types";
import {
CHRONIC_CONDITION_STATUS_LABELS,
HEALTH_REVIEW_SEVERITY_LABELS,
HEALTH_REVIEW_STATUS_LABELS,
VITAL_SOURCE_LABELS,
} from "@/modules/health/types";
type HealthAdminClientProps = {
canManage: boolean;
initialData: HealthAdminData;
};
type HealthTab = "profiles" | "vitals" | "conditions" | "reviews";
type ProfileForm = {
allergyNotes: string;
medicalHistory: string;
medicationNotes: string;
careRestrictions: string;
emergencyNotes: string;
};
const tabs: Array<{ value: HealthTab; label: string }> = [
{ value: "profiles", label: "健康档案" },
{ value: "vitals", label: "生命体征" },
{ value: "conditions", label: "慢病记录" },
{ value: "reviews", label: "异常复核" },
];
const emptyProfileForm: ProfileForm = {
allergyNotes: "",
medicalHistory: "",
medicationNotes: "",
careRestrictions: "",
emergencyNotes: "",
};
const emptyVitalForm = {
elderId: "",
recordedAt: "",
source: "manual" as VitalSource,
systolicBp: "",
diastolicBp: "",
heartRate: "",
temperatureTenths: "",
spo2: "",
bloodGlucoseTenths: "",
weightTenths: "",
notes: "",
};
const emptyConditionForm = {
elderId: "",
name: "",
status: "active" as ChronicConditionStatus,
diagnosedAt: "",
treatmentNotes: "",
followUpNotes: "",
};
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 optionalNumber(value: string): number | undefined {
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const numeric = Number(trimmed);
return Number.isFinite(numeric) ? numeric : undefined;
}
function optionalTenths(value: string): number | undefined {
const numeric = optionalNumber(value);
return numeric === undefined ? undefined : Math.round(numeric * 10);
}
function profileToForm(profile: HealthProfile | undefined): ProfileForm {
return profile
? {
allergyNotes: profile.allergyNotes,
medicalHistory: profile.medicalHistory,
medicationNotes: profile.medicationNotes,
careRestrictions: profile.careRestrictions,
emergencyNotes: profile.emergencyNotes,
}
: emptyProfileForm;
}
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";
}
export function HealthAdminClient({ canManage, initialData }: HealthAdminClientProps): React.ReactElement {
const [data, setData] = useState(initialData);
const [activeTab, setActiveTab] = useState<HealthTab>("profiles");
const [query, setQuery] = useState("");
const [vitalSourceFilter, setVitalSourceFilter] = useState<"all" | VitalSource>("all");
const [conditionStatusFilter, setConditionStatusFilter] = useState<"all" | ChronicConditionStatus>("all");
const [reviewStatusFilter, setReviewStatusFilter] = useState<"all" | HealthReviewStatus>("all");
const [reviewSeverityFilter, setReviewSeverityFilter] = useState<"all" | HealthReviewSeverity>("all");
const [message, setMessage] = useState("");
const [isPending, setIsPending] = useState(false);
const [editingProfile, setEditingProfile] = useState<HealthProfile | undefined>();
const [profileElderId, setProfileElderId] = useState("");
const [profileForm, setProfileForm] = useState<ProfileForm>(emptyProfileForm);
const [isVitalOpen, setIsVitalOpen] = useState(false);
const [vitalForm, setVitalForm] = useState(emptyVitalForm);
const [isConditionOpen, setIsConditionOpen] = useState(false);
const [conditionForm, setConditionForm] = useState(emptyConditionForm);
const [reviewTarget, setReviewTarget] = useState<HealthAnomalyReview | undefined>();
const [reviewStatus, setReviewStatus] = useState<HealthReviewStatus>("reviewed");
const [reviewNotes, setReviewNotes] = useState("");
const elderOptions: SelectOption[] = useMemo(
() => [
{ value: "", label: "选择老人", disabled: true },
...data.elders.map((elder) => ({ value: elder.id, label: elder.name })),
],
[data.elders],
);
const normalizedQuery = query.trim().toLowerCase();
const profilesByElderId = useMemo(
() => new Map(data.profiles.map((profile) => [profile.elderId, profile])),
[data.profiles],
);
const filteredElders = useMemo(
() =>
data.elders.filter((elder) => {
if (!normalizedQuery) {
return true;
}
const profile = profilesByElderId.get(elder.id);
return [elder.name, profile?.allergyNotes, profile?.medicalHistory, profile?.emergencyNotes]
.join(" ")
.toLowerCase()
.includes(normalizedQuery);
}),
[data.elders, normalizedQuery, profilesByElderId],
);
const filteredVitals = useMemo(
() =>
data.vitals.filter((vital) => {
const matchesQuery = !normalizedQuery || vital.elderName.toLowerCase().includes(normalizedQuery);
const matchesSource = vitalSourceFilter === "all" || vital.source === vitalSourceFilter;
return matchesQuery && matchesSource;
}),
[data.vitals, normalizedQuery, vitalSourceFilter],
);
const filteredConditions = useMemo(
() =>
data.chronicConditions.filter((condition) => {
const matchesQuery = !normalizedQuery
? true
: [condition.elderName, condition.name].join(" ").toLowerCase().includes(normalizedQuery);
const matchesStatus = conditionStatusFilter === "all" || condition.status === conditionStatusFilter;
return matchesQuery && matchesStatus;
}),
[conditionStatusFilter, data.chronicConditions, normalizedQuery],
);
const filteredReviews = useMemo(
() =>
data.reviews.filter((review) => {
const matchesQuery = !normalizedQuery
? true
: [review.elderName, review.title, review.description].join(" ").toLowerCase().includes(normalizedQuery);
const matchesStatus = reviewStatusFilter === "all" || review.status === reviewStatusFilter;
const matchesSeverity = reviewSeverityFilter === "all" || review.severity === reviewSeverityFilter;
return matchesQuery && matchesStatus && matchesSeverity;
}),
[data.reviews, normalizedQuery, reviewSeverityFilter, reviewStatusFilter],
);
async function refreshData(): Promise<void> {
const response = await fetch("/api/health/admin");
const result = (await response.json()) as ApiResult<{ data: HealthAdminData }>;
if (result.success) {
setData(result.data);
}
}
function openProfileDialog(elderId: string): void {
const profile = profilesByElderId.get(elderId);
setEditingProfile(profile);
setProfileElderId(elderId);
setProfileForm(profileToForm(profile));
setMessage("");
}
function closeProfileDialog(): void {
if (isPending) {
return;
}
setEditingProfile(undefined);
setProfileElderId("");
setProfileForm(emptyProfileForm);
}
async function saveProfile(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
if (!canManage || !profileElderId) {
setMessage("当前角色无权维护健康档案");
return;
}
setIsPending(true);
setMessage("");
const response = await fetch(`/api/health/profiles/${profileElderId}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(profileForm),
});
const result = (await response.json()) as ApiResult<{ profile: HealthProfile }>;
setIsPending(false);
setMessage(result.reason);
if (result.success) {
closeProfileDialog();
await refreshData();
}
}
async function saveVital(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
if (!canManage) {
setMessage("当前角色无权录入生命体征");
return;
}
setIsPending(true);
setMessage("");
const response = await fetch("/api/health/vitals", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
elderId: vitalForm.elderId,
recordedAt: vitalForm.recordedAt,
source: vitalForm.source,
systolicBp: optionalNumber(vitalForm.systolicBp),
diastolicBp: optionalNumber(vitalForm.diastolicBp),
heartRate: optionalNumber(vitalForm.heartRate),
temperatureTenths: optionalTenths(vitalForm.temperatureTenths),
spo2: optionalNumber(vitalForm.spo2),
bloodGlucoseTenths: optionalTenths(vitalForm.bloodGlucoseTenths),
weightTenths: optionalTenths(vitalForm.weightTenths),
notes: vitalForm.notes,
}),
});
const result = (await response.json()) as ApiResult<{ vital: VitalRecord; review?: HealthAnomalyReview }>;
setIsPending(false);
setMessage(result.reason);
if (result.success) {
setIsVitalOpen(false);
setVitalForm(emptyVitalForm);
await refreshData();
}
}
async function saveCondition(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
if (!canManage) {
setMessage("当前角色无权新增慢病记录");
return;
}
setIsPending(true);
setMessage("");
const response = await fetch("/api/health/chronic-conditions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(conditionForm),
});
const result = (await response.json()) as ApiResult<{ condition: ChronicCondition }>;
setIsPending(false);
setMessage(result.reason);
if (result.success) {
setIsConditionOpen(false);
setConditionForm(emptyConditionForm);
await refreshData();
}
}
function openReviewDialog(review: HealthAnomalyReview): void {
setReviewTarget(review);
setReviewStatus(review.status === "pending" ? "reviewed" : review.status);
setReviewNotes(review.resolutionNotes);
setMessage("");
}
function closeReviewDialog(): void {
if (isPending) {
return;
}
setReviewTarget(undefined);
setReviewStatus("reviewed");
setReviewNotes("");
}
async function saveReview(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
if (!canManage || !reviewTarget) {
setMessage("当前角色无权处理异常复核");
return;
}
setIsPending(true);
setMessage("");
const response = await fetch(`/api/health/reviews/${reviewTarget.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ status: reviewStatus, resolutionNotes: reviewNotes }),
});
const result = (await response.json()) as ApiResult<{ review: HealthAnomalyReview }>;
setIsPending(false);
setMessage(result.reason);
if (result.success) {
closeReviewDialog();
await refreshData();
}
}
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard icon={FileText} label="健康档案" value={data.metrics.eldersWithProfiles} />
<MetricCard icon={Activity} label="今日体征" value={data.metrics.vitalsToday} />
<MetricCard icon={Stethoscope} label="慢病管理" value={data.metrics.activeChronicConditions} />
<MetricCard icon={ShieldAlert} label="待复核" value={data.metrics.pendingReviews} />
</section>
<section className="flex flex-col gap-3 border-b pb-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-wrap gap-2" role="tablist" aria-label="健康数据管理">
{tabs.map((tab) => (
<Button
aria-selected={activeTab === tab.value}
key={tab.value}
onClick={() => setActiveTab(tab.value)}
role="tab"
type="button"
variant={activeTab === tab.value ? "default" : "outline"}
>
{tab.label}
</Button>
))}
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<label className="relative block">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="w-full pl-9 sm:w-72"
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索老人、记录或异常"
value={query}
/>
</label>
{activeTab === "vitals" ? (
<Select
aria-label="体征来源"
onValueChange={(value) => setVitalSourceFilter(value as "all" | VitalSource)}
options={[
{ value: "all", label: "全部来源" },
...Object.entries(VITAL_SOURCE_LABELS).map(([value, label]) => ({ value, label })),
]}
value={vitalSourceFilter}
/>
) : null}
{activeTab === "conditions" ? (
<Select
aria-label="慢病状态"
onValueChange={(value) => setConditionStatusFilter(value as "all" | ChronicConditionStatus)}
options={[
{ value: "all", label: "全部状态" },
...Object.entries(CHRONIC_CONDITION_STATUS_LABELS).map(([value, label]) => ({ value, label })),
]}
value={conditionStatusFilter}
/>
) : null}
{activeTab === "reviews" ? (
<>
<Select
aria-label="复核状态"
onValueChange={(value) => setReviewStatusFilter(value as "all" | HealthReviewStatus)}
options={[
{ value: "all", label: "全部状态" },
...Object.entries(HEALTH_REVIEW_STATUS_LABELS).map(([value, label]) => ({ value, label })),
]}
value={reviewStatusFilter}
/>
<Select
aria-label="异常级别"
onValueChange={(value) => setReviewSeverityFilter(value as "all" | HealthReviewSeverity)}
options={[
{ value: "all", label: "全部级别" },
...Object.entries(HEALTH_REVIEW_SEVERITY_LABELS).map(([value, label]) => ({ value, label })),
]}
value={reviewSeverityFilter}
/>
</>
) : null}
{activeTab === "vitals" ? (
<Button disabled={!canManage} onClick={() => setIsVitalOpen(true)} type="button">
<HeartPulse className="size-4" aria-hidden="true" />
</Button>
) : null}
{activeTab === "conditions" ? (
<Button disabled={!canManage} onClick={() => setIsConditionOpen(true)} type="button">
<ClipboardPlus className="size-4" aria-hidden="true" />
</Button>
) : null}
</div>
</section>
{message ? (
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
{message}
</p>
) : null}
{activeTab === "profiles" ? (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="overflow-x-auto">
<Table className="w-full min-w-[840px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 text-right font-medium"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{filteredElders.map((elder) => {
const profile = profilesByElderId.get(elder.id);
return (
<Table.Row key={elder.id}>
<Table.Cell className="px-4 py-3 font-medium">{elder.name}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{profile?.allergyNotes || "-"}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{profile?.medicationNotes || "-"}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{profile?.emergencyNotes || "-"}</Table.Cell>
<Table.Cell className="px-4 py-3 text-right">
<Button
disabled={!canManage}
onClick={() => openProfileDialog(elder.id)}
size="sm"
type="button"
variant="outline"
>
</Button>
</Table.Cell>
</Table.Row>
);
})}
</Table.Body>
</Table>
</CardContent>
</Card>
) : null}
{activeTab === "vitals" ? (
<DataTable
emptyText="暂无生命体征记录"
headers={["老人", "时间", "来源", "血压", "心率", "体温", "血氧", "备注"]}
rows={filteredVitals.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 || "-",
])}
title="生命体征"
/>
) : null}
{activeTab === "conditions" ? (
<DataTable
emptyText="暂无慢病记录"
headers={["老人", "慢病", "状态", "诊断时间", "治疗记录", "随访备注"]}
rows={filteredConditions.map((condition) => [
condition.elderName,
condition.name,
CHRONIC_CONDITION_STATUS_LABELS[condition.status],
formatDateTime(condition.diagnosedAt),
condition.treatmentNotes || "-",
condition.followUpNotes || "-",
])}
title="慢病记录"
/>
) : null}
{activeTab === "reviews" ? (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="overflow-x-auto">
<Table className="w-full min-w-[900px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 text-right font-medium"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{filteredReviews.map((review) => (
<Table.Row key={review.id}>
<Table.Cell className="px-4 py-3 font-medium">{review.elderName}</Table.Cell>
<Table.Cell className="px-4 py-3">
<p className="font-medium">{review.title}</p>
<p className="text-xs text-muted-foreground">{review.description}</p>
</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={severityBadgeVariant(review.severity)}>
{HEALTH_REVIEW_SEVERITY_LABELS[review.severity]}
</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={reviewStatusBadgeVariant(review.status)}>
{HEALTH_REVIEW_STATUS_LABELS[review.status]}
</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{review.reviewedByName ?? "-"}</Table.Cell>
<Table.Cell className="px-4 py-3 text-right">
<Button
disabled={!canManage}
onClick={() => openReviewDialog(review)}
size="sm"
type="button"
variant="outline"
>
</Button>
</Table.Cell>
</Table.Row>
))}
{filteredReviews.length === 0 ? (
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
</Table.Cell>
</Table.Row>
) : null}
</Table.Body>
</Table>
</CardContent>
</Card>
) : null}
<Dialog
className="max-w-2xl"
description="维护老人健康档案基础信息。"
onClose={closeProfileDialog}
open={profileElderId.length > 0}
title={editingProfile ? "编辑健康档案" : "新增健康档案"}
>
<form className="grid gap-3" onSubmit={saveProfile}>
<Textarea
label="过敏记录"
onChange={(event) => setProfileForm((current) => ({ ...current, allergyNotes: event.target.value }))}
value={profileForm.allergyNotes}
/>
<Textarea
label="既往病史"
onChange={(event) => setProfileForm((current) => ({ ...current, medicalHistory: event.target.value }))}
value={profileForm.medicalHistory}
/>
<Textarea
label="用药备注"
onChange={(event) => setProfileForm((current) => ({ ...current, medicationNotes: event.target.value }))}
value={profileForm.medicationNotes}
/>
<Textarea
label="照护限制"
onChange={(event) => setProfileForm((current) => ({ ...current, careRestrictions: event.target.value }))}
value={profileForm.careRestrictions}
/>
<Textarea
label="紧急说明"
onChange={(event) => setProfileForm((current) => ({ ...current, emergencyNotes: event.target.value }))}
value={profileForm.emergencyNotes}
/>
<DialogActions isPending={isPending} onCancel={closeProfileDialog} submitLabel="保存档案" />
</form>
</Dialog>
<Dialog className="max-w-2xl" description="录入一条真实生命体征记录。" onClose={() => setIsVitalOpen(false)} open={isVitalOpen} title="录入生命体征">
<form className="grid gap-3" onSubmit={saveVital}>
<Select
label="老人"
onValueChange={(value) => setVitalForm((current) => ({ ...current, elderId: value }))}
options={elderOptions}
required
value={vitalForm.elderId}
/>
<div className="grid gap-3 md:grid-cols-2">
<Input
label="记录时间"
onChange={(event) => setVitalForm((current) => ({ ...current, recordedAt: event.target.value }))}
required
type="datetime-local"
value={vitalForm.recordedAt}
/>
<Select
label="来源"
onValueChange={(value) => setVitalForm((current) => ({ ...current, source: value as VitalSource }))}
options={Object.entries(VITAL_SOURCE_LABELS).map(([value, label]) => ({ value, label }))}
value={vitalForm.source}
/>
</div>
<div className="grid gap-3 md:grid-cols-3">
<Input label="收缩压" onChange={(event) => setVitalForm((current) => ({ ...current, systolicBp: event.target.value }))} value={vitalForm.systolicBp} />
<Input label="舒张压" onChange={(event) => setVitalForm((current) => ({ ...current, diastolicBp: event.target.value }))} value={vitalForm.diastolicBp} />
<Input label="心率" onChange={(event) => setVitalForm((current) => ({ ...current, heartRate: event.target.value }))} value={vitalForm.heartRate} />
<Input label="体温" onChange={(event) => setVitalForm((current) => ({ ...current, temperatureTenths: event.target.value }))} value={vitalForm.temperatureTenths} />
<Input label="血氧" onChange={(event) => setVitalForm((current) => ({ ...current, spo2: event.target.value }))} value={vitalForm.spo2} />
<Input label="体重" onChange={(event) => setVitalForm((current) => ({ ...current, weightTenths: event.target.value }))} value={vitalForm.weightTenths} />
</div>
<Textarea label="备注" onChange={(event) => setVitalForm((current) => ({ ...current, notes: event.target.value }))} value={vitalForm.notes} />
<DialogActions isPending={isPending} onCancel={() => setIsVitalOpen(false)} submitLabel="录入体征" />
</form>
</Dialog>
<Dialog className="max-w-xl" description="新增老人慢病管理记录。" onClose={() => setIsConditionOpen(false)} open={isConditionOpen} title="新增慢病记录">
<form className="grid gap-3" onSubmit={saveCondition}>
<Select
label="老人"
onValueChange={(value) => setConditionForm((current) => ({ ...current, elderId: value }))}
options={elderOptions}
required
value={conditionForm.elderId}
/>
<Input label="慢病名称" onChange={(event) => setConditionForm((current) => ({ ...current, name: event.target.value }))} required value={conditionForm.name} />
<Select
label="状态"
onValueChange={(value) => setConditionForm((current) => ({ ...current, status: value as ChronicConditionStatus }))}
options={Object.entries(CHRONIC_CONDITION_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
value={conditionForm.status}
/>
<Input
label="诊断日期"
onChange={(event) => setConditionForm((current) => ({ ...current, diagnosedAt: event.target.value }))}
type="date"
value={conditionForm.diagnosedAt}
/>
<Textarea label="治疗记录" onChange={(event) => setConditionForm((current) => ({ ...current, treatmentNotes: event.target.value }))} value={conditionForm.treatmentNotes} />
<Textarea label="随访备注" onChange={(event) => setConditionForm((current) => ({ ...current, followUpNotes: event.target.value }))} value={conditionForm.followUpNotes} />
<DialogActions isPending={isPending} onCancel={() => setIsConditionOpen(false)} submitLabel="新增慢病" />
</form>
</Dialog>
<Dialog className="max-w-lg" description={reviewTarget?.description} onClose={closeReviewDialog} open={reviewTarget !== undefined} title={reviewTarget?.title ?? "处理异常复核"}>
<form className="grid gap-3" onSubmit={saveReview}>
<Select
label="处理状态"
onValueChange={(value) => setReviewStatus(value as HealthReviewStatus)}
options={Object.entries(HEALTH_REVIEW_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
value={reviewStatus}
/>
<Textarea label="处理备注" onChange={(event) => setReviewNotes(event.target.value)} value={reviewNotes} />
<DialogActions isPending={isPending} onCancel={closeReviewDialog} submitLabel="保存复核" />
</form>
</Dialog>
</div>
);
}
function MetricCard({
icon: Icon,
label,
value,
}: {
icon: React.ComponentType<{ "aria-hidden"?: boolean; className?: string }>;
label: string;
value: number;
}): React.ReactElement {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between p-4">
<div>
<CardDescription>{label}</CardDescription>
<CardTitle className="mt-2 text-3xl">{value}</CardTitle>
</div>
<Icon className="size-5 text-primary" aria-hidden={true} />
</CardHeader>
</Card>
);
}
function DataTable({
emptyText,
headers,
rows,
title,
}: {
emptyText: string;
headers: string[];
rows: string[][];
title: string;
}): React.ReactElement {
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent className="overflow-x-auto">
<Table className="w-full min-w-[860px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
{headers.map((header) => (
<Table.Head className="px-4 py-3 font-medium" key={header}>
{header}
</Table.Head>
))}
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{rows.map((row, rowIndex) => (
<Table.Row key={row.join("|") || rowIndex}>
{row.map((cell, cellIndex) => (
<Table.Cell className={cellIndex === 0 ? "px-4 py-3 font-medium" : "px-4 py-3 text-muted-foreground"} key={`${cell}-${cellIndex}`}>
{cell}
</Table.Cell>
))}
</Table.Row>
))}
{rows.length === 0 ? (
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={headers.length}>
{emptyText}
</Table.Cell>
</Table.Row>
) : null}
</Table.Body>
</Table>
</CardContent>
</Card>
);
}
function DialogActions({
isPending,
onCancel,
submitLabel,
}: {
isPending: boolean;
onCancel: () => void;
submitLabel: string;
}): React.ReactElement {
return (
<div className="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
<Button disabled={isPending} onClick={onCancel} type="button" variant="outline">
</Button>
<Button disabled={isPending} type="submit">
{submitLabel}
</Button>
</div>
);
}

View File

@@ -0,0 +1,378 @@
import { and, desc, eq, sql } from "drizzle-orm";
import { getDatabase } from "@/modules/core/server/db";
import {
accounts,
chronicConditions,
elders,
healthAnomalyReviews,
healthProfiles,
vitalRecords,
} from "@/modules/core/server/schema";
import type {
ChronicCondition,
ChronicConditionInput,
HealthAdminData,
HealthAnomalyReview,
HealthProfile,
HealthProfileInput,
HealthReviewUpdateInput,
VitalRecord,
VitalRecordInput,
} from "@/modules/health/types";
export type MutationFailure = {
success: false;
reason: string;
status: number;
};
type ElderRow = typeof elders.$inferSelect;
type AccountNameRow = {
id: string;
name: string;
};
export function isMutationFailure(value: unknown): value is MutationFailure {
return (
typeof value === "object" &&
value !== null &&
"success" in value &&
(value as { success?: unknown }).success === false
);
}
function iso(value: Date): string {
return value.toISOString();
}
function optionalIso(value: Date | null): string | undefined {
return value ? iso(value) : undefined;
}
function buildNameMap(rows: AccountNameRow[]): Map<string, string> {
return new Map(rows.map((row) => [row.id, row.name]));
}
function toProfile(row: typeof healthProfiles.$inferSelect, elderName: string): HealthProfile {
return {
id: row.id,
organizationId: row.organizationId,
elderId: row.elderId,
elderName,
allergyNotes: row.allergyNotes,
medicalHistory: row.medicalHistory,
medicationNotes: row.medicationNotes,
careRestrictions: row.careRestrictions,
emergencyNotes: row.emergencyNotes,
createdAt: iso(row.createdAt),
updatedAt: iso(row.updatedAt),
};
}
function toVital(row: typeof vitalRecords.$inferSelect, elderName: string, accountNameById: Map<string, string>): VitalRecord {
return {
id: row.id,
organizationId: row.organizationId,
elderId: row.elderId,
elderName,
recordedAt: iso(row.recordedAt),
source: row.source,
systolicBp: row.systolicBp ?? undefined,
diastolicBp: row.diastolicBp ?? undefined,
heartRate: row.heartRate ?? undefined,
temperatureTenths: row.temperatureTenths ?? undefined,
spo2: row.spo2 ?? undefined,
bloodGlucoseTenths: row.bloodGlucoseTenths ?? undefined,
weightTenths: row.weightTenths ?? undefined,
notes: row.notes,
createdByAccountId: row.createdByAccountId ?? undefined,
createdByName: row.createdByAccountId ? accountNameById.get(row.createdByAccountId) : undefined,
createdAt: iso(row.createdAt),
updatedAt: iso(row.updatedAt),
};
}
function toCondition(row: typeof chronicConditions.$inferSelect, elderName: string): ChronicCondition {
return {
id: row.id,
organizationId: row.organizationId,
elderId: row.elderId,
elderName,
name: row.name,
status: row.status,
diagnosedAt: optionalIso(row.diagnosedAt),
treatmentNotes: row.treatmentNotes,
followUpNotes: row.followUpNotes,
createdAt: iso(row.createdAt),
updatedAt: iso(row.updatedAt),
};
}
function toReview(
row: typeof healthAnomalyReviews.$inferSelect,
elderName: string,
accountNameById: Map<string, string>,
): HealthAnomalyReview {
return {
id: row.id,
organizationId: row.organizationId,
elderId: row.elderId,
elderName,
vitalRecordId: row.vitalRecordId ?? undefined,
severity: row.severity,
status: row.status,
title: row.title,
description: row.description,
reviewedByAccountId: row.reviewedByAccountId ?? undefined,
reviewedByName: row.reviewedByAccountId ? accountNameById.get(row.reviewedByAccountId) : undefined,
reviewedAt: optionalIso(row.reviewedAt),
resolutionNotes: row.resolutionNotes,
createdAt: iso(row.createdAt),
updatedAt: iso(row.updatedAt),
};
}
function getElderName(row: ElderRow | undefined): string {
return row?.name ?? "";
}
async function findElder(organizationId: string, elderId: string): Promise<ElderRow | undefined> {
const database = getDatabase();
const rows = await database
.select()
.from(elders)
.where(and(eq(elders.id, elderId), eq(elders.organizationId, organizationId)))
.limit(1);
return rows[0];
}
function detectVitalAnomaly(input: VitalRecordInput): { title: string; description: string; severity: "warning" | "critical" } | null {
if ((input.systolicBp !== undefined && input.systolicBp >= 180) || (input.diastolicBp !== undefined && input.diastolicBp >= 110)) {
return { title: "血压严重异常", description: "血压达到紧急复核阈值", severity: "critical" };
}
if ((input.systolicBp !== undefined && input.systolicBp >= 150) || (input.diastolicBp !== undefined && input.diastolicBp >= 95)) {
return { title: "血压异常", description: "血压偏高,需要护理人员复核", severity: "warning" };
}
if (input.temperatureTenths !== undefined && input.temperatureTenths >= 380) {
return { title: "体温异常", description: "体温偏高,需要复核", severity: "warning" };
}
if (input.spo2 !== undefined && input.spo2 < 92) {
return { title: "血氧异常", description: "血氧偏低,需要复核", severity: "critical" };
}
return null;
}
export async function listHealthAdminData(organizationId: string): Promise<HealthAdminData> {
const database = getDatabase();
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const [elderRows, profileRows, vitalRows, conditionRows, reviewRows, accountRows] = await Promise.all([
database.select().from(elders).where(eq(elders.organizationId, organizationId)).orderBy(desc(elders.createdAt)),
database.select().from(healthProfiles).where(eq(healthProfiles.organizationId, organizationId)).orderBy(desc(healthProfiles.updatedAt)),
database.select().from(vitalRecords).where(eq(vitalRecords.organizationId, organizationId)).orderBy(desc(vitalRecords.recordedAt)).limit(100),
database
.select()
.from(chronicConditions)
.where(eq(chronicConditions.organizationId, organizationId))
.orderBy(desc(chronicConditions.updatedAt)),
database
.select()
.from(healthAnomalyReviews)
.where(eq(healthAnomalyReviews.organizationId, organizationId))
.orderBy(desc(healthAnomalyReviews.createdAt)),
database.select({ id: accounts.id, name: accounts.name }).from(accounts),
]);
const elderById = new Map(elderRows.map((elder) => [elder.id, elder]));
const accountNameById = buildNameMap(accountRows);
return {
elders: elderRows.map((elder) => ({
id: elder.id,
name: elder.name,
careLevel: elder.careLevel,
status: elder.status,
})),
profiles: profileRows.map((profile) => toProfile(profile, getElderName(elderById.get(profile.elderId)))),
vitals: vitalRows.map((vital) => toVital(vital, getElderName(elderById.get(vital.elderId)), accountNameById)),
chronicConditions: conditionRows.map((condition) => toCondition(condition, getElderName(elderById.get(condition.elderId)))),
reviews: reviewRows.map((review) => toReview(review, getElderName(elderById.get(review.elderId)), accountNameById)),
metrics: {
eldersWithProfiles: profileRows.length,
vitalsToday: vitalRows.filter((vital) => vital.recordedAt >= todayStart).length,
activeChronicConditions: conditionRows.filter((condition) => condition.status === "active").length,
pendingReviews: reviewRows.filter((review) => review.status === "pending").length,
},
};
}
export async function upsertHealthProfile(input: HealthProfileInput & { elderId: string; organizationId: string }): Promise<HealthProfile | MutationFailure> {
const elder = await findElder(input.organizationId, input.elderId);
if (!elder) {
return { success: false, reason: "老人档案不存在", status: 404 };
}
const database = getDatabase();
const rows = await database
.insert(healthProfiles)
.values({
organizationId: input.organizationId,
elderId: input.elderId,
allergyNotes: input.allergyNotes,
medicalHistory: input.medicalHistory,
medicationNotes: input.medicationNotes,
careRestrictions: input.careRestrictions,
emergencyNotes: input.emergencyNotes,
})
.onConflictDoUpdate({
target: [healthProfiles.organizationId, healthProfiles.elderId],
set: {
allergyNotes: input.allergyNotes,
medicalHistory: input.medicalHistory,
medicationNotes: input.medicationNotes,
careRestrictions: input.careRestrictions,
emergencyNotes: input.emergencyNotes,
updatedAt: sql`NOW()`,
},
})
.returning();
const profile = rows[0];
if (!profile) {
return { success: false, reason: "健康档案保存失败", status: 500 };
}
return toProfile(profile, elder.name);
}
export async function createVitalRecord(input: VitalRecordInput & { accountId: string; organizationId: string }): Promise<
| {
vital: VitalRecord;
review?: HealthAnomalyReview;
}
| MutationFailure
> {
const elder = await findElder(input.organizationId, input.elderId);
if (!elder) {
return { success: false, reason: "老人档案不存在", status: 404 };
}
const database = getDatabase();
const created = await database.transaction(async (transaction) => {
const vitalRows = await transaction
.insert(vitalRecords)
.values({
organizationId: input.organizationId,
elderId: input.elderId,
recordedAt: input.recordedAt,
source: input.source,
systolicBp: input.systolicBp,
diastolicBp: input.diastolicBp,
heartRate: input.heartRate,
temperatureTenths: input.temperatureTenths,
spo2: input.spo2,
bloodGlucoseTenths: input.bloodGlucoseTenths,
weightTenths: input.weightTenths,
notes: input.notes,
createdByAccountId: input.accountId,
})
.returning();
const vital = vitalRows[0];
if (!vital) {
return null;
}
const anomaly = detectVitalAnomaly(input);
if (!anomaly) {
return { vital };
}
const reviewRows = await transaction
.insert(healthAnomalyReviews)
.values({
organizationId: input.organizationId,
elderId: input.elderId,
vitalRecordId: vital.id,
severity: anomaly.severity,
status: "pending",
title: anomaly.title,
description: anomaly.description,
})
.returning();
return { vital, review: reviewRows[0] };
});
if (!created) {
return { success: false, reason: "生命体征记录创建失败", status: 500 };
}
const accountRows = await database.select({ id: accounts.id, name: accounts.name }).from(accounts).where(eq(accounts.id, input.accountId));
const accountNameById = buildNameMap(accountRows);
return {
vital: toVital(created.vital, elder.name, accountNameById),
review: created.review ? toReview(created.review, elder.name, accountNameById) : undefined,
};
}
export async function createChronicCondition(
input: ChronicConditionInput & { organizationId: string },
): Promise<ChronicCondition | MutationFailure> {
const elder = await findElder(input.organizationId, input.elderId);
if (!elder) {
return { success: false, reason: "老人档案不存在", status: 404 };
}
const database = getDatabase();
const rows = await database
.insert(chronicConditions)
.values({
organizationId: input.organizationId,
elderId: input.elderId,
name: input.name,
status: input.status,
diagnosedAt: input.diagnosedAt,
treatmentNotes: input.treatmentNotes,
followUpNotes: input.followUpNotes,
})
.returning();
const condition = rows[0];
if (!condition) {
return { success: false, reason: "慢病记录创建失败", status: 500 };
}
return toCondition(condition, elder.name);
}
export async function updateHealthReview(input: HealthReviewUpdateInput & { accountId: string; id: string; organizationId: string }): Promise<
HealthAnomalyReview | MutationFailure
> {
const database = getDatabase();
const rows = await database
.update(healthAnomalyReviews)
.set({
status: input.status,
resolutionNotes: input.resolutionNotes,
reviewedByAccountId: input.accountId,
reviewedAt: new Date(),
updatedAt: new Date(),
})
.where(and(eq(healthAnomalyReviews.id, input.id), eq(healthAnomalyReviews.organizationId, input.organizationId)))
.returning();
const review = rows[0];
if (!review) {
return { success: false, reason: "异常复核记录不存在", status: 404 };
}
const [elderRows, accountRows] = await Promise.all([
database.select().from(elders).where(eq(elders.id, review.elderId)).limit(1),
database.select({ id: accounts.id, name: accounts.name }).from(accounts).where(eq(accounts.id, input.accountId)),
]);
return toReview(review, getElderName(elderRows[0]), buildNameMap(accountRows));
}

332
modules/health/types.ts Normal file
View File

@@ -0,0 +1,332 @@
export const VITAL_SOURCE_VALUES = ["manual", "device", "import"] as const;
export type VitalSource = (typeof VITAL_SOURCE_VALUES)[number];
export const CHRONIC_CONDITION_STATUS_VALUES = ["active", "controlled", "resolved"] as const;
export type ChronicConditionStatus = (typeof CHRONIC_CONDITION_STATUS_VALUES)[number];
export const HEALTH_REVIEW_STATUS_VALUES = ["pending", "reviewed", "resolved"] as const;
export type HealthReviewStatus = (typeof HEALTH_REVIEW_STATUS_VALUES)[number];
export const HEALTH_REVIEW_SEVERITY_VALUES = ["info", "warning", "critical"] as const;
export type HealthReviewSeverity = (typeof HEALTH_REVIEW_SEVERITY_VALUES)[number];
export const VITAL_SOURCE_LABELS: Record<VitalSource, string> = {
manual: "手工录入",
device: "设备采集",
import: "批量导入",
};
export const CHRONIC_CONDITION_STATUS_LABELS: Record<ChronicConditionStatus, string> = {
active: "持续管理",
controlled: "控制稳定",
resolved: "已结束",
};
export const HEALTH_REVIEW_STATUS_LABELS: Record<HealthReviewStatus, string> = {
pending: "待复核",
reviewed: "已复核",
resolved: "已处理",
};
export const HEALTH_REVIEW_SEVERITY_LABELS: Record<HealthReviewSeverity, string> = {
info: "提示",
warning: "预警",
critical: "紧急",
};
export type HealthElderOption = {
id: string;
name: string;
careLevel: string;
status: string;
};
export type HealthProfile = {
id: string;
organizationId: string;
elderId: string;
elderName: string;
allergyNotes: string;
medicalHistory: string;
medicationNotes: string;
careRestrictions: string;
emergencyNotes: string;
createdAt: string;
updatedAt: string;
};
export type VitalRecord = {
id: string;
organizationId: string;
elderId: string;
elderName: string;
recordedAt: string;
source: VitalSource;
systolicBp?: number;
diastolicBp?: number;
heartRate?: number;
temperatureTenths?: number;
spo2?: number;
bloodGlucoseTenths?: number;
weightTenths?: number;
notes: string;
createdByAccountId?: string;
createdByName?: string;
createdAt: string;
updatedAt: string;
};
export type ChronicCondition = {
id: string;
organizationId: string;
elderId: string;
elderName: string;
name: string;
status: ChronicConditionStatus;
diagnosedAt?: string;
treatmentNotes: string;
followUpNotes: string;
createdAt: string;
updatedAt: string;
};
export type HealthAnomalyReview = {
id: string;
organizationId: string;
elderId: string;
elderName: string;
vitalRecordId?: string;
severity: HealthReviewSeverity;
status: HealthReviewStatus;
title: string;
description: string;
reviewedByAccountId?: string;
reviewedByName?: string;
reviewedAt?: string;
resolutionNotes: string;
createdAt: string;
updatedAt: string;
};
export type HealthAdminMetrics = {
eldersWithProfiles: number;
vitalsToday: number;
activeChronicConditions: number;
pendingReviews: number;
};
export type HealthAdminData = {
elders: HealthElderOption[];
profiles: HealthProfile[];
vitals: VitalRecord[];
chronicConditions: ChronicCondition[];
reviews: HealthAnomalyReview[];
metrics: HealthAdminMetrics;
};
export type HealthProfileInput = {
allergyNotes: string;
medicalHistory: string;
medicationNotes: string;
careRestrictions: string;
emergencyNotes: string;
};
export type VitalRecordInput = {
elderId: string;
recordedAt: Date;
source: VitalSource;
systolicBp?: number;
diastolicBp?: number;
heartRate?: number;
temperatureTenths?: number;
spo2?: number;
bloodGlucoseTenths?: number;
weightTenths?: number;
notes: string;
};
export type ChronicConditionInput = {
elderId: string;
name: string;
status: ChronicConditionStatus;
diagnosedAt?: Date;
treatmentNotes: string;
followUpNotes: string;
};
export type HealthReviewUpdateInput = {
status: HealthReviewStatus;
resolutionNotes: string;
};
type ValidationSuccess<T> = {
success: true;
data: T;
};
type ValidationFailure = {
success: false;
reason: string;
};
export type ValidationResult<T> = ValidationSuccess<T> | ValidationFailure;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readString(source: Record<string, unknown>, key: string): string {
const value = source[key];
return typeof value === "string" ? value.trim() : "";
}
function readOptionalInteger(source: Record<string, unknown>, key: string): number | undefined | null {
const value = source[key];
if (value === undefined || value === null || value === "") {
return undefined;
}
const numeric = typeof value === "number" ? value : Number(value);
return Number.isInteger(numeric) ? numeric : null;
}
function readEnum<T extends string>(value: unknown, values: readonly T[]): T | null {
return typeof value === "string" ? values.find((item) => item === value) ?? null : null;
}
function readOptionalDate(source: Record<string, unknown>, key: string): Date | undefined | null {
const value = source[key];
if (value === undefined || value === null || value === "") {
return undefined;
}
if (typeof value !== "string") {
return null;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
export function validateHealthProfileInput(value: unknown): ValidationResult<HealthProfileInput> {
if (!isRecord(value)) {
return { success: false, reason: "请求数据格式无效" };
}
return {
success: true,
data: {
allergyNotes: readString(value, "allergyNotes"),
medicalHistory: readString(value, "medicalHistory"),
medicationNotes: readString(value, "medicationNotes"),
careRestrictions: readString(value, "careRestrictions"),
emergencyNotes: readString(value, "emergencyNotes"),
},
};
}
export function validateVitalRecordInput(value: unknown): ValidationResult<VitalRecordInput> {
if (!isRecord(value)) {
return { success: false, reason: "请求数据格式无效" };
}
const elderId = readString(value, "elderId");
if (!elderId) {
return { success: false, reason: "老人不能为空" };
}
const recordedAt = readOptionalDate(value, "recordedAt");
if (!recordedAt) {
return { success: false, reason: "记录时间无效" };
}
const source = readEnum(value.source, VITAL_SOURCE_VALUES);
if (!source) {
return { success: false, reason: "记录来源无效" };
}
const integerKeys = [
"systolicBp",
"diastolicBp",
"heartRate",
"temperatureTenths",
"spo2",
"bloodGlucoseTenths",
"weightTenths",
] as const;
const parsed: Partial<Record<(typeof integerKeys)[number], number>> = {};
for (const key of integerKeys) {
const numberValue = readOptionalInteger(value, key);
if (numberValue === null) {
return { success: false, reason: "生命体征数值需为整数" };
}
if (numberValue !== undefined) {
parsed[key] = numberValue;
}
}
return {
success: true,
data: {
elderId,
recordedAt,
source,
notes: readString(value, "notes"),
...parsed,
},
};
}
export function validateChronicConditionInput(value: unknown): ValidationResult<ChronicConditionInput> {
if (!isRecord(value)) {
return { success: false, reason: "请求数据格式无效" };
}
const elderId = readString(value, "elderId");
const name = readString(value, "name");
if (!elderId || !name) {
return { success: false, reason: "老人和慢病名称不能为空" };
}
const status = readEnum(value.status, CHRONIC_CONDITION_STATUS_VALUES);
if (!status) {
return { success: false, reason: "慢病状态无效" };
}
const diagnosedAt = readOptionalDate(value, "diagnosedAt");
if (diagnosedAt === null) {
return { success: false, reason: "诊断日期无效" };
}
return {
success: true,
data: {
elderId,
name,
status,
diagnosedAt,
treatmentNotes: readString(value, "treatmentNotes"),
followUpNotes: readString(value, "followUpNotes"),
},
};
}
export function validateHealthReviewUpdateInput(value: unknown): ValidationResult<HealthReviewUpdateInput> {
if (!isRecord(value)) {
return { success: false, reason: "请求数据格式无效" };
}
const status = readEnum(value.status, HEALTH_REVIEW_STATUS_VALUES);
if (!status) {
return { success: false, reason: "复核状态无效" };
}
return {
success: true,
data: {
status,
resolutionNotes: readString(value, "resolutionNotes"),
},
};
}

View File

@@ -26,6 +26,7 @@ const routeLabels: Record<string, string> = {
"/settings/organizations": "机构管理", "/settings/organizations": "机构管理",
"/settings/users": "用户管理", "/settings/users": "用户管理",
"/settings/roles": "角色权限", "/settings/roles": "角色权限",
"/settings/health": "健康数据管理",
"/settings/audit": "审计日志", "/settings/audit": "审计日志",
"/settings/status": "运行状态", "/settings/status": "运行状态",
}; };

View File

@@ -125,6 +125,12 @@ export const navGroups: NavGroup[] = [
icon: "roles", icon: "roles",
permission: "role:read", permission: "role:read",
}, },
{
path: "/settings/health",
label: "健康数据管理",
icon: "health",
permission: "health:read",
},
{ {
path: "/settings/audit", path: "/settings/audit",
label: "审计日志", label: "审计日志",

View File

@@ -6,6 +6,7 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"test": "vitest run",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
@@ -39,6 +40,7 @@
"eslint-config-next": "^15.0.0", "eslint-config-next": "^15.0.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"typescript-eslint": "^8.0.0" "typescript-eslint": "^8.0.0",
"vitest": "^4.1.9"
} }
} }

497
pnpm-lock.yaml generated
View File

@@ -84,6 +84,9 @@ importers:
typescript-eslint: typescript-eslint:
specifier: ^8.0.0 specifier: ^8.0.0
version: 8.62.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) version: 8.62.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
vitest:
specifier: ^4.1.9
version: 4.1.9(@types/node@22.20.0)(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4))
packages: packages:
@@ -146,6 +149,9 @@ packages:
'@emnapi/core@1.10.0': '@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
'@emnapi/core@1.11.1':
resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==}
'@emnapi/runtime@1.10.0': '@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
@@ -155,6 +161,9 @@ packages:
'@emnapi/wasi-threads@1.2.1': '@emnapi/wasi-threads@1.2.1':
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
'@emnapi/wasi-threads@1.2.2':
resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
'@esbuild-kit/core-utils@3.3.2': '@esbuild-kit/core-utils@3.3.2':
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
deprecated: 'Merged into tsx: https://tsx.is' deprecated: 'Merged into tsx: https://tsx.is'
@@ -909,6 +918,9 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'} engines: {node: '>=12.4.0'}
'@oxc-project/types@0.138.0':
resolution: {integrity: sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==}
'@phosphor-icons/react@2.1.10': '@phosphor-icons/react@2.1.10':
resolution: {integrity: sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==} resolution: {integrity: sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -945,6 +957,98 @@ packages:
react-redux: react-redux:
optional: true optional: true
'@rolldown/binding-android-arm64@1.1.4':
resolution: {integrity: sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@rolldown/binding-darwin-arm64@1.1.4':
resolution: {integrity: sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@rolldown/binding-darwin-x64@1.1.4':
resolution: {integrity: sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@rolldown/binding-freebsd-x64@1.1.4':
resolution: {integrity: sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@rolldown/binding-linux-arm-gnueabihf@1.1.4':
resolution: {integrity: sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@rolldown/binding-linux-arm64-gnu@1.1.4':
resolution: {integrity: sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
'@rolldown/binding-linux-arm64-musl@1.1.4':
resolution: {integrity: sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
'@rolldown/binding-linux-ppc64-gnu@1.1.4':
resolution: {integrity: sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
'@rolldown/binding-linux-s390x-gnu@1.1.4':
resolution: {integrity: sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
'@rolldown/binding-linux-x64-gnu@1.1.4':
resolution: {integrity: sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
'@rolldown/binding-linux-x64-musl@1.1.4':
resolution: {integrity: sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
'@rolldown/binding-openharmony-arm64@1.1.4':
resolution: {integrity: sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@rolldown/binding-wasm32-wasi@1.1.4':
resolution: {integrity: sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [wasm32]
'@rolldown/binding-win32-arm64-msvc@1.1.4':
resolution: {integrity: sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@rolldown/binding-win32-x64-msvc@1.1.4':
resolution: {integrity: sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@rtsao/scc@1.1.0': '@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
@@ -1086,6 +1190,9 @@ packages:
'@tybys/wasm-util@0.10.3': '@tybys/wasm-util@0.10.3':
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/d3-array@3.2.2': '@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
@@ -1113,6 +1220,9 @@ packages:
'@types/d3-timer@3.0.2': '@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree@1.0.9': '@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
@@ -1317,6 +1427,35 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@vitest/expect@4.1.9':
resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==}
'@vitest/mocker@4.1.9':
resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@4.1.9':
resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==}
'@vitest/runner@4.1.9':
resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==}
'@vitest/snapshot@4.1.9':
resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==}
'@vitest/spy@4.1.9':
resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==}
'@vitest/utils@4.1.9':
resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==}
acorn-jsx@5.3.2: acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies: peerDependencies:
@@ -1373,6 +1512,10 @@ packages:
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
ast-types-flow@0.0.8: ast-types-flow@0.0.8:
resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
@@ -1435,6 +1578,10 @@ packages:
ccount@2.0.1: ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
chalk@4.1.2: chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1468,6 +1615,9 @@ packages:
concat-map@0.0.1: concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -1713,6 +1863,9 @@ packages:
resolution: {integrity: sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==} resolution: {integrity: sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
es-module-lexer@2.3.0:
resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==}
es-object-atoms@1.1.2: es-object-atoms@1.1.2:
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1867,6 +2020,9 @@ packages:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'} engines: {node: '>=4.0'}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
esutils@2.0.3: esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -1874,6 +2030,10 @@ packages:
eventemitter3@5.0.4: eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
expect-type@1.4.0:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'}
fast-deep-equal@3.1.3: fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -2437,6 +2597,10 @@ packages:
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
obug@2.1.3:
resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
engines: {node: '>=12.20.0'}
oniguruma-parser@0.12.2: oniguruma-parser@0.12.2:
resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==}
@@ -2474,6 +2638,9 @@ packages:
path-parse@1.0.7: path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -2600,6 +2767,11 @@ packages:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'} engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
rolldown@1.1.4:
resolution: {integrity: sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
run-parallel@1.2.0: run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -2671,6 +2843,9 @@ packages:
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
source-map-js@1.2.1: source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2688,6 +2863,12 @@ packages:
stable-hash@0.0.5: stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
stop-iteration-iterator@1.1.0: stop-iteration-iterator@1.1.0:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2760,10 +2941,21 @@ packages:
tiny-invariant@1.3.3: tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@1.2.4:
resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
engines: {node: '>=18'}
tinyglobby@0.2.17: tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
tinyrainbow@3.1.0:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
to-regex-range@5.0.1: to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'} engines: {node: '>=8.0'}
@@ -2862,6 +3054,90 @@ packages:
victory-vendor@37.3.6: victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
vite@8.1.3:
resolution: {integrity: sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
'@vitejs/devtools': ^0.3.0
esbuild: ^0.27.0 || ^0.28.0
jiti: '>=1.21.0'
less: ^4.0.0
sass: ^1.70.0
sass-embedded: ^1.70.0
stylus: '>=0.54.8'
sugarss: ^5.0.0
terser: ^5.16.0
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
'@types/node':
optional: true
'@vitejs/devtools':
optional: true
esbuild:
optional: true
jiti:
optional: true
less:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
tsx:
optional: true
yaml:
optional: true
vitest@4.1.9:
resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 4.1.9
'@vitest/browser-preview': 4.1.9
'@vitest/browser-webdriverio': 4.1.9
'@vitest/coverage-istanbul': 4.1.9
'@vitest/coverage-v8': 4.1.9
'@vitest/ui': 4.1.9
happy-dom: '*'
jsdom: '*'
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@opentelemetry/api':
optional: true
'@types/node':
optional: true
'@vitest/browser-playwright':
optional: true
'@vitest/browser-preview':
optional: true
'@vitest/browser-webdriverio':
optional: true
'@vitest/coverage-istanbul':
optional: true
'@vitest/coverage-v8':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
which-boxed-primitive@1.1.1: which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2883,6 +3159,11 @@ packages:
engines: {node: '>= 8'} engines: {node: '>= 8'}
hasBin: true hasBin: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
word-wrap@1.2.5: word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2954,6 +3235,12 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@emnapi/core@1.11.1':
dependencies:
'@emnapi/wasi-threads': 1.2.2
tslib: 2.8.1
optional: true
'@emnapi/runtime@1.10.0': '@emnapi/runtime@1.10.0':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -2969,6 +3256,11 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@emnapi/wasi-threads@1.2.2':
dependencies:
tslib: 2.8.1
optional: true
'@esbuild-kit/core-utils@3.3.2': '@esbuild-kit/core-utils@3.3.2':
dependencies: dependencies:
esbuild: 0.18.20 esbuild: 0.18.20
@@ -3403,6 +3695,13 @@ snapshots:
'@tybys/wasm-util': 0.10.3 '@tybys/wasm-util': 0.10.3
optional: true optional: true
'@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
dependencies:
'@emnapi/core': 1.11.1
'@emnapi/runtime': 1.11.1
'@tybys/wasm-util': 0.10.3
optional: true
'@next/env@15.5.19': {} '@next/env@15.5.19': {}
'@next/eslint-plugin-next@15.5.19': '@next/eslint-plugin-next@15.5.19':
@@ -3447,6 +3746,8 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {} '@nolyfill/is-core-module@1.0.39': {}
'@oxc-project/types@0.138.0': {}
'@phosphor-icons/react@2.1.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': '@phosphor-icons/react@2.1.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies: dependencies:
react: 19.2.7 react: 19.2.7
@@ -3477,6 +3778,57 @@ snapshots:
react: 19.2.7 react: 19.2.7
react-redux: 9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1) react-redux: 9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1)
'@rolldown/binding-android-arm64@1.1.4':
optional: true
'@rolldown/binding-darwin-arm64@1.1.4':
optional: true
'@rolldown/binding-darwin-x64@1.1.4':
optional: true
'@rolldown/binding-freebsd-x64@1.1.4':
optional: true
'@rolldown/binding-linux-arm-gnueabihf@1.1.4':
optional: true
'@rolldown/binding-linux-arm64-gnu@1.1.4':
optional: true
'@rolldown/binding-linux-arm64-musl@1.1.4':
optional: true
'@rolldown/binding-linux-ppc64-gnu@1.1.4':
optional: true
'@rolldown/binding-linux-s390x-gnu@1.1.4':
optional: true
'@rolldown/binding-linux-x64-gnu@1.1.4':
optional: true
'@rolldown/binding-linux-x64-musl@1.1.4':
optional: true
'@rolldown/binding-openharmony-arm64@1.1.4':
optional: true
'@rolldown/binding-wasm32-wasi@1.1.4':
dependencies:
'@emnapi/core': 1.11.1
'@emnapi/runtime': 1.11.1
'@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)
optional: true
'@rolldown/binding-win32-arm64-msvc@1.1.4':
optional: true
'@rolldown/binding-win32-x64-msvc@1.1.4':
optional: true
'@rolldown/pluginutils@1.0.1': {}
'@rtsao/scc@1.1.0': {} '@rtsao/scc@1.1.0': {}
'@rushstack/eslint-patch@1.16.1': {} '@rushstack/eslint-patch@1.16.1': {}
@@ -3605,6 +3957,11 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/d3-array@3.2.2': {} '@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {} '@types/d3-color@3.1.3': {}
@@ -3629,6 +3986,8 @@ snapshots:
'@types/d3-timer@3.0.2': {} '@types/d3-timer@3.0.2': {}
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.9': {} '@types/estree@1.0.9': {}
'@types/hast@3.0.4': '@types/hast@3.0.4':
@@ -3822,6 +4181,47 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.12.2': '@unrs/resolver-binding-win32-x64-msvc@1.12.2':
optional: true optional: true
'@vitest/expect@4.1.9':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
'@vitest/spy': 4.1.9
'@vitest/utils': 4.1.9
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.9(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4))':
dependencies:
'@vitest/spy': 4.1.9
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)
'@vitest/pretty-format@4.1.9':
dependencies:
tinyrainbow: 3.1.0
'@vitest/runner@4.1.9':
dependencies:
'@vitest/utils': 4.1.9
pathe: 2.0.3
'@vitest/snapshot@4.1.9':
dependencies:
'@vitest/pretty-format': 4.1.9
'@vitest/utils': 4.1.9
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@4.1.9': {}
'@vitest/utils@4.1.9':
dependencies:
'@vitest/pretty-format': 4.1.9
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
acorn-jsx@5.3.2(acorn@8.17.0): acorn-jsx@5.3.2(acorn@8.17.0):
dependencies: dependencies:
acorn: 8.17.0 acorn: 8.17.0
@@ -3910,6 +4310,8 @@ snapshots:
get-intrinsic: 1.3.0 get-intrinsic: 1.3.0
is-array-buffer: 3.0.5 is-array-buffer: 3.0.5
assertion-error@2.0.1: {}
ast-types-flow@0.0.8: {} ast-types-flow@0.0.8: {}
async-function@1.0.0: {} async-function@1.0.0: {}
@@ -3964,6 +4366,8 @@ snapshots:
ccount@2.0.1: {} ccount@2.0.1: {}
chai@6.2.2: {}
chalk@4.1.2: chalk@4.1.2:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
@@ -3991,6 +4395,8 @@ snapshots:
concat-map@0.0.1: {} concat-map@0.0.1: {}
convert-source-map@2.0.0: {}
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -4208,6 +4614,8 @@ snapshots:
iterator.prototype: 1.1.5 iterator.prototype: 1.1.5
math-intrinsics: 1.1.0 math-intrinsics: 1.1.0
es-module-lexer@2.3.0: {}
es-object-atoms@1.1.2: es-object-atoms@1.1.2:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
@@ -4515,10 +4923,16 @@ snapshots:
estraverse@5.3.0: {} estraverse@5.3.0: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.9
esutils@2.0.3: {} esutils@2.0.3: {}
eventemitter3@5.0.4: {} eventemitter3@5.0.4: {}
expect-type@1.4.0: {}
fast-deep-equal@3.1.3: {} fast-deep-equal@3.1.3: {}
fast-glob@3.3.1: fast-glob@3.3.1:
@@ -5083,6 +5497,8 @@ snapshots:
define-properties: 1.2.1 define-properties: 1.2.1
es-object-atoms: 1.1.2 es-object-atoms: 1.1.2
obug@2.1.3: {}
oniguruma-parser@0.12.2: {} oniguruma-parser@0.12.2: {}
oniguruma-to-es@4.3.6: oniguruma-to-es@4.3.6:
@@ -5124,6 +5540,8 @@ snapshots:
path-parse@1.0.7: {} path-parse@1.0.7: {}
pathe@2.0.3: {}
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@2.3.2: {} picomatch@2.3.2: {}
@@ -5259,6 +5677,27 @@ snapshots:
reusify@1.1.0: {} reusify@1.1.0: {}
rolldown@1.1.4:
dependencies:
'@oxc-project/types': 0.138.0
'@rolldown/pluginutils': 1.0.1
optionalDependencies:
'@rolldown/binding-android-arm64': 1.1.4
'@rolldown/binding-darwin-arm64': 1.1.4
'@rolldown/binding-darwin-x64': 1.1.4
'@rolldown/binding-freebsd-x64': 1.1.4
'@rolldown/binding-linux-arm-gnueabihf': 1.1.4
'@rolldown/binding-linux-arm64-gnu': 1.1.4
'@rolldown/binding-linux-arm64-musl': 1.1.4
'@rolldown/binding-linux-ppc64-gnu': 1.1.4
'@rolldown/binding-linux-s390x-gnu': 1.1.4
'@rolldown/binding-linux-x64-gnu': 1.1.4
'@rolldown/binding-linux-x64-musl': 1.1.4
'@rolldown/binding-openharmony-arm64': 1.1.4
'@rolldown/binding-wasm32-wasi': 1.1.4
'@rolldown/binding-win32-arm64-msvc': 1.1.4
'@rolldown/binding-win32-x64-msvc': 1.1.4
run-parallel@1.2.0: run-parallel@1.2.0:
dependencies: dependencies:
queue-microtask: 1.2.3 queue-microtask: 1.2.3
@@ -5387,6 +5826,8 @@ snapshots:
side-channel-map: 1.0.1 side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2 side-channel-weakmap: 1.0.2
siginfo@2.0.0: {}
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
source-map-support@0.5.21: source-map-support@0.5.21:
@@ -5400,6 +5841,10 @@ snapshots:
stable-hash@0.0.5: {} stable-hash@0.0.5: {}
stackback@0.0.2: {}
std-env@4.1.0: {}
stop-iteration-iterator@1.1.0: stop-iteration-iterator@1.1.0:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
@@ -5484,11 +5929,17 @@ snapshots:
tiny-invariant@1.3.3: {} tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
tinyexec@1.2.4: {}
tinyglobby@0.2.17: tinyglobby@0.2.17:
dependencies: dependencies:
fdir: 6.5.0(picomatch@4.0.4) fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4 picomatch: 4.0.4
tinyrainbow@3.1.0: {}
to-regex-range@5.0.1: to-regex-range@5.0.1:
dependencies: dependencies:
is-number: 7.0.0 is-number: 7.0.0
@@ -5658,6 +6109,47 @@ snapshots:
d3-time: 3.1.0 d3-time: 3.1.0
d3-timer: 3.0.1 d3-timer: 3.0.1
vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
postcss: 8.5.16
rolldown: 1.1.4
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 22.20.0
esbuild: 0.28.1
fsevents: 2.3.3
jiti: 2.7.0
tsx: 4.22.4
vitest@4.1.9(@types/node@22.20.0)(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)):
dependencies:
'@vitest/expect': 4.1.9
'@vitest/mocker': 4.1.9(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4))
'@vitest/pretty-format': 4.1.9
'@vitest/runner': 4.1.9
'@vitest/snapshot': 4.1.9
'@vitest/spy': 4.1.9
'@vitest/utils': 4.1.9
es-module-lexer: 2.3.0
expect-type: 1.4.0
magic-string: 0.30.21
obug: 2.1.3
pathe: 2.0.3
picomatch: 4.0.4
std-env: 4.1.0
tinybench: 2.9.0
tinyexec: 1.2.4
tinyglobby: 0.2.17
tinyrainbow: 3.1.0
vite: 8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.20.0
transitivePeerDependencies:
- msw
which-boxed-primitive@1.1.1: which-boxed-primitive@1.1.1:
dependencies: dependencies:
is-bigint: 1.1.0 is-bigint: 1.1.0
@@ -5703,6 +6195,11 @@ snapshots:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
word-wrap@1.2.5: {} word-wrap@1.2.5: {}
yocto-queue@0.1.0: {} yocto-queue@0.1.0: {}

15
vitest.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import path from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "."),
},
},
test: {
environment: "node",
include: ["**/*.test.ts"],
},
});