feat: add health data management workspace
This commit is contained in:
@@ -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)));
|
||||||
|
```
|
||||||
|
|||||||
@@ -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."}
|
||||||
@@ -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."}
|
||||||
76
.trellis/tasks/07-02-care-execution-workspace/prd.md
Normal file
76
.trellis/tasks/07-02-care-execution-workspace/prd.md
Normal 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.
|
||||||
26
.trellis/tasks/07-02-care-execution-workspace/task.json
Normal file
26
.trellis/tasks/07-02-care-execution-workspace/task.json
Normal 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": {}
|
||||||
|
}
|
||||||
1
.trellis/tasks/07-02-care-service-workspace/check.jsonl
Normal file
1
.trellis/tasks/07-02-care-service-workspace/check.jsonl
Normal 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."}
|
||||||
191
.trellis/tasks/07-02-care-service-workspace/design.md
Normal file
191
.trellis/tasks/07-02-care-service-workspace/design.md
Normal 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`
|
||||||
@@ -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."}
|
||||||
74
.trellis/tasks/07-02-care-service-workspace/implement.md
Normal file
74
.trellis/tasks/07-02-care-service-workspace/implement.md
Normal 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.
|
||||||
117
.trellis/tasks/07-02-care-service-workspace/prd.md
Normal file
117
.trellis/tasks/07-02-care-service-workspace/prd.md
Normal 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.
|
||||||
26
.trellis/tasks/07-02-care-service-workspace/task.json
Normal file
26
.trellis/tasks/07-02-care-service-workspace/task.json
Normal 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": {}
|
||||||
|
}
|
||||||
@@ -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."}
|
||||||
@@ -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."}
|
||||||
55
.trellis/tasks/07-02-elder-bed-context-optimization/prd.md
Normal file
55
.trellis/tasks/07-02-elder-bed-context-optimization/prd.md
Normal 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.
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -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."}
|
||||||
@@ -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."}
|
||||||
65
.trellis/tasks/07-02-emergency-incident-workspace/prd.md
Normal file
65
.trellis/tasks/07-02-emergency-incident-workspace/prd.md
Normal 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.
|
||||||
26
.trellis/tasks/07-02-emergency-incident-workspace/task.json
Normal file
26
.trellis/tasks/07-02-emergency-incident-workspace/task.json
Normal 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": {}
|
||||||
|
}
|
||||||
@@ -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."}
|
||||||
@@ -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."}
|
||||||
57
.trellis/tasks/07-02-operations-dashboard-integration/prd.md
Normal file
57
.trellis/tasks/07-02-operations-dashboard-integration/prd.md
Normal 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.
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -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."}
|
||||||
47
.trellis/tasks/07-02-operations-module-roadmap/design.md
Normal file
47
.trellis/tasks/07-02-operations-module-roadmap/design.md
Normal 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.
|
||||||
@@ -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."}
|
||||||
42
.trellis/tasks/07-02-operations-module-roadmap/implement.md
Normal file
42
.trellis/tasks/07-02-operations-module-roadmap/implement.md
Normal 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.
|
||||||
106
.trellis/tasks/07-02-operations-module-roadmap/prd.md
Normal file
106
.trellis/tasks/07-02-operations-module-roadmap/prd.md
Normal 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.
|
||||||
32
.trellis/tasks/07-02-operations-module-roadmap/task.json
Normal file
32
.trellis/tasks/07-02-operations-module-roadmap/task.json
Normal 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": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import HealthSettingsPage from "../../../settings/health/page";
|
||||||
|
|
||||||
|
export default async function ScopedHealthSettingsPage(): Promise<React.ReactElement> {
|
||||||
|
return HealthSettingsPage();
|
||||||
|
}
|
||||||
27
app/(app)/app/settings/health/page.tsx
Normal file
27
app/(app)/app/settings/health/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
22
app/api/health/admin/route.ts
Normal file
22
app/api/health/admin/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
43
app/api/health/chronic-conditions/route.ts
Normal file
43
app/api/health/chronic-conditions/route.ts
Normal 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);
|
||||||
|
}
|
||||||
372
app/api/health/health-routes.test.ts
Normal file
372
app/api/health/health-routes.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
51
app/api/health/profiles/[elderId]/route.ts
Normal file
51
app/api/health/profiles/[elderId]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
56
app/api/health/reviews/[id]/route.ts
Normal file
56
app/api/health/reviews/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
43
app/api/health/vitals/route.ts
Normal file
43
app/api/health/vitals/route.ts
Normal 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);
|
||||||
|
}
|
||||||
79
drizzle/0003_gray_dust.sql
Normal file
79
drizzle/0003_gray_dust.sql
Normal 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");
|
||||||
2752
drizzle/meta/0003_snapshot.json
Normal file
2752
drizzle/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
677
modules/core/server/default-workspace-data.ts
Normal file
677
modules/core/server/default-workspace-data.ts
Normal 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: "默认工作区初始化",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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" }),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
822
modules/health/components/HealthAdminClient.tsx
Normal file
822
modules/health/components/HealthAdminClient.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
378
modules/health/server/operations.ts
Normal file
378
modules/health/server/operations.ts
Normal 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
332
modules/health/types.ts
Normal 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"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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": "运行状态",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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: "审计日志",
|
||||||
|
|||||||
@@ -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
497
pnpm-lock.yaml
generated
@@ -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
15
vitest.config.ts
Normal 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"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user