Compare commits
46 Commits
4bb4312b08
...
0bc296a8ee
| Author | SHA1 | Date | |
|---|---|---|---|
| 0bc296a8ee | |||
| 8af60c2d2a | |||
| d2404616bc | |||
| b5e2aa6df1 | |||
| 2095e9d804 | |||
| b7f64ad3da | |||
| 4792aa5a77 | |||
| 9d73457f79 | |||
| 3d55975975 | |||
| 4a9a7351e3 | |||
| 0ce8cb9644 | |||
| f620d6c3dd | |||
| fe9db61bbb | |||
| a0e50a9e83 | |||
| 63b90394ad | |||
| dddabc706c | |||
| b6b15acc69 | |||
| df7c2efcc5 | |||
| 20f68303e2 | |||
| 41807ff557 | |||
| dc034b6d85 | |||
| 1fcbddbf39 | |||
| 3ab0e3e034 | |||
| fae97a7046 | |||
| affad1f59d | |||
| 5412bbb143 | |||
| 1cdf89c608 | |||
| 1b8fae0116 | |||
| 5fe7e5a1b8 | |||
| 8b2fb0930e | |||
| 6efe3a6615 | |||
| 12c3ca560c | |||
| a8e8bb2bc1 | |||
| 0b4ed0c0f6 | |||
| 7718d9759c | |||
| fd548eaa7b | |||
| ac06490bb9 | |||
| 381233c675 | |||
| c181e6fbdd | |||
| 9480030391 | |||
| c394d85236 | |||
| 04f2dfc229 | |||
| 22e61f5efe | |||
| 89a59956ac | |||
| 67fcb8fabd | |||
| 0f0bd8813d |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -1,8 +1,18 @@
|
|||||||
.data/
|
.data/
|
||||||
|
.env*.local
|
||||||
.next/
|
.next/
|
||||||
node_modules/
|
node_modules/
|
||||||
tsconfig.tsbuildinfo
|
tsconfig.tsbuildinfo
|
||||||
|
|
||||||
|
output/
|
||||||
|
tmp/
|
||||||
|
auth-*-snapshot.md
|
||||||
|
auth-after-*.md
|
||||||
|
dashboard-*.md
|
||||||
|
teatea-dashboard-*.png
|
||||||
|
teatea-mobile-snapshot.md
|
||||||
|
register-select-after.png
|
||||||
|
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,70 @@ This document covers backend authentication integration using better-auth, inclu
|
|||||||
|
|
||||||
## 1. Overview
|
## 1. Overview
|
||||||
|
|
||||||
|
### Current Project Auth Contract: Session Organization and Profile APIs
|
||||||
|
|
||||||
|
#### 1. Scope / Trigger
|
||||||
|
- Trigger: app shell and account settings need authenticated current-tenant switching and current-account profile updates.
|
||||||
|
- This repository currently uses the custom `teatea_session` HTTP-only cookie plus Drizzle tables (`sessions`, `accounts`, `organizations`, `memberships`, `roles`), not a better-auth runtime.
|
||||||
|
|
||||||
|
#### 2. Signatures
|
||||||
|
- `GET /api/auth/session`: returns current account, active organization, available organization options, membership, and permissions.
|
||||||
|
- `POST /api/auth/organization`: switches `sessions.activeOrganizationId` for the current session.
|
||||||
|
- `PATCH /api/account/profile`: updates the authenticated account's `name` and `avatarUrl`.
|
||||||
|
|
||||||
|
#### 3. Contracts
|
||||||
|
- `GET /api/auth/session` response payload:
|
||||||
|
- `account: PublicAccount | null`
|
||||||
|
- `organization: Organization | null`
|
||||||
|
- `organizations: AccountOrganizationOption[]`
|
||||||
|
- `membership: Membership | null`
|
||||||
|
- `permissions: Permission[]`
|
||||||
|
- `POST /api/auth/organization` request payload: `{ organizationId: string }`.
|
||||||
|
- `POST /api/auth/organization` success response payload includes:
|
||||||
|
- `organization: Organization | null`
|
||||||
|
- `organizations: AccountOrganizationOption[]`
|
||||||
|
- `permissions: Permission[]`
|
||||||
|
- `PATCH /api/account/profile` request payload: `{ name: string; avatarUrl: string }`.
|
||||||
|
- All responses use `{ success, reason, ...payload }` and `Cache-Control: no-store`.
|
||||||
|
- `Organization.slug` is the canonical tenant segment for authenticated workspace URLs.
|
||||||
|
Organization create/update and setup slug generation must reject or rewrite reserved
|
||||||
|
workspace segments from `modules/shared/lib/workspace-routing.ts` so `/app/settings` and
|
||||||
|
similar product routes cannot be interpreted as tenants.
|
||||||
|
- Switching organizations should return the selected organization and available organization
|
||||||
|
options so the client can redirect to the same workspace section under the new slug.
|
||||||
|
|
||||||
|
#### 4. Validation & Error Matrix
|
||||||
|
- Missing/expired session -> `success: false`, `401`, `未登录或会话已过期`.
|
||||||
|
- Empty `organizationId` -> `success: false`, `400`, `机构不能为空`.
|
||||||
|
- Organization not in authenticated account's available organization list -> `success: false`, `403`, `无权切换到该机构`.
|
||||||
|
- Empty profile `name` -> `success: false`, `400`, `用户名称不能为空`.
|
||||||
|
|
||||||
|
#### 5. Good/Base/Bad Cases
|
||||||
|
- Good: platform account with organization-read permission can switch among active organizations.
|
||||||
|
- Base: organization user can switch only among active memberships.
|
||||||
|
- Bad: never trust a client-provided organization ID without comparing against server-computed organization options.
|
||||||
|
|
||||||
|
#### 6. Tests Required
|
||||||
|
- Session API asserts `organizations` includes `isActive`, `slug`, and `roleLabel`.
|
||||||
|
- Organization switch asserts session row changes and forbidden org IDs are rejected.
|
||||||
|
- Profile update asserts current account only is updated and audit log is recorded.
|
||||||
|
|
||||||
|
#### 7. Wrong vs Correct
|
||||||
|
|
||||||
|
Wrong:
|
||||||
|
```typescript
|
||||||
|
await database.update(sessions).set({ activeOrganizationId: body.organizationId });
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```typescript
|
||||||
|
const target = context.organizations.find((organization) => organization.id === organizationId);
|
||||||
|
if (!target) {
|
||||||
|
throw new Error("无权切换到该机构");
|
||||||
|
}
|
||||||
|
await database.update(sessions).set({ activeOrganizationId: organizationId }).where(eq(sessions.id, context.session.id));
|
||||||
|
```
|
||||||
|
|
||||||
### What is better-auth
|
### What is better-auth
|
||||||
|
|
||||||
better-auth is a modern authentication library for TypeScript applications that provides:
|
better-auth is a modern authentication library for TypeScript applications that provides:
|
||||||
|
|||||||
@@ -191,6 +191,70 @@ export async function getOrdersWithItems(params: {
|
|||||||
|
|
||||||
## Advanced SQL Patterns
|
## Advanced SQL Patterns
|
||||||
|
|
||||||
|
## Scenario: Admission and Bed Mutations
|
||||||
|
|
||||||
|
### 1. Scope / Trigger
|
||||||
|
|
||||||
|
- Trigger: changing elder admission, transfer, discharge, or bed occupancy behavior.
|
||||||
|
- These mutations span `admissions`, `beds`, and `elders`, so partial writes are not acceptable.
|
||||||
|
|
||||||
|
### 2. Signatures
|
||||||
|
|
||||||
|
- `POST /api/admissions`: admit an elder or transfer an active elder to a new available bed.
|
||||||
|
- `PATCH /api/admissions/[id]`: discharge an active admission and release its bed.
|
||||||
|
- `GET /api/elders`: list elders with current active bed labels joined from `admissions -> beds -> rooms`.
|
||||||
|
- `readData()`: compatibility read model must expose the same active bed labels on `Elder.room`, `Elder.bed`, and `Elder.bedId`.
|
||||||
|
- Mutations use `getDatabase().transaction(async (transaction) => ...)`.
|
||||||
|
|
||||||
|
### 3. Contracts
|
||||||
|
|
||||||
|
- `POST /api/admissions` request: `{ elderId: string; bedId: string; notes?: string }`.
|
||||||
|
- `PATCH /api/admissions/[id]` request: `{ notes?: string }`.
|
||||||
|
- Success responses include `{ success: true, reason: string, admission }`.
|
||||||
|
- Failure responses include `{ success: false, reason: string }`.
|
||||||
|
|
||||||
|
### 4. Validation & Error Matrix
|
||||||
|
|
||||||
|
- Missing active organization -> `400`.
|
||||||
|
- Missing elder or bed -> `404`.
|
||||||
|
- Target bed not `available` -> `409`.
|
||||||
|
- Discharge target not found -> `404`.
|
||||||
|
- Discharge target not `active` -> `409`.
|
||||||
|
- Insert/update returning no row -> `500`.
|
||||||
|
|
||||||
|
### 5. Good/Base/Bad Cases
|
||||||
|
|
||||||
|
- Good: return a typed mutation result from inside the transaction, then convert it to `jsonFailure` or `jsonSuccess` outside the transaction.
|
||||||
|
- Good: display elder bed fields as read-only data derived from the active admission; bed assignment actions live in the bed/admission workspace.
|
||||||
|
- Base: read-only admission lists may use the temporary `readData()` compatibility model while the domain helper layer is being extracted.
|
||||||
|
- Bad: throw ordinary `Error` for business conflicts such as occupied beds, because it loses the structured status/reason contract.
|
||||||
|
- Bad: allow free-text room/bed edits on the elder form when those strings are not persisted relationship data.
|
||||||
|
|
||||||
|
### 6. Tests Required
|
||||||
|
|
||||||
|
- `pnpm lint`
|
||||||
|
- `pnpm type-check`
|
||||||
|
- `pnpm build`
|
||||||
|
- Integration assertions: occupied-bed admission returns `409`; transfer closes the old active admission and frees the old bed; discharge closes the active admission, frees the bed, and marks the elder discharged.
|
||||||
|
|
||||||
|
### 7. Wrong vs Correct
|
||||||
|
|
||||||
|
#### Wrong
|
||||||
|
|
||||||
|
```ts
|
||||||
|
if (bed.status !== "available") {
|
||||||
|
throw new Error("床位不可分配");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Correct
|
||||||
|
|
||||||
|
```ts
|
||||||
|
if (bed.status !== "available") {
|
||||||
|
return { success: false, reason: "床位不可分配", status: 409 };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### JSON Column Operations
|
### JSON Column Operations
|
||||||
|
|
||||||
When using PostgreSQL JSON/JSONB columns, proper casting is required for JSON functions.
|
When using PostgreSQL JSON/JSONB columns, proper casting is required for JSON functions.
|
||||||
|
|||||||
339
.trellis/spec/backend/drizzle-postgres-current.md
Normal file
339
.trellis/spec/backend/drizzle-postgres-current.md
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
# Drizzle PostgreSQL Current Persistence Contract
|
||||||
|
|
||||||
|
## Scenario: Current Project After PostgreSQL Migration
|
||||||
|
|
||||||
|
### 1. Scope / Trigger
|
||||||
|
|
||||||
|
- Trigger: implementing or modifying auth, RBAC, audit, elder, facility, admission, organization, settings, or dashboard data flows in this repository.
|
||||||
|
- Applies because this project already has `drizzle-orm`, `postgres`, Drizzle migrations under `drizzle/`, and schema definitions under `modules/core/server/schema.ts`.
|
||||||
|
- This supersedes local JSON persistence for current feature work. Local JSON guidance is only historical or for projects that have not adopted Drizzle yet.
|
||||||
|
|
||||||
|
### 2. Signatures
|
||||||
|
|
||||||
|
- `getDatabase(): AppDatabase`
|
||||||
|
- `checkDatabaseConnection(): Promise<{ ok: boolean; reason: string }>`
|
||||||
|
- `readData(): Promise<AppData>`
|
||||||
|
- `recordAuditLog(input: AuditInput): Promise<AuditLog>`
|
||||||
|
- `requirePermission(permission: Permission, auditContext: DeniedAuditContext): Promise<PermissionCheckSuccess | PermissionCheckFailure>`
|
||||||
|
- Route Handlers use standard `GET`, `POST`, `PATCH`, and `DELETE` exports and return `Response`.
|
||||||
|
|
||||||
|
### 3. Contracts
|
||||||
|
|
||||||
|
- Environment key: `DATABASE_URL` is required for database-backed runtime behavior.
|
||||||
|
- Drizzle source of truth: `modules/core/server/schema.ts`.
|
||||||
|
- Migration output: `drizzle/`.
|
||||||
|
- Database config: `drizzle.config.ts`.
|
||||||
|
- Session cookie:
|
||||||
|
- Name: `teatea_session`
|
||||||
|
- Flags: `httpOnly`, `sameSite: "lax"`, `path: "/"`
|
||||||
|
- Lifetime: 7 days
|
||||||
|
- API response shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type ApiResult<T extends Record<string, unknown>> =
|
||||||
|
| ({ success: true; reason: string } & T)
|
||||||
|
| { success: false; reason: string };
|
||||||
|
```
|
||||||
|
|
||||||
|
- `modules/core/server/store.ts` is a compatibility read model:
|
||||||
|
- `readData()` may aggregate Drizzle rows for existing pages.
|
||||||
|
- `writeData()` and `updateData()` must remain unavailable after migration.
|
||||||
|
- New mutation code must use Drizzle queries or transactions.
|
||||||
|
|
||||||
|
### 4. Validation & Error Matrix
|
||||||
|
|
||||||
|
- Missing `DATABASE_URL` -> throw during `getDatabase()` and surface a clear database configuration failure.
|
||||||
|
- Missing/expired session -> `401` with `{ success: false, reason: "未登录或会话已过期" }`.
|
||||||
|
- Missing permission -> `403` with `{ success: false, reason: "权限不足" }` and a denied audit log.
|
||||||
|
- Invalid JSON body -> `400` with a Chinese user-facing `reason`.
|
||||||
|
- Missing record by ID -> `404` with `{ success: false, reason: "<entity>不存在" }`.
|
||||||
|
- Duplicate account email -> `409` with `{ success: false, reason: "账号已存在" }`.
|
||||||
|
- Bed/admission conflict -> structured failure response and no partial mutation.
|
||||||
|
|
||||||
|
### 5. Good/Base/Bad Cases
|
||||||
|
|
||||||
|
- Good: UI submits to a Route Handler, the handler validates input, calls `requirePermission`, mutates Drizzle tables in a transaction when multiple tables are involved, records audit, and returns `ApiResult`.
|
||||||
|
- Base: Server Component reads data directly through focused server helpers or the temporary `readData()` compatibility model.
|
||||||
|
- Bad: New mutation code calls `writeData()` or `updateData()`.
|
||||||
|
- Bad: New code treats `.data/teatea.json` as the active persistence layer.
|
||||||
|
- Bad: API auth is enforced only by hidden UI controls.
|
||||||
|
|
||||||
|
### 6. Tests Required
|
||||||
|
|
||||||
|
- `pnpm lint`
|
||||||
|
- `pnpm type-check`
|
||||||
|
- `pnpm build`
|
||||||
|
- When schema changes are made: `pnpm db:generate`, then review generated SQL and run `pnpm db:migrate` against the configured development database.
|
||||||
|
- Manual or automated integration assertions:
|
||||||
|
- first setup creates platform admin, organization, organization roles, membership, and cookie session
|
||||||
|
- protected app route redirects without cookie
|
||||||
|
- protected app route renders with a valid cookie
|
||||||
|
- CRUD mutation persists across reload/API list
|
||||||
|
- denied permission returns `403` and writes an audit log
|
||||||
|
- admission mutation updates `admissions`, `beds`, and `elders` consistently
|
||||||
|
|
||||||
|
### 7. Wrong vs Correct
|
||||||
|
|
||||||
|
#### Wrong
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await updateData((data) => {
|
||||||
|
data.beds.push(newBed);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Correct
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const database = getDatabase();
|
||||||
|
await database.insert(beds).values({
|
||||||
|
organizationId,
|
||||||
|
roomId,
|
||||||
|
code,
|
||||||
|
status: "available",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Wrong
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await database.insert(admissions).values({ organizationId, elderId, bedId });
|
||||||
|
await database.update(beds).set({ status: "occupied" }).where(eq(beds.id, bedId));
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Correct
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await database.transaction(async (transaction) => {
|
||||||
|
await transaction.insert(admissions).values({ organizationId, elderId, bedId });
|
||||||
|
await transaction.update(beds).set({ status: "occupied" }).where(eq(beds.id, bedId));
|
||||||
|
await transaction.update(elders).set({ status: "active" }).where(eq(elders.id, elderId));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scenario: Facility Room Creation Without Fabricated Hierarchy
|
||||||
|
|
||||||
|
### 1. Scope / Trigger
|
||||||
|
|
||||||
|
- Trigger: changing facility room creation APIs or any UI that creates rooms.
|
||||||
|
- Applies because `rooms.floorId` is required by schema, but the system must not create
|
||||||
|
fake campus/building/floor records to satisfy that foreign key.
|
||||||
|
|
||||||
|
### 2. Signatures
|
||||||
|
|
||||||
|
- `POST /api/facilities/rooms`
|
||||||
|
- Request: `{ name: string; code: string; floorId: string; capacity?: number }`
|
||||||
|
- Response: `ApiResult<{ room: typeof rooms.$inferSelect }>`
|
||||||
|
|
||||||
|
### 3. Contracts
|
||||||
|
|
||||||
|
- `floorId` must refer to an existing `floors.id` in the authenticated active organization.
|
||||||
|
- `capacity` is optional; omitted means schema/product default `1`.
|
||||||
|
- If `capacity` is supplied, it must be a positive integer.
|
||||||
|
- The handler may insert only the `rooms` row. It must not insert `campuses`,
|
||||||
|
`buildings`, or `floors` as placeholder hierarchy.
|
||||||
|
|
||||||
|
### 4. Validation & Error Matrix
|
||||||
|
|
||||||
|
- Missing active organization -> `400` / `请选择机构后维护房间`.
|
||||||
|
- Missing `name`, `code`, or `floorId` -> `400` / `房间名称、编号和楼层不能为空`.
|
||||||
|
- Invalid supplied `capacity` -> `400` / `房间容量需为正整数`.
|
||||||
|
- `floorId` not found in active organization -> `404` / `楼层不存在`.
|
||||||
|
- Insert returning no row -> `500` / `房间创建失败`.
|
||||||
|
|
||||||
|
### 5. Good/Base/Bad Cases
|
||||||
|
|
||||||
|
- Good: create UI first exposes real campus/building/floor selection, then submits the selected `floorId`.
|
||||||
|
- Base: if facility hierarchy management is not exposed yet, keep room creation UI hidden or disabled.
|
||||||
|
- Bad: auto-create `"默认院区"`, `"默认楼栋"`, or `"默认楼层"` inside the room API.
|
||||||
|
|
||||||
|
### 6. Tests Required
|
||||||
|
|
||||||
|
- `pnpm lint`
|
||||||
|
- `pnpm type-check`
|
||||||
|
- `pnpm build`
|
||||||
|
- Integration assertions:
|
||||||
|
- missing `floorId` returns `400`
|
||||||
|
- unknown or cross-organization `floorId` returns `404`
|
||||||
|
- valid `floorId` creates a room without inserting campus/building/floor rows
|
||||||
|
|
||||||
|
### 7. Wrong vs Correct
|
||||||
|
|
||||||
|
#### Wrong
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const floor = existingFloor ?? await transaction.insert(floors).values({ name: "默认楼层" }).returning();
|
||||||
|
await transaction.insert(rooms).values({ floorId: floor.id, name, code });
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Correct
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const floorRows = await database
|
||||||
|
.select({ id: floors.id })
|
||||||
|
.from(floors)
|
||||||
|
.where(and(eq(floors.id, floorId), eq(floors.organizationId, organizationId)))
|
||||||
|
.limit(1);
|
||||||
|
const floor = floorRows[0];
|
||||||
|
if (!floor) {
|
||||||
|
return jsonFailure("楼层不存在", 404);
|
||||||
|
}
|
||||||
|
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)));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scenario: Care Execution Workspace
|
||||||
|
|
||||||
|
### 1. Scope / Trigger
|
||||||
|
|
||||||
|
- Trigger: adding or changing persisted daily care task execution behavior.
|
||||||
|
- Applies when replacing reserved operational module placeholders with real care task data. Care records must be Drizzle-backed and organization-scoped.
|
||||||
|
|
||||||
|
### 2. Signatures
|
||||||
|
|
||||||
|
- `GET /api/care/tasks`
|
||||||
|
- `PATCH /api/care/tasks/[id]`
|
||||||
|
- `listCareExecutionData(organizationId: string): Promise<CareExecutionData>`
|
||||||
|
- `updateCareTaskStatus(input): Promise<CareTask | { success: false; reason: string; status: number }>`
|
||||||
|
- Drizzle table: `care_tasks`
|
||||||
|
|
||||||
|
### 3. Contracts
|
||||||
|
|
||||||
|
- `/app/care` and `/app/{organizationSlug}/care` render the real care workspace once `care_tasks` exists.
|
||||||
|
- Reads require `care:read`; mutations require `care:manage`.
|
||||||
|
- `GET /api/care/tasks` returns `{ success: true; reason: string; data: CareExecutionData }`; client refresh code must read `result.data`.
|
||||||
|
- `PATCH /api/care/tasks/[id]` request: `{ status: "pending" | "in_progress" | "completed" | "cancelled"; executionNotes?: string }`.
|
||||||
|
- Completing a task sets `completedAt` and `completedByAccountId`; moving a task away from `completed` clears completion metadata.
|
||||||
|
- Mutations update by both `id` and active `organizationId`, then write an audit log after success.
|
||||||
|
|
||||||
|
### 4. Validation & Error Matrix
|
||||||
|
|
||||||
|
- Missing active organization on list -> `400` / `请选择机构后查看护理任务`.
|
||||||
|
- Missing active organization on mutation -> `400` / `请选择机构后处理护理任务`.
|
||||||
|
- Invalid status -> `400` / `护理任务状态无效`.
|
||||||
|
- Missing or cross-organization task ID -> `404` / `护理任务不存在`.
|
||||||
|
- Missing permission -> `403` from `requirePermission`; route must not call domain helpers.
|
||||||
|
- Update returning no row -> structured mutation failure.
|
||||||
|
|
||||||
|
### 5. Good/Base/Bad Cases
|
||||||
|
|
||||||
|
- Good: keep care task examples in default workspace seed tied to real seeded elders.
|
||||||
|
- Good: use one additive `care_tasks` table for MVP execution records instead of building a full recurring care-plan engine.
|
||||||
|
- Good: preserve `/app/health` separation; care execution is operational and not the health admin settings page.
|
||||||
|
- Base: `elderId` can be nullable for future public-area checks, but seeded MVP examples should use real elders.
|
||||||
|
- Bad: render fake care metrics in `ModulePage` after the module becomes real.
|
||||||
|
- Bad: update task status by ID alone without `organizationId`.
|
||||||
|
- Bad: only disable buttons in the UI while leaving Route Handlers on broad elder permissions.
|
||||||
|
|
||||||
|
### 6. Tests Required
|
||||||
|
|
||||||
|
- `pnpm test` with API assertions for list and status update routes.
|
||||||
|
- Route tests must cover happy path, permission denial, missing organization, invalid status, and missing/cross-organization task IDs.
|
||||||
|
- `pnpm db:generate` after schema changes and review generated SQL for only care enum/table/index/FK changes.
|
||||||
|
- `pnpm lint`, `pnpm type-check`, and `pnpm build`.
|
||||||
|
|
||||||
|
### 7. Wrong vs Correct
|
||||||
|
|
||||||
|
#### Wrong
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await database.update(careTasks).set({ status }).where(eq(careTasks.id, id));
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Correct
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await database
|
||||||
|
.update(careTasks)
|
||||||
|
.set({ status, updatedAt: new Date() })
|
||||||
|
.where(and(eq(careTasks.id, id), eq(careTasks.organizationId, organizationId)));
|
||||||
|
```
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
| [database.md](./database.md) | Drizzle ORM, queries, transactions, SQL patterns | Database operations |
|
| [database.md](./database.md) | Drizzle ORM, queries, transactions, SQL patterns | Database operations |
|
||||||
| [authentication.md](./authentication.md) | better-auth, sessions, OAuth, protected procedures | Auth-related features |
|
| [authentication.md](./authentication.md) | better-auth, sessions, OAuth, protected procedures | Auth-related features |
|
||||||
| [logging.md](./logging.md) | Structured logging, Sentry tracing, telemetry | Debugging, observability |
|
| [logging.md](./logging.md) | Structured logging, Sentry tracing, telemetry | Debugging, observability |
|
||||||
|
| [drizzle-postgres-current.md](./drizzle-postgres-current.md) | Current project Drizzle/PostgreSQL persistence boundary | Any persisted feature in this repository |
|
||||||
| [local-json-mvp.md](./local-json-mvp.md) | Local JSON persistence/session/API contracts before database adoption | Single-repo MVP features without DB/oRPC deps |
|
| [local-json-mvp.md](./local-json-mvp.md) | Local JSON persistence/session/API contracts before database adoption | Single-repo MVP features without DB/oRPC deps |
|
||||||
| [deployment.md](./deployment.md) | Wulanchabu deployment target and validation contract | Deploying this project |
|
| [deployment.md](./deployment.md) | Wulanchabu deployment target and validation contract | Deploying this project |
|
||||||
| [performance.md](./performance.md) | Concurrency, caching, batch processing, streaming | Performance optimization |
|
| [performance.md](./performance.md) | Concurrency, caching, batch processing, streaming | Performance optimization |
|
||||||
|
|||||||
@@ -97,6 +97,180 @@ export function InteractiveWidget({ initialData }: { initialData: Data }) {
|
|||||||
|
|
||||||
## Semantic HTML
|
## Semantic HTML
|
||||||
|
|
||||||
|
## Kumo UI Convention
|
||||||
|
|
||||||
|
Use `components/ui/*` as the project-owned adapter layer for Cloudflare Kumo components.
|
||||||
|
Feature modules and route files should import `Button`, `Input`, `Select`, `Textarea`, `Table`,
|
||||||
|
`Checkbox`, `Badge`, `Card`, and `Dialog` from `@/components/ui/...` instead of importing
|
||||||
|
`@cloudflare/kumo` directly.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good: project adapter keeps Kumo API differences local
|
||||||
|
import { Select } from "@/components/ui/select";
|
||||||
|
import { Table } from "@/components/ui/table";
|
||||||
|
|
||||||
|
// Avoid: feature code should not hand-style native controls
|
||||||
|
<select name="roleId" />
|
||||||
|
<table />
|
||||||
|
```
|
||||||
|
|
||||||
|
Visible form controls and data tables should use the Kumo-backed adapters. Native hidden
|
||||||
|
inputs are acceptable only inside adapters when needed to preserve FormData semantics.
|
||||||
|
|
||||||
|
### Select Adapter Positioning Contract
|
||||||
|
|
||||||
|
`components/ui/select.tsx` is intentionally project-owned instead of directly wrapping
|
||||||
|
`@cloudflare/kumo/components/select`. Kumo Select 2.6 can inherit Base UI
|
||||||
|
`alignItemWithTrigger=true`, which may render `data-side="none"` and overlap the trigger in
|
||||||
|
production.
|
||||||
|
|
||||||
|
The project Select adapter must:
|
||||||
|
|
||||||
|
- Preserve the local API: `options`, `value`, `defaultValue`, `name`, `placeholder`,
|
||||||
|
`disabled`, `required`, `aria-*`, and `onValueChange`.
|
||||||
|
- Render a hidden form value only inside the adapter when `name` is provided.
|
||||||
|
- Use portal/fixed positioning for the popup and keep a positive gap between trigger and
|
||||||
|
panel. Browser validation should assert `verticalOverlap === 0` on desktop and mobile.
|
||||||
|
- Keep keyboard behavior for ArrowUp/ArrowDown, Home/End, Enter/Space, Escape, and Tab.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good: feature code stays on the project adapter.
|
||||||
|
<Select name="organizationId" options={organizationOptions} />
|
||||||
|
|
||||||
|
// Avoid: this can reintroduce production overlay regressions.
|
||||||
|
import { Select } from "@cloudflare/kumo/components/select";
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dialog Adapter Centering Contract
|
||||||
|
|
||||||
|
`components/ui/dialog.tsx` may use Kumo Dialog primitives for accessibility, but the
|
||||||
|
project adapter owns panel geometry. Kumo Dialog includes fixed `left-1/2 top-1/2`
|
||||||
|
and translate classes, and those transforms can interact with project animations or
|
||||||
|
production CSS ordering.
|
||||||
|
|
||||||
|
The project Dialog adapter must:
|
||||||
|
|
||||||
|
- Center the panel without relying on translate transforms. Use `fixed inset-0 m-auto`
|
||||||
|
plus explicit width, max-height, and `h-fit`.
|
||||||
|
- Override Kumo minimum width so mobile dialogs stay inside `100vw`.
|
||||||
|
- Keep `transform: none` and `translate: 0 0` on `.teatea-dialog`; use
|
||||||
|
opacity/scale-only motion if animation is needed.
|
||||||
|
- Kumo may inject `-translate-x-1/2 -translate-y-1/2` before project classes in the
|
||||||
|
runtime class list. Do not rely only on Tailwind class order or a plain CSS rule;
|
||||||
|
the Dialog adapter should enforce `style={{ transform: "none", translate: "0 0" }}`
|
||||||
|
or an equally strong adapter-owned override.
|
||||||
|
- Browser validation should assert the panel center is within 2px of the viewport center
|
||||||
|
on desktop and mobile and should inspect computed `translate` when a dialog is visibly
|
||||||
|
offset.
|
||||||
|
|
||||||
|
### Static Module Data Contract
|
||||||
|
|
||||||
|
Static or reserved module pages must not fabricate operational data. If a module does
|
||||||
|
not have a Drizzle-backed query/API and real persisted records, render a clear empty or
|
||||||
|
not-connected state instead of generated counters, fake workflows, sample records, or
|
||||||
|
mock priority/status rows.
|
||||||
|
|
||||||
|
`modules/shared/components/ModulePage.tsx` is the shared placeholder for these modules.
|
||||||
|
Its props should stay descriptive only:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ModulePageProps = {
|
||||||
|
title: string;
|
||||||
|
eyebrow: string;
|
||||||
|
description: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
phase: "待接入" | "二期预留" | "三期预留";
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Bad: static pages pretending to have live business data.
|
||||||
|
<ModulePage
|
||||||
|
title="Care"
|
||||||
|
metrics={[{ label: "Today", value: "184", detail: "142 done" }]}
|
||||||
|
workflows={["Create plan", "Dispatch task"]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Good: no fabricated records until a real data source exists.
|
||||||
|
<ModulePage
|
||||||
|
title="Care"
|
||||||
|
eyebrow="Plans and inspections"
|
||||||
|
description="Pending connection to care plans, task dispatch, and execution records."
|
||||||
|
icon={ClipboardCheck}
|
||||||
|
phase="待接入"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
When a module becomes real, replace the placeholder with a Server Component that loads
|
||||||
|
data from the server boundary and pass only persisted, permission-filtered data into
|
||||||
|
client components.
|
||||||
|
|
||||||
|
### App Shell Tenant and Account Menu Contract
|
||||||
|
|
||||||
|
The desktop app shell footer is the tenant/account workspace control, not only a sign-out
|
||||||
|
surface.
|
||||||
|
|
||||||
|
- Show a compact organization switcher above the account card.
|
||||||
|
- Display both organization name and non-empty `slug`; do not hide or fabricate missing
|
||||||
|
tenant identifiers.
|
||||||
|
- Switch organizations by calling `POST /api/auth/organization` and then `router.refresh()`;
|
||||||
|
do not store the active organization in localStorage.
|
||||||
|
- The account card menu opens a user settings dialog for the current account. Profile edits
|
||||||
|
call `PATCH /api/account/profile`.
|
||||||
|
- OIDC binding UI must reflect real backend capability. If binding records/callbacks are not
|
||||||
|
implemented, show an honest not-connected state instead of fake provider accounts.
|
||||||
|
- The sidebar nav selected state belongs in a small client component using `usePathname()`;
|
||||||
|
keep the rest of `AppShell` server-rendered.
|
||||||
|
- Authenticated workspace links should preserve the active organization slug by using
|
||||||
|
`modules/shared/lib/workspace-routing.ts`. Sidebar links, breadcrumbs, permission
|
||||||
|
redirects, table search/pagination paths, login redirects, and organization switch
|
||||||
|
redirects should call `getWorkspaceHref(activeSlug, workspacePath)` instead of hard-coding
|
||||||
|
`/app/...` paths.
|
||||||
|
- The legacy unscoped `/app/...` paths remain valid fallback paths for accounts without an
|
||||||
|
active organization. When a session has an active organization, redirect into
|
||||||
|
`/app/{organizationSlug}/...` so operators can see which workspace they are using.
|
||||||
|
- Top-level workspace route names such as `dashboard`, `settings`, `elders`, and `beds` are
|
||||||
|
reserved path segments, not tenant identifiers. Keep the frontend reserved list in
|
||||||
|
`workspace-routing.ts` aligned with backend organization slug validation.
|
||||||
|
|
||||||
|
### Business Form Defaults Contract
|
||||||
|
|
||||||
|
Create forms for persisted business records must not prefill required domain fields with
|
||||||
|
assumed values. Defaults like age `75`, gender `male`, care level `self-care`, status
|
||||||
|
`active`, or "first available role" can turn into real PostgreSQL records when a user
|
||||||
|
submits without noticing.
|
||||||
|
|
||||||
|
Use explicit empty selection states for required business choices, then validate before
|
||||||
|
calling the API:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Bad: saves fabricated domain decisions if the user only fills name/email.
|
||||||
|
const emptyInput = {
|
||||||
|
gender: "male",
|
||||||
|
age: 75,
|
||||||
|
careLevel: "self-care",
|
||||||
|
status: "active",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Good: the user must provide the domain values that will be persisted.
|
||||||
|
const emptyInput = {
|
||||||
|
gender: "",
|
||||||
|
age: "",
|
||||||
|
careLevel: "",
|
||||||
|
status: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const genderOptions = [
|
||||||
|
{ value: "", label: "Select gender", disabled: true },
|
||||||
|
...realGenderOptions,
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit forms may preload values that already exist on the persisted record. Create,
|
||||||
|
invite, approve, and assignment forms should require an explicit selection for role,
|
||||||
|
organization, status, and other authorization or workflow fields unless the default is
|
||||||
|
server-owned and documented as a product rule.
|
||||||
|
|
||||||
### Use Proper Elements
|
### Use Proper Elements
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|||||||
@@ -155,6 +155,71 @@ app/(app)/
|
|||||||
└── page.tsx -> modules/orders/ (detail view)
|
└── page.tsx -> modules/orders/ (detail view)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Scenario: Organization-Scoped Workspace Routes
|
||||||
|
|
||||||
|
### 1. Scope / Trigger
|
||||||
|
|
||||||
|
- Trigger: adding or changing protected app workspace routes, app-shell navigation, breadcrumbs, login redirects, organization switching, or settings pagination links.
|
||||||
|
- The current workspace URL must include the active organization slug when an organization is selected.
|
||||||
|
|
||||||
|
### 2. Signatures
|
||||||
|
|
||||||
|
- Canonical workspace route: `/app/[organizationSlug]/<workspacePath>`.
|
||||||
|
- Legacy-compatible route: `/app/<workspacePath>` may remain available while old links are migrated.
|
||||||
|
- Shared helpers live in `modules/shared/lib/workspace-routing.ts`:
|
||||||
|
- `getWorkspaceHref(organizationSlug: string | undefined, path?: string): string`
|
||||||
|
- `getWorkspacePathFromPathname(pathname: string): string`
|
||||||
|
- `getWorkspaceSlugFromPathname(pathname: string): string | undefined`
|
||||||
|
- `isWorkspacePathActive(pathname: string, itemPath: string): boolean`
|
||||||
|
|
||||||
|
### 3. Contracts
|
||||||
|
|
||||||
|
- `navGroups` stores workspace-local paths such as `/dashboard`, `/settings/users`, not full `/app/...` hrefs.
|
||||||
|
- `AppShell` passes the current `organization.slug` into navigation and logo links.
|
||||||
|
- `AppSidebarNav`, `AppBreadcrumbs`, settings search forms, and pagination must generate hrefs with `getWorkspaceHref(...)`.
|
||||||
|
- The `[organizationSlug]` layout must reject slug/session mismatches by redirecting to the active session organization workspace.
|
||||||
|
- Organization slugs cannot use reserved first-level workspace section keys such as `dashboard`, `settings`, `elders`, or `beds`.
|
||||||
|
|
||||||
|
### 4. Validation & Error Matrix
|
||||||
|
|
||||||
|
- Missing active organization slug -> helpers fall back to legacy `/app/<workspacePath>`.
|
||||||
|
- URL slug differs from `getCurrentAuthContext().organization.slug` -> redirect to `getWorkspaceHref(activeSlug, "/dashboard")`.
|
||||||
|
- New or updated organization slug equals a reserved workspace key -> API returns validation failure.
|
||||||
|
- A page-level redirect inside protected routes -> use `getWorkspaceHref(context.organization?.slug, targetPath)`.
|
||||||
|
|
||||||
|
### 5. Good/Base/Bad Cases
|
||||||
|
|
||||||
|
- Good: organization switch calls `POST /api/auth/organization`, then navigates to the same workspace path under the returned active organization slug.
|
||||||
|
- Base: legacy `/app/dashboard` remains renderable for compatibility, but new app-shell links point to `/app/{slug}/dashboard`.
|
||||||
|
- Bad: hard-code `/app/settings/users` in table forms or breadcrumbs; it drops users out of the organization-scoped workspace.
|
||||||
|
- Bad: infer tenant from `localStorage`; active organization remains server-session state.
|
||||||
|
|
||||||
|
### 6. Tests Required
|
||||||
|
|
||||||
|
- `pnpm lint`
|
||||||
|
- `pnpm type-check`
|
||||||
|
- `pnpm build`
|
||||||
|
- Browser assertions:
|
||||||
|
- login/setup lands on `/app/{activeOrg.slug}/dashboard`
|
||||||
|
- sidebar links preserve the active organization slug
|
||||||
|
- settings search and pagination preserve the active organization slug
|
||||||
|
- switching organization moves the URL to the new slug while preserving the workspace path
|
||||||
|
- mismatched `/app/{wrongSlug}/...` redirects to the active organization workspace
|
||||||
|
|
||||||
|
### 7. Wrong vs Correct
|
||||||
|
|
||||||
|
#### Wrong
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Link href="/app/settings/users">用户管理</Link>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Correct
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Link href={getWorkspaceHref(organization.slug, "/settings/users")}>用户管理</Link>
|
||||||
|
```
|
||||||
|
|
||||||
## Import Path Aliases
|
## Import Path Aliases
|
||||||
|
|
||||||
Configure in `tsconfig.json`:
|
Configure in `tsconfig.json`:
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
# Design
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
This task keeps the app in a single Next.js project and adds a small server-side domain layer inside `modules/`:
|
|
||||||
|
|
||||||
- `modules/core/server/`: server-only persistence, session, permissions, and audit helpers.
|
|
||||||
- `modules/auth/`: UI and client interactions for setup, login, logout, and session state.
|
|
||||||
- `modules/elders/`: elder schemas/types, server actions or API client helpers, and UI components.
|
|
||||||
- `modules/settings/`: role/account/audit display components.
|
|
||||||
- `app/api/.../route.ts`: Route Handlers for auth, session, elders, accounts, and audit APIs.
|
|
||||||
|
|
||||||
The persistence boundary should be centralized behind helper functions that read/write JSON files. UI and route handlers must not know file paths directly.
|
|
||||||
|
|
||||||
## Data Storage
|
|
||||||
|
|
||||||
Use a JSON file store under `.data/teatea.json` by default. The file should contain:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type AppData = {
|
|
||||||
accounts: Account[];
|
|
||||||
sessions: Session[];
|
|
||||||
elders: Elder[];
|
|
||||||
auditLogs: AuditLog[];
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
The store module owns initialization, read, write, and update operations. Updates should read the current snapshot, apply a synchronous mutation callback, and write the full file back. This is enough for the MVP and gives a clear future replacement boundary for Drizzle/Postgres.
|
|
||||||
|
|
||||||
## API Contracts
|
|
||||||
|
|
||||||
Route Handlers return a consistent response shape:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type ApiResult<T> =
|
|
||||||
| ({ success: true; reason: string } & T)
|
|
||||||
| { success: false; reason: string };
|
|
||||||
```
|
|
||||||
|
|
||||||
Planned endpoints:
|
|
||||||
|
|
||||||
- `GET /api/auth/bootstrap`: returns whether setup is required.
|
|
||||||
- `POST /api/auth/setup`: creates the first admin account, creates a session, logs setup.
|
|
||||||
- `POST /api/auth/login`: validates credentials, creates a session, logs login.
|
|
||||||
- `POST /api/auth/logout`: deletes current session cookie/session, logs logout.
|
|
||||||
- `GET /api/auth/session`: returns current account and permissions.
|
|
||||||
- `GET /api/elders`: lists elder profiles.
|
|
||||||
- `POST /api/elders`: creates an elder profile.
|
|
||||||
- `PATCH /api/elders/[id]`: updates an elder profile.
|
|
||||||
- `DELETE /api/elders/[id]`: deletes an elder profile.
|
|
||||||
- `GET /api/settings/accounts`: lists accounts for authorized roles.
|
|
||||||
- `GET /api/settings/roles`: lists built-in role definitions.
|
|
||||||
- `GET /api/audit-logs`: lists recent audit events.
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
Sessions use an HTTP-only cookie. Route Handlers read and write cookies with `await cookies()` from `next/headers`, matching current Next.js behavior.
|
|
||||||
|
|
||||||
Passwords are never stored in plain text. For the MVP, use Node built-in crypto with a per-account salt and `scryptSync` or `scrypt` for password hashing. This avoids adding a dependency while keeping the current local milestone meaningfully better than browser-only auth.
|
|
||||||
|
|
||||||
## Permissions
|
|
||||||
|
|
||||||
Define built-in role permissions in one server/shared constants module:
|
|
||||||
|
|
||||||
- `account:read`
|
|
||||||
- `account:manage`
|
|
||||||
- `audit:read`
|
|
||||||
- `elder:read`
|
|
||||||
- `elder:create`
|
|
||||||
- `elder:update`
|
|
||||||
- `elder:delete`
|
|
||||||
|
|
||||||
Route Handlers call a shared `requirePermission(permission)` helper. This helper returns an authenticated context or a structured forbidden/unauthorized result and writes denied audit entries.
|
|
||||||
|
|
||||||
## Audit Logging
|
|
||||||
|
|
||||||
Audit logging is implemented as a server helper that appends immutable records to the store:
|
|
||||||
|
|
||||||
- timestamp
|
|
||||||
- actor account ID/email if known
|
|
||||||
- action
|
|
||||||
- target type
|
|
||||||
- target ID
|
|
||||||
- result: `success` or `denied` or `failure`
|
|
||||||
- reason
|
|
||||||
|
|
||||||
Audit writes are part of the route handler flow. The MVP accepts best-effort logging for logout when a session has already expired.
|
|
||||||
|
|
||||||
## UI Flow
|
|
||||||
|
|
||||||
Protected app pages should use server session state where practical instead of a client-only `AuthGate`. The app layout can fetch session data and redirect unauthenticated users before rendering protected content.
|
|
||||||
|
|
||||||
The elder page becomes a real data view:
|
|
||||||
|
|
||||||
- Server Component loads initial elders.
|
|
||||||
- Client component handles create/edit/delete forms and refreshes after mutations.
|
|
||||||
- Controls are disabled or hidden based on current permissions.
|
|
||||||
|
|
||||||
The settings page becomes a server-loaded administrative view:
|
|
||||||
|
|
||||||
- Role definitions table.
|
|
||||||
- Accounts table.
|
|
||||||
- Recent audit log table.
|
|
||||||
|
|
||||||
## Compatibility
|
|
||||||
|
|
||||||
Static module pages not in scope remain untouched except for auth/layout integration. Existing visual design should be preserved: dense operational screens, restrained cards/tables, and Tailwind/Radix-compatible UI components.
|
|
||||||
|
|
||||||
## Risks and Rollback
|
|
||||||
|
|
||||||
- File persistence is not safe for high-concurrency production writes. This is acceptable for MVP and documented as a migration boundary.
|
|
||||||
- Replacing `AuthGate` with server-side auth can affect all `/app` routes. Rollback point: keep the old component until server session redirect is working.
|
|
||||||
- Password hashing must use Node runtime APIs, so affected Route Handlers should run in the Node runtime if needed.
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
# Implementation Plan
|
|
||||||
|
|
||||||
## Checklist
|
|
||||||
|
|
||||||
1. Add core server modules:
|
|
||||||
- JSON store read/write helpers.
|
|
||||||
- Account/session/password helpers.
|
|
||||||
- Role and permission definitions.
|
|
||||||
- Audit logging helper.
|
|
||||||
- Shared API response helpers.
|
|
||||||
|
|
||||||
2. Add auth APIs:
|
|
||||||
- `GET /api/auth/bootstrap`
|
|
||||||
- `POST /api/auth/setup`
|
|
||||||
- `POST /api/auth/login`
|
|
||||||
- `POST /api/auth/logout`
|
|
||||||
- `GET /api/auth/session`
|
|
||||||
|
|
||||||
3. Migrate auth UI:
|
|
||||||
- Update `AuthPanel` to call server APIs.
|
|
||||||
- Update `SignOutButton` to call logout API.
|
|
||||||
- Replace or bypass localStorage-only `AuthGate` with server session protection in the app layout.
|
|
||||||
- Keep unauthenticated redirects and setup redirects working.
|
|
||||||
|
|
||||||
4. Add elder APIs and UI:
|
|
||||||
- Create elder types and input validators.
|
|
||||||
- Implement list/create/update/delete Route Handlers.
|
|
||||||
- Replace `app/(app)/app/elders/page.tsx` static content with server-loaded CRUD UI.
|
|
||||||
|
|
||||||
5. Add settings/audit UI:
|
|
||||||
- Implement settings/account, role, and audit APIs.
|
|
||||||
- Replace `app/(app)/app/settings/page.tsx` static content with server-loaded tables.
|
|
||||||
|
|
||||||
6. Wire audit events:
|
|
||||||
- Account setup/create.
|
|
||||||
- Login/logout.
|
|
||||||
- Elder create/update/delete.
|
|
||||||
- Denied permission checks.
|
|
||||||
|
|
||||||
7. Verification:
|
|
||||||
- Run `pnpm lint`.
|
|
||||||
- Run `pnpm type-check`.
|
|
||||||
- Run `pnpm build`.
|
|
||||||
- Start dev server and manually exercise setup/login/CRUD/settings if build passes.
|
|
||||||
|
|
||||||
## Files Expected to Change
|
|
||||||
|
|
||||||
- `modules/auth/components/AuthPanel.tsx`
|
|
||||||
- `modules/auth/components/AuthGate.tsx`
|
|
||||||
- `modules/auth/components/SignOutButton.tsx`
|
|
||||||
- `app/(app)/app/layout.tsx`
|
|
||||||
- `app/(app)/app/elders/page.tsx`
|
|
||||||
- `app/(app)/app/settings/page.tsx`
|
|
||||||
|
|
||||||
## Files Expected to Be Created
|
|
||||||
|
|
||||||
- `modules/core/server/store.ts`
|
|
||||||
- `modules/core/server/auth.ts`
|
|
||||||
- `modules/core/server/permissions.ts`
|
|
||||||
- `modules/core/server/audit.ts`
|
|
||||||
- `modules/core/server/api.ts`
|
|
||||||
- `modules/elders/types.ts`
|
|
||||||
- `modules/elders/components/EldersClient.tsx`
|
|
||||||
- `modules/settings/components/SettingsOverview.tsx`
|
|
||||||
- `app/api/auth/bootstrap/route.ts`
|
|
||||||
- `app/api/auth/setup/route.ts`
|
|
||||||
- `app/api/auth/login/route.ts`
|
|
||||||
- `app/api/auth/logout/route.ts`
|
|
||||||
- `app/api/auth/session/route.ts`
|
|
||||||
- `app/api/elders/route.ts`
|
|
||||||
- `app/api/elders/[id]/route.ts`
|
|
||||||
- `app/api/settings/accounts/route.ts`
|
|
||||||
- `app/api/settings/roles/route.ts`
|
|
||||||
- `app/api/audit-logs/route.ts`
|
|
||||||
|
|
||||||
## Validation Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm lint
|
|
||||||
pnpm type-check
|
|
||||||
pnpm build
|
|
||||||
pnpm dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rollback Points
|
|
||||||
|
|
||||||
- If server auth blocks all app routes, revert only layout/AuthGate changes while keeping APIs.
|
|
||||||
- If elder CRUD UI is unstable, keep APIs and temporarily render a read-only server table.
|
|
||||||
- If file store causes build/runtime issues, move the data directory to `/tmp` behind the same store API for verification, then restore `.data` once filesystem assumptions are fixed.
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
# Next.js Full-Stack CRUD, RBAC, and Audit Logs
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Turn the current static/local-storage Next.js app into a minimal real full-stack application for the first operational slice: account setup/login, built-in RBAC, elder profile CRUD, and auditable administrative actions.
|
|
||||||
|
|
||||||
The first release should remove mock/local-only behavior from the core flow and persist data through server-side APIs so the app can be exercised end-to-end without browser localStorage state.
|
|
||||||
|
|
||||||
## Confirmed Facts
|
|
||||||
|
|
||||||
- The repository is already a Next.js 15 App Router project with routes under `app/`.
|
|
||||||
- Protected app screens are currently guarded by `modules/auth/components/AuthGate.tsx`, which reads localStorage through `modules/auth/lib/local-auth.ts`.
|
|
||||||
- Login/register/setup UI exists in `modules/auth/components/AuthPanel.tsx`, but password input is not validated server-side and accounts are browser-local.
|
|
||||||
- The "老人档案" route at `app/(app)/app/elders/page.tsx` is currently a static `ModulePage`.
|
|
||||||
- The "权限设置" route at `app/(app)/app/settings/page.tsx` is currently a static `ModulePage`.
|
|
||||||
- The project has no installed database/auth dependencies such as Drizzle, PostgreSQL client, oRPC, Zod, or better-auth.
|
|
||||||
- Current `package.json` already supports `pnpm lint`, `pnpm type-check`, and `pnpm build`.
|
|
||||||
|
|
||||||
## MVP Scope
|
|
||||||
|
|
||||||
- Implement a real server-side persistence layer using JSON files under a server-owned data directory for this milestone. This is not mock data: API mutations must write durable data on disk during local/runtime execution.
|
|
||||||
- Use Next.js Route Handlers for the first API surface instead of adding oRPC/Drizzle/better-auth in this milestone.
|
|
||||||
- Replace localStorage authentication with server-side account/session APIs and HTTP-only cookie sessions.
|
|
||||||
- Provide built-in roles and permission groups without user-defined custom role creation in this milestone.
|
|
||||||
- Implement full CRUD for elder profiles as the first business entity.
|
|
||||||
- Implement a permissions/settings page that shows accounts, roles, permission coverage, and audit logs from server data.
|
|
||||||
- Record audit log entries for login, logout, account creation, elder create/update/delete, and permission-sensitive denied actions.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Authentication
|
|
||||||
|
|
||||||
- Setup must create the first administrator account on the server.
|
|
||||||
- Login must validate account credentials on the server and set an HTTP-only session cookie.
|
|
||||||
- Logout must clear the session cookie and record an audit event when possible.
|
|
||||||
- App routes must no longer depend on localStorage for authentication.
|
|
||||||
|
|
||||||
### Roles and Permissions
|
|
||||||
|
|
||||||
- The system must include built-in roles:
|
|
||||||
- `admin`: full access to accounts, permissions, audit logs, and elder CRUD.
|
|
||||||
- `manager`: elder CRUD and audit log viewing, but no account/role administration.
|
|
||||||
- `caregiver`: elder read/update access for care-facing fields, no delete or account administration.
|
|
||||||
- `viewer`: read-only elder access.
|
|
||||||
- Permission checks must run on the server for all protected APIs.
|
|
||||||
- UI navigation or controls may hide unavailable actions, but hidden UI must not be the only enforcement.
|
|
||||||
|
|
||||||
### Elder CRUD
|
|
||||||
|
|
||||||
- Users with permission can list, create, update, and delete elder profiles.
|
|
||||||
- Elder records must include at minimum: name, gender, birth date or age, care level, room/bed, status, primary contact, phone, medical notes, created/updated timestamps.
|
|
||||||
- The elder page must show real server data and support create/edit/delete interactions.
|
|
||||||
- Invalid input must return structured API errors and show usable feedback in the UI.
|
|
||||||
|
|
||||||
### Audit Logs
|
|
||||||
|
|
||||||
- Audit logs must be persisted server-side and visible in the settings page.
|
|
||||||
- Each audit record must include timestamp, actor account ID/email when available, action, target type, target ID when available, result, and human-readable reason.
|
|
||||||
- Failed permission checks must be logged without exposing sensitive internals to the client.
|
|
||||||
|
|
||||||
### Data and API
|
|
||||||
|
|
||||||
- API responses must use a consistent `{ success, reason, ... }` shape.
|
|
||||||
- Server-side data helpers must avoid browser APIs.
|
|
||||||
- All file persistence operations must be centralized so future Drizzle/Postgres migration has a single boundary to replace.
|
|
||||||
- Do not use hard-coded UI counters for the implemented CRUD/settings/audit areas once server data exists.
|
|
||||||
|
|
||||||
### Quality
|
|
||||||
|
|
||||||
- Keep TypeScript strict without `any`, non-null assertions, or `@ts-ignore`/`@ts-expect-error`.
|
|
||||||
- Prefer Server Components for initial data loading and small Client Components only for forms/mutations.
|
|
||||||
- `pnpm lint`, `pnpm type-check`, and `pnpm build` should pass before marking implementation complete.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- [ ] First-run setup creates a server-persisted admin account and redirects into the app.
|
|
||||||
- [ ] Login/logout uses server APIs and an HTTP-only cookie session, not localStorage.
|
|
||||||
- [ ] Visiting `/app/elders` while authenticated loads elder records from the server persistence layer.
|
|
||||||
- [ ] An authorized user can create, edit, and delete elder records from the UI, and changes survive page reloads.
|
|
||||||
- [ ] Unauthorized role attempts against protected elder/account/audit APIs return a forbidden response and create audit log entries.
|
|
||||||
- [ ] `/app/settings` displays built-in roles, account list, and recent audit log entries from server data.
|
|
||||||
- [ ] Audit logs are written for account creation, login, logout, elder create/update/delete, and denied permission checks.
|
|
||||||
- [ ] Existing static module pages outside the MVP continue to render.
|
|
||||||
- [ ] `pnpm lint` passes.
|
|
||||||
- [ ] `pnpm type-check` passes.
|
|
||||||
- [ ] `pnpm build` passes.
|
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
|
|
||||||
- PostgreSQL/Drizzle migration.
|
|
||||||
- oRPC adoption.
|
|
||||||
- better-auth adoption.
|
|
||||||
- Password reset, email verification, OAuth, multi-factor authentication, or account invitations.
|
|
||||||
- Custom role builder UI.
|
|
||||||
- CRUD implementation for every module in the sidebar.
|
|
||||||
- Production-grade password hashing beyond Node built-in cryptographic hashing suitable for this local MVP.
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
- None blocking. The MVP assumes "basic CRUD" means the first core business entity, elder profiles, plus real account/session/audit support.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Next.js documentation confirms App Router Route Handlers support `GET`, `POST`, `PATCH`, and `DELETE`, `Response.json`, `request.json()`, and async `cookies()` from `next/headers` for cookie reads/writes in current versions.
|
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
This task keeps the app in a single Next.js project and continues the Drizzle/PostgreSQL-backed server domain layer inside `modules/`:
|
||||||
|
|
||||||
|
- `modules/core/server/`: Drizzle connection, schema, session, permissions, audit helpers, and compatibility read models.
|
||||||
|
- `modules/auth/`: UI and client interactions for setup, register, login, logout, and session state.
|
||||||
|
- `modules/elders/`: elder schemas/types, validation, and CRUD UI components.
|
||||||
|
- `modules/settings/`: role/account/organization/audit/status display and management components.
|
||||||
|
- `modules/facilities` or existing page-local modules: room, bed, occupancy, admission, transfer, and discharge UI as the feature grows.
|
||||||
|
- `app/api/.../route.ts`: Route Handlers for auth, session, elders, facilities, admissions, accounts, roles, organizations, status, and audit APIs.
|
||||||
|
|
||||||
|
The persistence boundary is now Drizzle/PostgreSQL, not JSON files. UI and route handlers should use domain helpers or direct Drizzle queries through `getDatabase()` in server-only modules. New mutation paths must not use `writeData()` or `updateData()`.
|
||||||
|
|
||||||
|
## Data Storage
|
||||||
|
|
||||||
|
The current persistent model is PostgreSQL with Drizzle schema in `modules/core/server/schema.ts` and generated migrations under `drizzle/`.
|
||||||
|
|
||||||
|
Important existing tables:
|
||||||
|
|
||||||
|
- Identity and tenancy: `accounts`, `sessions`, `organizations`, `memberships`, `join_requests`, `organization_invitations`.
|
||||||
|
- Authorization: `roles`, `permissions`, `role_permissions`.
|
||||||
|
- Operations: `elders`, `rooms`, `beds`, `admissions`.
|
||||||
|
- Governance and status: `audit_logs`, `system_incidents`, `system_settings`.
|
||||||
|
|
||||||
|
`modules/core/server/store.ts` should be treated as a compatibility read model only:
|
||||||
|
|
||||||
|
- `readData()` may aggregate Drizzle rows into existing UI-friendly types while legacy pages are migrated.
|
||||||
|
- `writeData()` and `updateData()` intentionally throw after the PostgreSQL migration.
|
||||||
|
- New feature work should prefer focused query helpers so the compatibility model does not become the long-term domain layer.
|
||||||
|
|
||||||
|
## API Contracts
|
||||||
|
|
||||||
|
Route Handlers return a consistent response shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type ApiResult<T extends Record<string, unknown>> =
|
||||||
|
| ({ success: true; reason: string } & T)
|
||||||
|
| { success: false; reason: string };
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing or expected endpoints:
|
||||||
|
|
||||||
|
- `GET /api/auth/bootstrap`: returns whether setup is required.
|
||||||
|
- `POST /api/auth/setup`: creates the first platform admin, first organization, session, roles/membership, and audit log.
|
||||||
|
- `POST /api/auth/register`: creates a user registration or invitation-based account flow where enabled.
|
||||||
|
- `POST /api/auth/login`: validates credentials, creates a session, logs login.
|
||||||
|
- `POST /api/auth/logout`: deletes current session cookie/session, logs logout.
|
||||||
|
- `GET /api/auth/session`: returns current account, organization, membership, and permissions.
|
||||||
|
- `GET /api/elders`: lists elder profiles for the active organization.
|
||||||
|
- `POST /api/elders`: creates an elder profile and optional active admission.
|
||||||
|
- `PATCH /api/elders/[id]`: updates an elder profile.
|
||||||
|
- `DELETE /api/elders/[id]`: deletes an elder profile.
|
||||||
|
- `GET /api/facilities/rooms`: lists rooms for the active organization.
|
||||||
|
- `POST /api/facilities/rooms`: creates room records when facility management UI exposes this.
|
||||||
|
- `GET /api/facilities/beds`: lists beds and current occupancy.
|
||||||
|
- `POST /api/facilities/beds`: creates bed records when facility management UI exposes this.
|
||||||
|
- `GET /api/admissions`: lists admission and transfer history.
|
||||||
|
- `POST /api/admissions`: admits or transfers an elder into an available bed.
|
||||||
|
- Planned: `PATCH /api/admissions/[id]` or equivalent mutation endpoint for transfer/discharge if POST cannot express the workflow cleanly.
|
||||||
|
- `GET /api/settings/accounts`: lists accounts for authorized roles.
|
||||||
|
- `GET /api/settings/roles`: lists built-in and organization roles.
|
||||||
|
- `GET /api/settings/permissions`: lists permission coverage.
|
||||||
|
- `GET /api/audit-logs`: lists recent audit events.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Sessions use an HTTP-only cookie named `teatea_session`. Route Handlers read and write cookies with `await cookies()` from `next/headers`, matching current Next.js behavior.
|
||||||
|
|
||||||
|
Passwords are never stored in plain text. The current implementation uses Node built-in crypto with per-account salt and `scryptSync`, which is acceptable for this local MVP until a dedicated auth library is introduced in a separate task.
|
||||||
|
|
||||||
|
Protected app routes should use server session state where practical instead of client-only localStorage guards. Server-rendered pages that depend on session cookies must opt out of static prerendering with `export const dynamic = "force-dynamic"` where needed.
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
Permissions and role definitions live in the core server/shared type boundary:
|
||||||
|
|
||||||
|
- Platform: `platform:manage`, `organization:read`, `organization:manage`.
|
||||||
|
- Account/role/security: `account:read`, `account:manage`, `role:read`, `role:manage`, `permission:read`, `audit:read`.
|
||||||
|
- Operations: `facility:read`, `facility:manage`, `admission:read`, `admission:manage`, `elder:read`, `elder:create`, `elder:update`, `elder:delete`.
|
||||||
|
- Status: `incident:read`, `incident:manage`.
|
||||||
|
|
||||||
|
Route Handlers call a shared `requirePermission(permission)` helper. This helper returns an authenticated context or a structured forbidden/unauthorized response and writes denied audit entries.
|
||||||
|
|
||||||
|
## Admission Transactions
|
||||||
|
|
||||||
|
Bed/admission mutations must run inside a Drizzle transaction.
|
||||||
|
|
||||||
|
Admit flow:
|
||||||
|
|
||||||
|
1. Validate active organization and `admission:manage`.
|
||||||
|
2. Validate elder belongs to the active organization.
|
||||||
|
3. Validate target bed belongs to the active organization and has status `available`.
|
||||||
|
4. Close or transfer any active admission for that elder if the operation is a transfer.
|
||||||
|
5. Insert a new `admissions` row with status `active`.
|
||||||
|
6. Set target bed status to `occupied`.
|
||||||
|
7. Set elder status to `active`.
|
||||||
|
8. Record an audit log.
|
||||||
|
|
||||||
|
Transfer flow:
|
||||||
|
|
||||||
|
1. Find active admission for the elder.
|
||||||
|
2. Set previous admission status to `transferred` and `dischargedAt` to now.
|
||||||
|
3. Set previous bed status to `available`.
|
||||||
|
4. Insert the new active admission and occupy the target bed.
|
||||||
|
5. Record an audit log.
|
||||||
|
|
||||||
|
Discharge flow:
|
||||||
|
|
||||||
|
1. Find active admission.
|
||||||
|
2. Set admission status to `discharged` and `dischargedAt` to now.
|
||||||
|
3. Set bed status to `available`.
|
||||||
|
4. Set elder status to `discharged` or another explicitly selected status.
|
||||||
|
5. Record an audit log.
|
||||||
|
|
||||||
|
All conflict checks must return structured API failures rather than partially mutating state.
|
||||||
|
|
||||||
|
## UI Flow
|
||||||
|
|
||||||
|
The app should remain an operational workspace: dense, restrained, and action-oriented.
|
||||||
|
|
||||||
|
Elder page:
|
||||||
|
|
||||||
|
- Server Component loads initial elders and available beds.
|
||||||
|
- Client component handles create/edit/delete forms and refreshes after mutations.
|
||||||
|
- Controls are disabled or hidden based on current permissions, while APIs still enforce permission checks.
|
||||||
|
|
||||||
|
Bed/admission page:
|
||||||
|
|
||||||
|
- Replace raw API placeholders with page-local controls.
|
||||||
|
- Use tabs or segmented navigation for overview, bed status, admissions/history, and management actions.
|
||||||
|
- Place admit/transfer/discharge actions inside the bed/admission workspace, not as a dead global top-bar button.
|
||||||
|
- Show occupancy metrics, active admissions, room/bed tables, and history from Drizzle-backed data.
|
||||||
|
|
||||||
|
Dashboard:
|
||||||
|
|
||||||
|
- Replace hard-coded counters for implemented domains with Drizzle-backed data.
|
||||||
|
- Use a standard chart library for selected charts such as occupancy distribution or admission activity.
|
||||||
|
- Keep chart usage modest; tables and status lists remain the primary record surfaces.
|
||||||
|
|
||||||
|
Screenshot feedback:
|
||||||
|
|
||||||
|
- Move page-level secondary navigation into tabs where appropriate.
|
||||||
|
- Remove oversized intro/hero blocks from routine operational pages.
|
||||||
|
- Remove redundant header action controls that are not wired to the current page workflow.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
Static module pages not in scope remain untouched except for auth/layout integration. Existing visual design should be preserved: dense operational screens, restrained cards/tables, project UI adapters under `components/ui/*`, and Tailwind-compatible layout.
|
||||||
|
|
||||||
|
## Risks and Rollback
|
||||||
|
|
||||||
|
- Drizzle schema and migrations are now the persistence source of truth; mismatches between schema and migrations can block deployment. Rollback point: keep migration changes separate from UI-only work.
|
||||||
|
- Admission mutations touch multiple tables. Rollback point: keep transaction helpers isolated and temporarily render read-only admission data if mutation UI is unstable.
|
||||||
|
- Replacing global header actions can affect user navigation habits. Rollback point: remove only the dead top-bar "入住" button while keeping the sidebar and pages stable.
|
||||||
|
- Adding a chart library increases client bundle size. Rollback point: limit chart usage to one focused client component and keep tables as the fallback data surface.
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Implementation Plan
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
1. Reconcile existing Drizzle migration state:
|
||||||
|
- Confirm `modules/core/server/schema.ts` matches `drizzle/` migrations.
|
||||||
|
- Confirm `modules/core/server/db.ts` is the only PostgreSQL connection boundary.
|
||||||
|
- Confirm `modules/core/server/store.ts` is treated as a compatibility read model only.
|
||||||
|
- Remove task assumptions that mention JSON file writes as the target persistence layer.
|
||||||
|
|
||||||
|
2. Verify existing auth/RBAC/audit slice:
|
||||||
|
- Confirm setup creates platform admin, organization, session, organization roles, membership, and audit log.
|
||||||
|
- Confirm login/logout uses server APIs and the `teatea_session` HTTP-only cookie.
|
||||||
|
- Confirm `requirePermission` logs denied permission checks.
|
||||||
|
- Confirm roles and permissions cover facility and admission operations.
|
||||||
|
|
||||||
|
3. Finish elder CRUD on Drizzle:
|
||||||
|
- Keep validation in `modules/elders/types.ts`.
|
||||||
|
- Ensure list/create/update/delete APIs query and mutate Drizzle only.
|
||||||
|
- Ensure elder create with `bedId` performs admission and bed status updates inside a transaction.
|
||||||
|
- Ensure UI refresh and error handling remain usable.
|
||||||
|
|
||||||
|
4. Add or complete bed/admission APIs:
|
||||||
|
- Keep `GET /api/facilities/rooms` and `GET /api/facilities/beds` Drizzle-backed.
|
||||||
|
- Keep `POST /api/admissions` transactional for admit/transfer.
|
||||||
|
- Add discharge and explicit transfer mutation support if the existing POST shape is not enough.
|
||||||
|
- Return structured `{ success, reason, ... }` responses for conflicts and validation failures.
|
||||||
|
- Write audit events for admission create, transfer, discharge, and facility mutations implemented in this task.
|
||||||
|
|
||||||
|
5. Build bed/admission UI:
|
||||||
|
- Replace "please create through API" empty states with usable controls where permission allows.
|
||||||
|
- Add tabs or segmented navigation for overview, bed status, admission actions/history, and facility records.
|
||||||
|
- Move admission actions into the bed/admission workspace.
|
||||||
|
- Remove or replace the dead global top-bar "入住" button in `AppShell`.
|
||||||
|
- Ensure page layout matches screenshot feedback: compact workspace header, no oversized intro block, local page actions.
|
||||||
|
|
||||||
|
6. Introduce selected Phase 2 UI affordances:
|
||||||
|
- Add tabs to pages that combine overview and management modes.
|
||||||
|
- Keep implementation incremental; do not attempt every sidebar module.
|
||||||
|
- Keep user-facing copy operational and concise.
|
||||||
|
|
||||||
|
7. Add standard chart usage:
|
||||||
|
- Decide the chart library before adding dependency. Recharts is the likely default unless a better project fit is chosen.
|
||||||
|
- Install the library only after checking existing dependencies.
|
||||||
|
- Replace at least one meaningful hand-built chart with a data-backed chart component.
|
||||||
|
- Keep chart component client-only and pass serializable server data into it.
|
||||||
|
|
||||||
|
8. Replace hard-coded operational counters where data exists:
|
||||||
|
- Dashboard bed/elder/admission counters should come from Drizzle-backed data.
|
||||||
|
- Bed/admission workspace metrics should compute from server-loaded rooms, beds, and admissions.
|
||||||
|
- Do not fabricate counters for modules that remain out of scope.
|
||||||
|
|
||||||
|
9. Verification:
|
||||||
|
- Run `pnpm lint`.
|
||||||
|
- Run `pnpm type-check`.
|
||||||
|
- Run `pnpm build`.
|
||||||
|
- Run Drizzle generation/check commands when schema changes are made.
|
||||||
|
- Start dev server and manually exercise setup/login/elder CRUD/bed admission/transfer/discharge/settings if build passes.
|
||||||
|
|
||||||
|
## Files Expected to Change
|
||||||
|
|
||||||
|
- `.trellis/tasks/07-01-nextjs-fullstack-crud-rbac-audit/prd.md`
|
||||||
|
- `.trellis/tasks/07-01-nextjs-fullstack-crud-rbac-audit/design.md`
|
||||||
|
- `.trellis/tasks/07-01-nextjs-fullstack-crud-rbac-audit/implement.md`
|
||||||
|
- `modules/shared/components/AppShell.tsx`
|
||||||
|
- `app/(app)/app/beds/page.tsx`
|
||||||
|
- `app/(app)/app/dashboard/page.tsx`
|
||||||
|
- `modules/dashboard/components/DashboardHome.tsx`
|
||||||
|
- `app/api/admissions/route.ts`
|
||||||
|
- Potentially `app/api/admissions/[id]/route.ts`
|
||||||
|
- Potentially facility UI components under `modules/` if the bed page is split into smaller client/server components.
|
||||||
|
|
||||||
|
## Files Expected to Stay As Existing Foundations
|
||||||
|
|
||||||
|
- `modules/core/server/db.ts`
|
||||||
|
- `modules/core/server/schema.ts`
|
||||||
|
- `modules/core/server/auth.ts`
|
||||||
|
- `modules/core/server/permissions.ts`
|
||||||
|
- `modules/core/server/audit.ts`
|
||||||
|
- `modules/core/server/api.ts`
|
||||||
|
- `modules/core/server/store.ts` as a temporary Drizzle-backed read model.
|
||||||
|
- Existing auth route handlers unless verification finds a concrete bug.
|
||||||
|
- Existing elder route handlers unless admission or bed assignment behavior requires a narrow fix.
|
||||||
|
|
||||||
|
## Dependency Notes
|
||||||
|
|
||||||
|
- Do not add oRPC or better-auth in this task.
|
||||||
|
- Do not reintroduce JSON-file persistence.
|
||||||
|
- A standard chart library may be added after dependency review. Prefer a focused dashboard dependency over a broad visualization stack.
|
||||||
|
|
||||||
|
## Validation Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm lint
|
||||||
|
pnpm type-check
|
||||||
|
pnpm build
|
||||||
|
pnpm db:generate
|
||||||
|
```
|
||||||
|
|
||||||
|
If schema files are changed, also validate migrations and run the migration flow against the configured development database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rollback Points
|
||||||
|
|
||||||
|
- If admission mutation UI is unstable, keep the Drizzle APIs and temporarily render a read-only admissions table.
|
||||||
|
- If discharge/transfer API design becomes too broad, implement only admission create/transfer in this slice and keep discharge as a clearly scoped follow-up.
|
||||||
|
- If chart dependency causes build or bundle issues, remove the chart component and keep the server data preparation in place.
|
||||||
|
- If tabs cause layout regressions, keep the page-local action placement and defer only the tab styling.
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# Next.js Drizzle Full-Stack CRUD, RBAC, Audit Logs, and Admission Ops
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Stabilize the current Next.js 15 application around the Drizzle/PostgreSQL persistence layer that has already been introduced, then finish the first operational slice and prepare a controlled Phase 2 path for bed/admission operations, tabs-based workspace navigation, and real chart components.
|
||||||
|
|
||||||
|
The near-term release should keep moving away from static/mock-only surfaces, but it must treat the existing Drizzle schema and migrations as the source of truth. JSON-file persistence is no longer the target architecture for this task.
|
||||||
|
|
||||||
|
## Confirmed Facts
|
||||||
|
|
||||||
|
- The repository is already a Next.js 15 App Router project with routes under `app/`.
|
||||||
|
- Drizzle and PostgreSQL dependencies are installed: `drizzle-orm`, `drizzle-kit`, and `postgres`.
|
||||||
|
- Drizzle schema exists at `modules/core/server/schema.ts`, with migrations under `drizzle/`.
|
||||||
|
- The schema already includes accounts, sessions, organizations, memberships, roles, permissions, elders, rooms, beds, admissions, audit logs, system incidents, invitations, and system settings.
|
||||||
|
- `modules/core/server/db.ts` owns the Drizzle/PostgreSQL connection and requires `DATABASE_URL`.
|
||||||
|
- `modules/core/server/store.ts` is now a Drizzle-backed compatibility read model. `readData()` aggregates data from PostgreSQL, while `writeData()` and `updateData()` intentionally throw after the PostgreSQL migration.
|
||||||
|
- Server-side authentication, password hashing, HTTP-only session cookies, role seeding, permission checks, and audit logging are implemented around Drizzle.
|
||||||
|
- Elder CRUD APIs at `app/api/elders/*` write through Drizzle and can create an active admission when a bed is selected.
|
||||||
|
- Facility and admission routes exist for rooms, beds, and admissions, but the bed/admission UI is still mostly read-only and does not yet provide a complete operator workflow.
|
||||||
|
- The dashboard still contains hard-coded operational counters and hand-built chart-like progress bars.
|
||||||
|
- The app shell has a top-bar "入住" button, but it is not wired to a usable admission workflow.
|
||||||
|
- The screenshot feedback requires moving workspace-level navigation into tabs, removing oversized module intro blocks, and avoiding redundant header actions such as the extra "入住" affordance in the top bar.
|
||||||
|
- Current `package.json` already supports `pnpm lint`, `pnpm type-check`, `pnpm build`, `pnpm db:generate`, `pnpm db:migrate`, and `pnpm db:studio`.
|
||||||
|
|
||||||
|
## MVP Scope
|
||||||
|
|
||||||
|
- Use the existing Drizzle/PostgreSQL layer for all new and changed persistent business data.
|
||||||
|
- Keep Next.js Route Handlers as the current API surface for this milestone. Do not introduce oRPC or better-auth in this task unless a separate migration task is created.
|
||||||
|
- Preserve server-side account/session APIs, HTTP-only cookie sessions, built-in platform and organization roles, membership permissions, and audit logging.
|
||||||
|
- Complete full CRUD for elder profiles as the first business entity, backed by Drizzle.
|
||||||
|
- Complete basic bed and admission operations: list rooms/beds, show current occupancy, create occupancy/admission, support bed transfer, and keep bed/admission/elder status consistent in a transaction.
|
||||||
|
- Implement an operator-friendly bed/admission UI under the app workspace instead of exposing "create through API" placeholders.
|
||||||
|
- Add workspace tabs where the current UI needs secondary navigation, especially for operational pages that combine overview, records, and management views.
|
||||||
|
- Replace hard-coded counters on implemented areas with Drizzle-backed data.
|
||||||
|
- Use a standard React chart library for selected operational charts instead of hand-rolled visual bars where the visualization is meaningful and data-backed.
|
||||||
|
- Keep settings pages showing accounts, roles, permission coverage, organizations, status, and audit logs from server data.
|
||||||
|
- Record audit log entries for login, logout, account creation, elder create/update/delete, admission create/transfer/discharge, facility mutations, and permission-sensitive denied actions.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
- Setup must create the first platform administrator account, initial organization, initial organization membership, and cookie session on the server.
|
||||||
|
- Login must validate account credentials on the server and set an HTTP-only session cookie.
|
||||||
|
- Logout must clear the session cookie and record an audit event when possible.
|
||||||
|
- App routes must not depend on localStorage for authentication.
|
||||||
|
|
||||||
|
### Roles and Permissions
|
||||||
|
|
||||||
|
- The system must include built-in roles:
|
||||||
|
- `platform_admin`: full platform access.
|
||||||
|
- `platform_operator`: platform organization/account operations without full security ownership.
|
||||||
|
- `platform_auditor`: platform read/audit access.
|
||||||
|
- `platform_ops`: platform operations and incident management.
|
||||||
|
- `org_admin`: full organization-level access.
|
||||||
|
- `manager`: elder, facility, admission, and audit operations inside an organization.
|
||||||
|
- `caregiver`: elder read/update plus read-only facility/admission access.
|
||||||
|
- `viewer`: read-only elder, facility, and admission access.
|
||||||
|
- Permission checks must run on the server for all protected APIs.
|
||||||
|
- UI navigation or controls may hide unavailable actions, but hidden UI must not be the only enforcement.
|
||||||
|
|
||||||
|
### Elder CRUD
|
||||||
|
|
||||||
|
- Users with permission can list, create, update, and delete elder profiles.
|
||||||
|
- Elder records must include at minimum: name, gender, age, care level, current room/bed when admitted, status, primary contact, phone, medical notes, created/updated timestamps.
|
||||||
|
- The elder page must show real server data and support create/edit/delete interactions.
|
||||||
|
- Invalid input must return structured API errors and show usable feedback in the UI.
|
||||||
|
|
||||||
|
### Bed and Admission Operations
|
||||||
|
|
||||||
|
- Operators with `facility:read` can view rooms, beds, occupancy status, current elder assignment, and admission history.
|
||||||
|
- Operators with `facility:manage` can create or update room and bed metadata where the UI exposes those controls.
|
||||||
|
- Operators with `admission:manage` can admit an elder to an available bed, transfer an active elder to another available bed, and discharge an elder from a bed.
|
||||||
|
- Admission mutations must be transactional: active admission records, bed statuses, and elder status must stay consistent.
|
||||||
|
- Bed assignment conflicts must be rejected with structured API errors.
|
||||||
|
- The UI must make common workflows discoverable without relying on raw API calls.
|
||||||
|
- The top app header should not contain redundant or dead "入住" controls. Admission actions should live inside the relevant bed/admission workspace.
|
||||||
|
|
||||||
|
### Workspace UI and Tabs
|
||||||
|
|
||||||
|
- The app shell should keep dense operational navigation and avoid large hero-style module introductions.
|
||||||
|
- Pages that combine multiple operator modes should use tabs or equivalent segmented navigation, not stacked explanatory sections.
|
||||||
|
- The screenshot feedback for notices/general workspace applies broadly: remove oversized first-screen intro blocks when they do not help an operator act, place secondary navigation tabs near the workspace header, and keep action controls local to the page.
|
||||||
|
- UI text should remain operational and data-oriented, not marketing copy.
|
||||||
|
|
||||||
|
### Charts
|
||||||
|
|
||||||
|
- Use a standard chart library for selected data-backed charts such as bed occupancy composition, admission activity, or status distribution.
|
||||||
|
- Avoid hand-coded fake chart bars for business metrics that should reflect persisted data.
|
||||||
|
- Keep charts lightweight, readable, and useful for operations; tables remain the primary surface for detailed records.
|
||||||
|
|
||||||
|
### Audit Logs
|
||||||
|
|
||||||
|
- Audit logs must be persisted server-side and visible in the settings page.
|
||||||
|
- Each audit record must include timestamp, actor account ID/email when available, action, target type, target ID when available, result, and human-readable reason.
|
||||||
|
- Failed permission checks must be logged without exposing sensitive internals to the client.
|
||||||
|
|
||||||
|
### Data and API
|
||||||
|
|
||||||
|
- API responses must use a consistent `{ success, reason, ... }` shape.
|
||||||
|
- Server-side data helpers must avoid browser APIs.
|
||||||
|
- New persistent mutations must use Drizzle queries or transactions through the server database boundary, not JSON file writes.
|
||||||
|
- `readData()` may remain temporarily as a compatibility read model, but new mutation code must not depend on `writeData()` or `updateData()`.
|
||||||
|
- Prefer domain-specific Drizzle query helpers over growing the compatibility read model for every new use case.
|
||||||
|
- Do not use hard-coded UI counters for implemented CRUD, settings, audit, bed, admission, or dashboard areas once server data exists.
|
||||||
|
- Routes or server components that depend on cookies/session or live database state must opt out of static caching where required.
|
||||||
|
|
||||||
|
### Quality
|
||||||
|
|
||||||
|
- Keep TypeScript strict without `any`, non-null assertions, or `@ts-ignore`/`@ts-expect-error`.
|
||||||
|
- Prefer Server Components for initial data loading and small Client Components only for forms/mutations.
|
||||||
|
- `pnpm lint`, `pnpm type-check`, `pnpm build`, and relevant Drizzle migration validation should pass before marking implementation complete.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] First-run setup creates a Drizzle-persisted platform admin account, initial organization, organization roles, membership, and cookie session, then redirects into the app.
|
||||||
|
- [ ] Login/logout uses server APIs and an HTTP-only cookie session, not localStorage.
|
||||||
|
- [ ] Visiting `/app/elders` while authenticated loads elder records from PostgreSQL through the server layer.
|
||||||
|
- [ ] An authorized user can create, edit, and delete elder records from the UI, and changes survive page reloads.
|
||||||
|
- [ ] Unauthorized role attempts against protected elder/account/audit APIs return a forbidden response and create audit log entries.
|
||||||
|
- [ ] Settings pages display built-in roles, account list, organizations, system status, and recent audit log entries from server data.
|
||||||
|
- [ ] `/app/beds` or the equivalent admission workspace shows Drizzle-backed room, bed, occupancy, and admission data without raw API placeholders.
|
||||||
|
- [ ] An authorized user can admit an elder to an available bed from the UI.
|
||||||
|
- [ ] An authorized user can transfer or discharge an active admission from the UI, with bed status and elder status updated transactionally.
|
||||||
|
- [ ] Admission conflict attempts return structured errors and do not corrupt bed occupancy state.
|
||||||
|
- [ ] The screenshot feedback is reflected in the workspace layout: tabs are introduced where needed, oversized intro blocks are removed, and redundant top-bar admission action is removed or replaced with a useful page-local action.
|
||||||
|
- [ ] At least one implemented operational chart uses a standard chart library and real server data.
|
||||||
|
- [ ] Audit logs are written for account creation, login, logout, elder create/update/delete, admission create/transfer/discharge, facility mutations implemented in this task, and denied permission checks.
|
||||||
|
- [ ] Existing static module pages outside the MVP continue to render.
|
||||||
|
- [ ] Existing Drizzle migrations remain coherent with `modules/core/server/schema.ts`.
|
||||||
|
- [ ] `pnpm lint` passes.
|
||||||
|
- [ ] `pnpm type-check` passes.
|
||||||
|
- [ ] `pnpm build` passes.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Reverting to JSON-file persistence.
|
||||||
|
- Full historical data migration from any old `.data/teatea.json` files unless a separate migration task is created.
|
||||||
|
- Replacing the current Route Handlers with oRPC.
|
||||||
|
- Replacing the current custom session implementation with better-auth.
|
||||||
|
- Password reset, email verification, OAuth, multi-factor authentication, or new invitation flows beyond what already exists.
|
||||||
|
- Full custom role builder beyond the existing role/permission management surface.
|
||||||
|
- CRUD implementation for every module in the sidebar.
|
||||||
|
- Advanced care plan, billing, family app, device telemetry, and emergency workflow automation.
|
||||||
|
- Production-grade password hashing beyond Node built-in cryptographic hashing suitable for this local MVP.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Which chart library should be standardized for the project? Recharts is a practical default for React dashboards, but the decision should be captured before adding the dependency.
|
||||||
|
- Should room/bed creation be part of this immediate slice, or should the UI focus first on admission, transfer, and discharge against existing room/bed records?
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Next.js documentation confirms App Router Route Handlers support `GET`, `POST`, `PATCH`, and `DELETE`, `Response.json`, `request.json()`, and async `cookies()` from `next/headers` for cookie reads/writes in current versions.
|
||||||
|
- The current codebase already demonstrates the intended Drizzle direction. Future task text should not describe JSON files as the target persistence layer unless explicitly working on a compatibility migration.
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"name": "nextjs-fullstack-crud-rbac-audit",
|
"name": "nextjs-fullstack-crud-rbac-audit",
|
||||||
"title": "Next.js Full-Stack CRUD, RBAC, and Audit Logs",
|
"title": "Next.js Full-Stack CRUD, RBAC, and Audit Logs",
|
||||||
"description": "",
|
"description": "",
|
||||||
"status": "in_progress",
|
"status": "completed",
|
||||||
"dev_type": null,
|
"dev_type": null,
|
||||||
"scope": null,
|
"scope": null,
|
||||||
"package": null,
|
"package": null,
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"creator": "TalexDreamSoul",
|
"creator": "TalexDreamSoul",
|
||||||
"assignee": "TalexDreamSoul",
|
"assignee": "TalexDreamSoul",
|
||||||
"createdAt": "2026-07-01",
|
"createdAt": "2026-07-01",
|
||||||
"completedAt": null,
|
"completedAt": "2026-07-02",
|
||||||
"branch": null,
|
"branch": null,
|
||||||
"base_branch": "main",
|
"base_branch": "main",
|
||||||
"worktree_path": null,
|
"worktree_path": null,
|
||||||
@@ -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,104 @@
|
|||||||
|
# Care Execution Workspace Design
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Replace the reserved `/care` module page with a real operational workspace backed by persisted care execution records. This task owns daily care execution only: task list, status movement, completion notes, and seeded examples.
|
||||||
|
|
||||||
|
## Route Boundary
|
||||||
|
|
||||||
|
- Unscoped route: `app/(app)/app/care/page.tsx`
|
||||||
|
- Scoped route: `app/(app)/app/[organizationSlug]/care/page.tsx`
|
||||||
|
- Navigation item remains in the `运营` group.
|
||||||
|
- Permission should move from `elder:update` to explicit care permissions:
|
||||||
|
- `care:read`
|
||||||
|
- `care:manage`
|
||||||
|
|
||||||
|
The unscoped page should:
|
||||||
|
|
||||||
|
1. Load `getCurrentAuthContext()`.
|
||||||
|
2. Redirect unauthenticated users to `/login`.
|
||||||
|
3. Redirect users without `care:read` to the workspace dashboard.
|
||||||
|
4. Require an active organization.
|
||||||
|
5. Load care data through a focused server helper.
|
||||||
|
6. Render a client workspace with `canManage` based on `care:manage`.
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
Add one MVP table: `care_tasks`.
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `organizationId`
|
||||||
|
- `elderId` nullable, because some tasks can be room/public-area checks later
|
||||||
|
- `title`
|
||||||
|
- `careType`: `daily_care | meal | medication | rehab | inspection | cleaning | other`
|
||||||
|
- `priority`: `low | normal | high | urgent`
|
||||||
|
- `status`: `pending | in_progress | completed | cancelled`
|
||||||
|
- `scheduledAt`
|
||||||
|
- `assigneeLabel`
|
||||||
|
- `executionNotes`
|
||||||
|
- `completedAt`
|
||||||
|
- `createdByAccountId`
|
||||||
|
- `completedByAccountId`
|
||||||
|
- `createdAt`
|
||||||
|
- `updatedAt`
|
||||||
|
|
||||||
|
Indexes:
|
||||||
|
|
||||||
|
- `(organizationId, status, priority)` for queues.
|
||||||
|
- `(organizationId, scheduledAt)` for daily schedule ordering.
|
||||||
|
|
||||||
|
## Server Helpers
|
||||||
|
|
||||||
|
Create `modules/care/`:
|
||||||
|
|
||||||
|
- `modules/care/types.ts`
|
||||||
|
- enum values, labels, DTOs, validators.
|
||||||
|
- `modules/care/server/operations.ts`
|
||||||
|
- `listCareExecutionData(organizationId: string)`
|
||||||
|
- `updateCareTaskStatus(input)`
|
||||||
|
- `modules/care/components/CareWorkspaceClient.tsx`
|
||||||
|
- dense tabs/filters/table and status action dialogs.
|
||||||
|
|
||||||
|
Reads should batch the care tasks and elder names in one query shape. Mutations must filter by `organizationId` and update by task ID plus organization ID.
|
||||||
|
|
||||||
|
## API Design
|
||||||
|
|
||||||
|
- `GET /api/care/tasks`
|
||||||
|
- permission: `care:read`
|
||||||
|
- response: `{ success: true; reason: string; data: CareExecutionData }`
|
||||||
|
- `PATCH /api/care/tasks/[id]`
|
||||||
|
- permission: `care:manage`
|
||||||
|
- request: `{ status: "pending" | "in_progress" | "completed" | "cancelled"; executionNotes?: string }`
|
||||||
|
- completion sets `completedAt` and `completedByAccountId`
|
||||||
|
- moving out of completed clears completion metadata
|
||||||
|
- records audit log
|
||||||
|
|
||||||
|
All failures use `{ success: false; reason: string }`.
|
||||||
|
|
||||||
|
## UI Design
|
||||||
|
|
||||||
|
The care workspace should be operational and compact:
|
||||||
|
|
||||||
|
- Metrics: pending, in-progress, completed today, high/urgent.
|
||||||
|
- Filters: status, priority, care type, search by elder/title/assignee.
|
||||||
|
- Table columns: task, elder, type, priority, status, scheduled time, assignee, completed time, actions.
|
||||||
|
- Actions:
|
||||||
|
- pending -> start
|
||||||
|
- in-progress/pending -> complete with notes
|
||||||
|
- completed/cancelled are visible but not primary work queue items
|
||||||
|
- Users without `care:manage` can view but action buttons are disabled or hidden.
|
||||||
|
|
||||||
|
## Default Workspace Data
|
||||||
|
|
||||||
|
Extend `seedDefaultWorkspaceData()` with care tasks after seeded elders exist. Examples should cover:
|
||||||
|
|
||||||
|
- pending morning care
|
||||||
|
- in-progress rehabilitation or inspection
|
||||||
|
- completed meal/medication task
|
||||||
|
- urgent/high overdue-style task scheduled in the past
|
||||||
|
|
||||||
|
## Compatibility And Rollback
|
||||||
|
|
||||||
|
- Existing `/app/care` placeholder is replaced only for this module.
|
||||||
|
- New tables are additive; rollback is dropping generated care migration and removing `modules/care`, care API routes, and route/page changes.
|
||||||
|
- Dashboard integration remains out of scope until the dedicated dashboard task.
|
||||||
@@ -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,61 @@
|
|||||||
|
# Implementation Plan
|
||||||
|
|
||||||
|
## 1. Schema And Migration
|
||||||
|
|
||||||
|
- Add care enums and `care_tasks` table to `modules/core/server/schema.ts`.
|
||||||
|
- Add indexes for queue and schedule queries.
|
||||||
|
- Run `pnpm db:generate`.
|
||||||
|
- Review generated SQL.
|
||||||
|
|
||||||
|
## 2. Types And Permissions
|
||||||
|
|
||||||
|
- Add `care:read` and `care:manage` to `modules/core/types.ts`.
|
||||||
|
- Add permission definitions and default role grants in `modules/core/server/permissions.ts`.
|
||||||
|
- Move navigation `/care` permission to `care:read`.
|
||||||
|
- Add care DTOs, labels, and validators in `modules/care/types.ts`.
|
||||||
|
|
||||||
|
## 3. TDD API Tests
|
||||||
|
|
||||||
|
- Add `app/api/care/care-routes.test.ts` before implementation.
|
||||||
|
- Cover:
|
||||||
|
- list care tasks
|
||||||
|
- permission denial
|
||||||
|
- missing active organization
|
||||||
|
- start task
|
||||||
|
- complete task with notes
|
||||||
|
- invalid status
|
||||||
|
- missing/cross-organization task
|
||||||
|
|
||||||
|
## 4. Server Operations And API Routes
|
||||||
|
|
||||||
|
- Add `modules/care/server/operations.ts`.
|
||||||
|
- Add `GET /api/care/tasks`.
|
||||||
|
- Add `PATCH /api/care/tasks/[id]`.
|
||||||
|
- Use `requirePermission`, structured failures, organization scoping, and audit logs.
|
||||||
|
|
||||||
|
## 5. Workspace UI
|
||||||
|
|
||||||
|
- Replace `app/(app)/app/care/page.tsx` placeholder with real Server Component.
|
||||||
|
- Keep scoped route delegating to the unscoped route.
|
||||||
|
- Add `modules/care/components/CareWorkspaceClient.tsx`.
|
||||||
|
- Implement metrics, filters, table, and start/complete actions.
|
||||||
|
|
||||||
|
## 6. Default Workspace Data
|
||||||
|
|
||||||
|
- Extend `modules/core/server/default-workspace-data.ts` with care task examples tied to seeded elders.
|
||||||
|
- Keep seed insertion inside the first-run default workspace transaction.
|
||||||
|
|
||||||
|
## 7. Verification
|
||||||
|
|
||||||
|
- `pnpm test`
|
||||||
|
- `pnpm lint`
|
||||||
|
- `pnpm type-check`
|
||||||
|
- `pnpm db:generate`
|
||||||
|
- Review generated SQL.
|
||||||
|
- `pnpm build`
|
||||||
|
|
||||||
|
## Rollback Points
|
||||||
|
|
||||||
|
- Before migration generation: remove schema and care module files.
|
||||||
|
- After migration generation: remove generated care migration plus schema changes.
|
||||||
|
- After UI/API: remove care API routes, `modules/care`, page changes, permissions, nav permission change, and seed rows.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"id": "care-execution-workspace",
|
||||||
|
"name": "care-execution-workspace",
|
||||||
|
"title": "护理服务执行工作台",
|
||||||
|
"description": "",
|
||||||
|
"status": "completed",
|
||||||
|
"dev_type": null,
|
||||||
|
"scope": null,
|
||||||
|
"package": null,
|
||||||
|
"priority": "P2",
|
||||||
|
"creator": "TalexDreamSoul",
|
||||||
|
"assignee": "TalexDreamSoul",
|
||||||
|
"createdAt": "2026-07-02",
|
||||||
|
"completedAt": "2026-07-03",
|
||||||
|
"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,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."}
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"id": "care-service-workspace",
|
||||||
|
"name": "care-service-workspace",
|
||||||
|
"title": "新增健康照护后台管理页",
|
||||||
|
"description": "新增独立的健康数据管理后台页面,与现有运营健康照护入口分开。",
|
||||||
|
"status": "completed",
|
||||||
|
"dev_type": null,
|
||||||
|
"scope": null,
|
||||||
|
"package": null,
|
||||||
|
"priority": "P2",
|
||||||
|
"creator": "TalexDreamSoul",
|
||||||
|
"assignee": "TalexDreamSoul",
|
||||||
|
"createdAt": "2026-07-02",
|
||||||
|
"completedAt": "2026-07-03",
|
||||||
|
"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,72 @@
|
|||||||
|
# Elder Bed Context Optimization Design
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add read-only cross-module context to the existing elder and bed workspaces. The implementation should not merge workflows; it only surfaces concise recent signals from persisted admissions, care tasks, health records, and emergency incidents.
|
||||||
|
|
||||||
|
## Data Boundary
|
||||||
|
|
||||||
|
Use existing persisted tables:
|
||||||
|
|
||||||
|
- admissions / beds / rooms for current bed state.
|
||||||
|
- care_tasks for recent and active care execution.
|
||||||
|
- vital_records and health_anomaly_reviews for health context.
|
||||||
|
- system_incidents for safety/emergency context.
|
||||||
|
|
||||||
|
No schema change is required. `system_incidents` does not have elder/bed foreign keys yet, so emergency context is shown only when an incident's title, description, or source clearly mentions the elder name, bed code, or room label.
|
||||||
|
|
||||||
|
## Permission Boundary
|
||||||
|
|
||||||
|
Server pages decide which context to load:
|
||||||
|
|
||||||
|
- `admission:read` / `facility:read`: bed/admission context.
|
||||||
|
- `care:read`: recent care task context.
|
||||||
|
- `health:read`: health anomalies and latest vitals.
|
||||||
|
- `incident:read`: matching open/acknowledged emergency context.
|
||||||
|
|
||||||
|
Users without a permission should not receive that context in props.
|
||||||
|
|
||||||
|
## Server Helper
|
||||||
|
|
||||||
|
Add `modules/operations/server/context.ts`:
|
||||||
|
|
||||||
|
- `listElderBedContextData(input)`
|
||||||
|
- input:
|
||||||
|
- `organizationId`
|
||||||
|
- `permissions`
|
||||||
|
- `elders`
|
||||||
|
- `beds`
|
||||||
|
- `admissions`
|
||||||
|
- output:
|
||||||
|
- `elderContexts: Record<string, ElderOperationalContext>`
|
||||||
|
- `bedContexts: Record<string, BedOperationalContext>`
|
||||||
|
|
||||||
|
The helper should batch queries and keep bounded result sizes.
|
||||||
|
|
||||||
|
## UI Design
|
||||||
|
|
||||||
|
### Elder list
|
||||||
|
|
||||||
|
Add a compact "近期联动" column:
|
||||||
|
|
||||||
|
- current bed/admission is already shown in the bed column.
|
||||||
|
- show up to three concise lines:
|
||||||
|
- latest active/recent care task.
|
||||||
|
- latest health anomaly or vital summary.
|
||||||
|
- matching open/acknowledged emergency event.
|
||||||
|
- If no permitted context exists, show `-`.
|
||||||
|
|
||||||
|
### Bed workspace
|
||||||
|
|
||||||
|
Add an occupant context column to the bed status table:
|
||||||
|
|
||||||
|
- show current elder care level/status.
|
||||||
|
- show active/recent care task for that occupant.
|
||||||
|
- show matching emergency event for occupant or bed when present.
|
||||||
|
- Keep actions in the existing workflows; context is read-only.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
- Existing elder CRUD remains unchanged.
|
||||||
|
- Existing admission workflows remain unchanged.
|
||||||
|
- Client refresh continues to update elder/bed/admission tables; cross-module snippets are refreshed on page reload.
|
||||||
@@ -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,38 @@
|
|||||||
|
# Implementation Plan
|
||||||
|
|
||||||
|
## 1. Start Task And Read Specs
|
||||||
|
|
||||||
|
- Start the Trellis task.
|
||||||
|
- Reuse existing frontend/backend specs.
|
||||||
|
- Preserve existing uncommitted UI cleanup in elder and bed components.
|
||||||
|
|
||||||
|
## 2. Types And Server Helper
|
||||||
|
|
||||||
|
- Add context DTO types in `modules/operations/types.ts`.
|
||||||
|
- Add `modules/operations/server/context.ts`.
|
||||||
|
- Batch-load care, health, and incident context based on permissions.
|
||||||
|
- Avoid fabricating emergency links; match text only when the persisted incident names the elder/bed/room.
|
||||||
|
|
||||||
|
## 3. Wire Pages
|
||||||
|
|
||||||
|
- Update `app/(app)/app/elders/page.tsx` to load and pass `elderContexts`.
|
||||||
|
- Update `app/(app)/app/beds/page.tsx` to load and pass `bedContexts`.
|
||||||
|
|
||||||
|
## 4. Update Client Components
|
||||||
|
|
||||||
|
- Extend `EldersClient` with optional `elderContexts`.
|
||||||
|
- Extend `BedsWorkspace` with optional `bedContexts`.
|
||||||
|
- Render compact read-only context lines.
|
||||||
|
- Preserve existing CRUD/admission behavior.
|
||||||
|
|
||||||
|
## 5. Verification
|
||||||
|
|
||||||
|
- `pnpm lint`
|
||||||
|
- `pnpm type-check`
|
||||||
|
- `pnpm test`
|
||||||
|
- `pnpm build`
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
- Remove operations context helper/types.
|
||||||
|
- Remove context props and columns from elder/bed clients/pages.
|
||||||
@@ -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": "completed",
|
||||||
|
"dev_type": null,
|
||||||
|
"scope": null,
|
||||||
|
"package": null,
|
||||||
|
"priority": "P2",
|
||||||
|
"creator": "TalexDreamSoul",
|
||||||
|
"assignee": "TalexDreamSoul",
|
||||||
|
"createdAt": "2026-07-02",
|
||||||
|
"completedAt": "2026-07-03",
|
||||||
|
"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,80 @@
|
|||||||
|
# Emergency Incident Workspace Design
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Replace `/app/emergency` with a real safety/emergency incident workspace using the existing `systemIncidents` Drizzle table. The MVP covers manual event creation, status triage, filters, and metrics.
|
||||||
|
|
||||||
|
## Route Boundary
|
||||||
|
|
||||||
|
- Unscoped route: `app/(app)/app/emergency/page.tsx`
|
||||||
|
- Scoped route: `app/(app)/app/[organizationSlug]/emergency/page.tsx`
|
||||||
|
- Navigation remains under `运营`.
|
||||||
|
- Permissions:
|
||||||
|
- read: `incident:read`
|
||||||
|
- mutate: `incident:manage`
|
||||||
|
|
||||||
|
Server Component behavior:
|
||||||
|
|
||||||
|
1. Load `getCurrentAuthContext()`.
|
||||||
|
2. Redirect unauthenticated users to `/login`.
|
||||||
|
3. Redirect users without `incident:read` to dashboard.
|
||||||
|
4. Require active organization.
|
||||||
|
5. Load incidents through `listEmergencyIncidentData(organizationId)`.
|
||||||
|
6. Render client workspace with `canManage`.
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
Use existing `system_incidents`:
|
||||||
|
|
||||||
|
- `organizationId`
|
||||||
|
- `severity`: `info | warning | critical`
|
||||||
|
- `status`: `open | acknowledged | resolved | closed`
|
||||||
|
- `title`
|
||||||
|
- `description`
|
||||||
|
- `source`
|
||||||
|
- acknowledgement / resolution account and time fields
|
||||||
|
|
||||||
|
No schema change is required for MVP. Elder/bed linking stays future work for the context optimization task.
|
||||||
|
|
||||||
|
## Server Helpers
|
||||||
|
|
||||||
|
Create `modules/emergency/`:
|
||||||
|
|
||||||
|
- `modules/emergency/types.ts`
|
||||||
|
- labels, DTOs, create/status validators.
|
||||||
|
- `modules/emergency/server/operations.ts`
|
||||||
|
- `listEmergencyIncidentData(organizationId: string)`
|
||||||
|
- `createEmergencyIncident(input)`
|
||||||
|
- `updateEmergencyIncidentStatus(input)`
|
||||||
|
- `modules/emergency/components/EmergencyWorkspaceClient.tsx`
|
||||||
|
- metrics, filters, event table, create dialog, status actions.
|
||||||
|
|
||||||
|
## API Design
|
||||||
|
|
||||||
|
- `GET /api/emergency/incidents`
|
||||||
|
- permission: `incident:read`
|
||||||
|
- response: `{ success: true; reason: string; data }`
|
||||||
|
- `POST /api/emergency/incidents`
|
||||||
|
- permission: `incident:manage`
|
||||||
|
- request: `{ severity; title; description; source }`
|
||||||
|
- creates an organization-scoped incident
|
||||||
|
- `PATCH /api/emergency/incidents/[id]`
|
||||||
|
- permission: `incident:manage`
|
||||||
|
- request: `{ status }`
|
||||||
|
- updates only rows in the active organization
|
||||||
|
|
||||||
|
All create/update mutations write audit logs after successful persistence.
|
||||||
|
|
||||||
|
## UI Design
|
||||||
|
|
||||||
|
- Metrics: open, acknowledged, critical, resolved/closed today.
|
||||||
|
- Filters: status, severity, search by title/source/description.
|
||||||
|
- Dense table columns: event, severity, status, source, created time, updated time, actions.
|
||||||
|
- Actions: acknowledge, resolve, close.
|
||||||
|
- Manual creation dialog for authorized operators.
|
||||||
|
- Read-only users can see rows but not mutation controls.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
- Existing `/api/system/incidents/[id]` stays for system status settings.
|
||||||
|
- The emergency workspace uses dedicated `/api/emergency/...` routes so tests and UI contracts are local to the operational module.
|
||||||
@@ -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,38 @@
|
|||||||
|
# Implementation Plan
|
||||||
|
|
||||||
|
## 1. Types And Tests
|
||||||
|
|
||||||
|
- Add emergency DTOs and validators.
|
||||||
|
- Add `app/api/emergency/emergency-routes.test.ts` first.
|
||||||
|
- Cover list, create, status update, permission denial, missing organization, invalid input, and missing/cross-organization incident.
|
||||||
|
|
||||||
|
## 2. Server Operations And API Routes
|
||||||
|
|
||||||
|
- Add `modules/emergency/server/operations.ts`.
|
||||||
|
- Add `GET`/`POST /api/emergency/incidents`.
|
||||||
|
- Add `PATCH /api/emergency/incidents/[id]`.
|
||||||
|
- Use `requirePermission`, active organization checks, structured failures, and audit logs.
|
||||||
|
|
||||||
|
## 3. Workspace UI
|
||||||
|
|
||||||
|
- Replace `app/(app)/app/emergency/page.tsx`.
|
||||||
|
- Keep scoped route delegating to the unscoped route.
|
||||||
|
- Add `modules/emergency/components/EmergencyWorkspaceClient.tsx`.
|
||||||
|
- Implement metrics, filters, create dialog, and status action buttons.
|
||||||
|
|
||||||
|
## 4. Default Data
|
||||||
|
|
||||||
|
- Existing default workspace already inserts representative `systemIncidents`.
|
||||||
|
- Review whether the seed includes open/acknowledged/resolved and critical/warning/info; adjust only if needed.
|
||||||
|
|
||||||
|
## 5. Verification
|
||||||
|
|
||||||
|
- `pnpm test`
|
||||||
|
- `pnpm lint`
|
||||||
|
- `pnpm type-check`
|
||||||
|
- `pnpm build`
|
||||||
|
|
||||||
|
## Rollback Points
|
||||||
|
|
||||||
|
- Remove `modules/emergency`, emergency API routes, and page changes.
|
||||||
|
- No migration rollback expected unless implementation discovers schema changes are required.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"id": "emergency-incident-workspace",
|
||||||
|
"name": "emergency-incident-workspace",
|
||||||
|
"title": "安全应急事件工作台",
|
||||||
|
"description": "",
|
||||||
|
"status": "completed",
|
||||||
|
"dev_type": null,
|
||||||
|
"scope": null,
|
||||||
|
"package": null,
|
||||||
|
"priority": "P2",
|
||||||
|
"creator": "TalexDreamSoul",
|
||||||
|
"assignee": "TalexDreamSoul",
|
||||||
|
"createdAt": "2026-07-02",
|
||||||
|
"completedAt": "2026-07-03",
|
||||||
|
"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,40 @@
|
|||||||
|
# Operations Dashboard Integration Design
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Upgrade the dashboard into a cross-module operations summary now that health, care, emergency, elder, bed, and admission data are persisted.
|
||||||
|
|
||||||
|
## Data Boundary
|
||||||
|
|
||||||
|
The dashboard remains a Server Component:
|
||||||
|
|
||||||
|
- elder/bed/admission data from `modules/core/server/operations.ts`
|
||||||
|
- health data from `listHealthAdminData`
|
||||||
|
- care data from `listCareExecutionData`
|
||||||
|
- emergency data from `listEmergencyIncidentData`
|
||||||
|
|
||||||
|
Load optional module data only when the viewer has the corresponding read permission. Do not fabricate metrics when permission or data is absent.
|
||||||
|
|
||||||
|
## UI Boundary
|
||||||
|
|
||||||
|
`DashboardHome` stays presentational. It receives:
|
||||||
|
|
||||||
|
- primary metrics
|
||||||
|
- bed occupancy chart data
|
||||||
|
- recent admissions
|
||||||
|
- work queues for health, care, and emergency
|
||||||
|
|
||||||
|
Dashboard items should link into modules:
|
||||||
|
|
||||||
|
- health reviews -> `/settings/health`
|
||||||
|
- care tasks -> `/care`
|
||||||
|
- emergency events -> `/emergency`
|
||||||
|
- admissions -> `/beds`
|
||||||
|
|
||||||
|
The dashboard is not a mutation surface.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
- Existing bed occupancy chart remains data-backed.
|
||||||
|
- Existing admission list remains data-backed.
|
||||||
|
- Top badge-style tag should be removed to match the module cleanup direction.
|
||||||
@@ -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,29 @@
|
|||||||
|
# Implementation Plan
|
||||||
|
|
||||||
|
## 1. Start Task
|
||||||
|
|
||||||
|
- Start Trellis task.
|
||||||
|
- Preserve unrelated dirty settings/dialog files.
|
||||||
|
|
||||||
|
## 2. Server Data
|
||||||
|
|
||||||
|
- Update dashboard page to load health/care/emergency summaries when permitted.
|
||||||
|
- Keep all reads organization-scoped.
|
||||||
|
- Build route hrefs with `getWorkspaceHref`.
|
||||||
|
|
||||||
|
## 3. Presentational UI
|
||||||
|
|
||||||
|
- Extend `DashboardHome` props for health reviews, care tasks, and emergency events.
|
||||||
|
- Add compact queue sections with links to modules.
|
||||||
|
- Remove top badge tag.
|
||||||
|
|
||||||
|
## 4. Verification
|
||||||
|
|
||||||
|
- `pnpm lint`
|
||||||
|
- `pnpm type-check`
|
||||||
|
- `pnpm test`
|
||||||
|
- `pnpm build`
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
- Revert dashboard page and `DashboardHome` changes.
|
||||||
@@ -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": "completed",
|
||||||
|
"dev_type": null,
|
||||||
|
"scope": null,
|
||||||
|
"package": null,
|
||||||
|
"priority": "P2",
|
||||||
|
"creator": "TalexDreamSoul",
|
||||||
|
"assignee": "TalexDreamSoul",
|
||||||
|
"createdAt": "2026-07-02",
|
||||||
|
"completedAt": "2026-07-03",
|
||||||
|
"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,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."}
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"id": "operations-module-roadmap",
|
||||||
|
"name": "operations-module-roadmap",
|
||||||
|
"title": "运营模块完善总计划",
|
||||||
|
"description": "",
|
||||||
|
"status": "completed",
|
||||||
|
"dev_type": null,
|
||||||
|
"scope": null,
|
||||||
|
"package": null,
|
||||||
|
"priority": "P2",
|
||||||
|
"creator": "TalexDreamSoul",
|
||||||
|
"assignee": "TalexDreamSoul",
|
||||||
|
"createdAt": "2026-07-02",
|
||||||
|
"completedAt": "2026-07-03",
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
@@ -8,8 +8,8 @@
|
|||||||
|
|
||||||
<!-- @@@auto:current-status -->
|
<!-- @@@auto:current-status -->
|
||||||
- **Active File**: `journal-1.md`
|
- **Active File**: `journal-1.md`
|
||||||
- **Total Sessions**: 0
|
- **Total Sessions**: 9
|
||||||
- **Last Active**: -
|
- **Last Active**: 2026-07-03
|
||||||
<!-- @@@/auto:current-status -->
|
<!-- @@@/auto:current-status -->
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
<!-- @@@auto:active-documents -->
|
<!-- @@@auto:active-documents -->
|
||||||
| File | Lines | Status |
|
| File | Lines | Status |
|
||||||
|------|-------|--------|
|
|------|-------|--------|
|
||||||
| `journal-1.md` | ~0 | Active |
|
| `journal-1.md` | ~316 | Active |
|
||||||
<!-- @@@/auto:active-documents -->
|
<!-- @@@/auto:active-documents -->
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -29,6 +29,15 @@
|
|||||||
<!-- @@@auto:session-history -->
|
<!-- @@@auto:session-history -->
|
||||||
| # | Date | Title | Commits | Branch |
|
| # | Date | Title | Commits | Branch |
|
||||||
|---|------|-------|---------|--------|
|
|---|------|-------|---------|--------|
|
||||||
|
| 9 | 2026-07-03 | Settings dialog layout cleanup | `d240461` | `main` |
|
||||||
|
| 8 | 2026-07-03 | Operations Roadmap Complete | `41807ff`, `b6b15ac`, `a0e50a9`, `0ce8cb9`, `9d73457` | `main` |
|
||||||
|
| 7 | 2026-07-03 | Operations Dashboard Integration | `9d73457` | `main` |
|
||||||
|
| 6 | 2026-07-03 | Elder Bed Operational Context | `0ce8cb9` | `main` |
|
||||||
|
| 5 | 2026-07-03 | Emergency Incident Workspace | `a0e50a9` | `main` |
|
||||||
|
| 4 | 2026-07-03 | Care Execution Workspace | `b6b15ac` | `main` |
|
||||||
|
| 3 | 2026-07-03 | Health Data Management Workspace | `41807ff` | `main` |
|
||||||
|
| 2 | 2026-07-02 | Organization-scoped workspace routes | `3ab0e3e` | `main` |
|
||||||
|
| 1 | 2026-07-02 | Settings real data and control cleanup | `1cdf89c`, `5412bbb`, `affad1f` | `main` |
|
||||||
<!-- @@@/auto:session-history -->
|
<!-- @@@/auto:session-history -->
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -5,3 +5,312 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Session 1: Settings real data and control cleanup
|
||||||
|
|
||||||
|
**Date**: 2026-07-02
|
||||||
|
**Task**: Settings real data and control cleanup
|
||||||
|
**Branch**: `main`
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Removed fake settings surfaces, added real settings query helpers, stabilized account workspace controls, global settings tabs, checkbox/dialog styling, then deployed to wlcb1.
|
||||||
|
|
||||||
|
### Main Changes
|
||||||
|
|
||||||
|
- Added authenticated organization switching and current-account profile APIs backed by Drizzle.
|
||||||
|
- Moved settings reads from the compatibility `readData()` model into focused settings query helpers.
|
||||||
|
- Removed repeated settings page title blocks and simplified reserved module pages to honest not-connected states.
|
||||||
|
- Converted global settings to tabs and stabilized checkbox/dialog component geometry.
|
||||||
|
|
||||||
|
### Git Commits
|
||||||
|
|
||||||
|
| Hash | Message |
|
||||||
|
|------|---------|
|
||||||
|
| `1cdf89c` | (see git log) |
|
||||||
|
| `5412bbb` | (see git log) |
|
||||||
|
| `affad1f` | (see git log) |
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- [OK] `pnpm lint`
|
||||||
|
- [OK] `pnpm type-check`
|
||||||
|
- [OK] `pnpm build`
|
||||||
|
- [OK] Production deploy to `wlcb1`, with `/api/auth/bootstrap`, `/login`, protected settings redirect, and systemd health verified.
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
[OK] **Committed, pushed, and deployed**
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
|
||||||
|
- Keep the broader CRUD/RBAC audit task active for the next operational slice.
|
||||||
|
|
||||||
|
|
||||||
|
## Session 2: Organization-scoped workspace routes
|
||||||
|
|
||||||
|
**Date**: 2026-07-02
|
||||||
|
**Task**: Organization-scoped workspace routes
|
||||||
|
**Branch**: `main`
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Added organization slug workspace URLs, permission-aware navigation, reserved slug validation, family/resident role concepts, and Trellis specs for the routing contract.
|
||||||
|
|
||||||
|
### Main Changes
|
||||||
|
|
||||||
|
(Add details)
|
||||||
|
|
||||||
|
### Git Commits
|
||||||
|
|
||||||
|
| Hash | Message |
|
||||||
|
|------|---------|
|
||||||
|
| `3ab0e3e` | (see git log) |
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- [OK] (Add test results)
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
[OK] **Completed**
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
|
||||||
|
- None - task complete
|
||||||
|
|
||||||
|
|
||||||
|
## Session 3: Health Data Management Workspace
|
||||||
|
|
||||||
|
**Date**: 2026-07-03
|
||||||
|
**Task**: Health Data Management Workspace
|
||||||
|
**Branch**: `main`
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Added persisted health admin tables, APIs, settings workspace, seeded demo health records, route tests, migration, and backend contract spec.
|
||||||
|
|
||||||
|
### Main Changes
|
||||||
|
|
||||||
|
(Add details)
|
||||||
|
|
||||||
|
### Git Commits
|
||||||
|
|
||||||
|
| Hash | Message |
|
||||||
|
|------|---------|
|
||||||
|
| `41807ff` | (see git log) |
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- [OK] (Add test results)
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
[OK] **Completed**
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
|
||||||
|
- None - task complete
|
||||||
|
|
||||||
|
|
||||||
|
## Session 4: Care Execution Workspace
|
||||||
|
|
||||||
|
**Date**: 2026-07-03
|
||||||
|
**Task**: Care Execution Workspace
|
||||||
|
**Branch**: `main`
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Replaced care placeholder with persisted care task workspace, permissions, APIs, route tests, seed data, migration, and backend contract spec.
|
||||||
|
|
||||||
|
### Main Changes
|
||||||
|
|
||||||
|
(Add details)
|
||||||
|
|
||||||
|
### Git Commits
|
||||||
|
|
||||||
|
| Hash | Message |
|
||||||
|
|------|---------|
|
||||||
|
| `b6b15ac` | (see git log) |
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- [OK] (Add test results)
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
[OK] **Completed**
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
|
||||||
|
- None - task complete
|
||||||
|
|
||||||
|
|
||||||
|
## Session 5: Emergency Incident Workspace
|
||||||
|
|
||||||
|
**Date**: 2026-07-03
|
||||||
|
**Task**: Emergency Incident Workspace
|
||||||
|
**Branch**: `main`
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Replaced emergency placeholder with persisted incident workspace, dedicated APIs, route tests, manual creation, status actions, and real scoped data.
|
||||||
|
|
||||||
|
### Main Changes
|
||||||
|
|
||||||
|
(Add details)
|
||||||
|
|
||||||
|
### Git Commits
|
||||||
|
|
||||||
|
| Hash | Message |
|
||||||
|
|------|---------|
|
||||||
|
| `a0e50a9` | (see git log) |
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- [OK] (Add test results)
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
[OK] **Completed**
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
|
||||||
|
- None - task complete
|
||||||
|
|
||||||
|
|
||||||
|
## Session 6: Elder Bed Operational Context
|
||||||
|
|
||||||
|
**Date**: 2026-07-03
|
||||||
|
**Task**: Elder Bed Operational Context
|
||||||
|
**Branch**: `main`
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Added read-only cross-module context for elder and bed workspaces using admission, care, health, and emergency data with permission-aware server loading.
|
||||||
|
|
||||||
|
### Main Changes
|
||||||
|
|
||||||
|
(Add details)
|
||||||
|
|
||||||
|
### Git Commits
|
||||||
|
|
||||||
|
| Hash | Message |
|
||||||
|
|------|---------|
|
||||||
|
| `0ce8cb9` | (see git log) |
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- [OK] (Add test results)
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
[OK] **Completed**
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
|
||||||
|
- None - task complete
|
||||||
|
|
||||||
|
|
||||||
|
## Session 7: Operations Dashboard Integration
|
||||||
|
|
||||||
|
**Date**: 2026-07-03
|
||||||
|
**Task**: Operations Dashboard Integration
|
||||||
|
**Branch**: `main`
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Integrated dashboard summaries for health reviews, care tasks, emergency events, bed occupancy, and admissions with module links and persisted server data.
|
||||||
|
|
||||||
|
### Main Changes
|
||||||
|
|
||||||
|
(Add details)
|
||||||
|
|
||||||
|
### Git Commits
|
||||||
|
|
||||||
|
| Hash | Message |
|
||||||
|
|------|---------|
|
||||||
|
| `9d73457` | (see git log) |
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- [OK] (Add test results)
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
[OK] **Completed**
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
|
||||||
|
- None - task complete
|
||||||
|
|
||||||
|
|
||||||
|
## Session 8: Operations Roadmap Complete
|
||||||
|
|
||||||
|
**Date**: 2026-07-03
|
||||||
|
**Task**: Operations Roadmap Complete
|
||||||
|
**Branch**: `main`
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Completed all five operations roadmap children: health management, care execution, emergency incidents, elder/bed context, and dashboard integration.
|
||||||
|
|
||||||
|
### Main Changes
|
||||||
|
|
||||||
|
(Add details)
|
||||||
|
|
||||||
|
### Git Commits
|
||||||
|
|
||||||
|
| Hash | Message |
|
||||||
|
|------|---------|
|
||||||
|
| `41807ff` | (see git log) |
|
||||||
|
| `b6b15ac` | (see git log) |
|
||||||
|
| `a0e50a9` | (see git log) |
|
||||||
|
| `0ce8cb9` | (see git log) |
|
||||||
|
| `9d73457` | (see git log) |
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- [OK] (Add test results)
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
[OK] **Completed**
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
|
||||||
|
- None - task complete
|
||||||
|
|
||||||
|
|
||||||
|
## Session 9: Settings dialog layout cleanup
|
||||||
|
|
||||||
|
**Date**: 2026-07-03
|
||||||
|
**Task**: Settings dialog layout cleanup
|
||||||
|
**Branch**: `main`
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Refined global settings invitation content and fixed settings dialog sizing/centering after UI review.
|
||||||
|
|
||||||
|
### Main Changes
|
||||||
|
|
||||||
|
(Add details)
|
||||||
|
|
||||||
|
### Git Commits
|
||||||
|
|
||||||
|
| Hash | Message |
|
||||||
|
|------|---------|
|
||||||
|
| `d240461` | (see git log) |
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- [OK] (Add test results)
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
[OK] **Completed**
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
|
||||||
|
- None - task complete
|
||||||
|
|||||||
5
app/(app)/app/[organizationSlug]/alerts/page.tsx
Normal file
5
app/(app)/app/[organizationSlug]/alerts/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { AlertsModulePage } from "@/modules/shared/components/ReservedModulePages";
|
||||||
|
|
||||||
|
export default function ScopedAlertsPage(): React.ReactElement {
|
||||||
|
return <AlertsModulePage />;
|
||||||
|
}
|
||||||
5
app/(app)/app/[organizationSlug]/beds/page.tsx
Normal file
5
app/(app)/app/[organizationSlug]/beds/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import BedsPage from "../../beds/page";
|
||||||
|
|
||||||
|
export default async function ScopedBedsPage(): Promise<React.ReactElement> {
|
||||||
|
return BedsPage();
|
||||||
|
}
|
||||||
5
app/(app)/app/[organizationSlug]/care/page.tsx
Normal file
5
app/(app)/app/[organizationSlug]/care/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import CarePage from "../../care/page";
|
||||||
|
|
||||||
|
export default async function ScopedCarePage(): Promise<React.ReactElement> {
|
||||||
|
return CarePage();
|
||||||
|
}
|
||||||
5
app/(app)/app/[organizationSlug]/dashboard/page.tsx
Normal file
5
app/(app)/app/[organizationSlug]/dashboard/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import DashboardPage from "../../dashboard/page";
|
||||||
|
|
||||||
|
export default async function ScopedDashboardPage(): Promise<React.ReactElement> {
|
||||||
|
return DashboardPage();
|
||||||
|
}
|
||||||
5
app/(app)/app/[organizationSlug]/devices/page.tsx
Normal file
5
app/(app)/app/[organizationSlug]/devices/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { DevicesModulePage } from "@/modules/shared/components/ReservedModulePages";
|
||||||
|
|
||||||
|
export default function ScopedDevicesPage(): React.ReactElement {
|
||||||
|
return <DevicesModulePage />;
|
||||||
|
}
|
||||||
5
app/(app)/app/[organizationSlug]/elders/page.tsx
Normal file
5
app/(app)/app/[organizationSlug]/elders/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import EldersPage from "../../elders/page";
|
||||||
|
|
||||||
|
export default async function ScopedEldersPage(): Promise<React.ReactElement> {
|
||||||
|
return EldersPage();
|
||||||
|
}
|
||||||
5
app/(app)/app/[organizationSlug]/emergency/page.tsx
Normal file
5
app/(app)/app/[organizationSlug]/emergency/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import EmergencyPage from "../../emergency/page";
|
||||||
|
|
||||||
|
export default async function ScopedEmergencyPage(): Promise<React.ReactElement> {
|
||||||
|
return EmergencyPage();
|
||||||
|
}
|
||||||
5
app/(app)/app/[organizationSlug]/family/page.tsx
Normal file
5
app/(app)/app/[organizationSlug]/family/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { FamilyModulePage } from "@/modules/shared/components/ReservedModulePages";
|
||||||
|
|
||||||
|
export default function ScopedFamilyPage(): React.ReactElement {
|
||||||
|
return <FamilyModulePage />;
|
||||||
|
}
|
||||||
5
app/(app)/app/[organizationSlug]/health/page.tsx
Normal file
5
app/(app)/app/[organizationSlug]/health/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { renderHealthWorkspacePage } from "@/modules/health/components/HealthWorkspacePage";
|
||||||
|
|
||||||
|
export default async function ScopedHealthPage(): Promise<React.ReactElement> {
|
||||||
|
return renderHealthWorkspacePage();
|
||||||
|
}
|
||||||
28
app/(app)/app/[organizationSlug]/layout.tsx
Normal file
28
app/(app)/app/[organizationSlug]/layout.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
|
type OrganizationWorkspaceLayoutProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{
|
||||||
|
organizationSlug: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function OrganizationWorkspaceLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: OrganizationWorkspaceLayoutProps): Promise<React.ReactElement> {
|
||||||
|
const [{ organizationSlug }, context] = await Promise.all([params, getCurrentAuthContext()]);
|
||||||
|
if (!context) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeSlug = context.organization?.slug;
|
||||||
|
if (activeSlug && activeSlug !== organizationSlug) {
|
||||||
|
redirect(getWorkspaceHref(activeSlug, "/dashboard"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
5
app/(app)/app/[organizationSlug]/notices/page.tsx
Normal file
5
app/(app)/app/[organizationSlug]/notices/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { NoticesModulePage } from "@/modules/shared/components/ReservedModulePages";
|
||||||
|
|
||||||
|
export default function ScopedNoticesPage(): React.ReactElement {
|
||||||
|
return <NoticesModulePage />;
|
||||||
|
}
|
||||||
14
app/(app)/app/[organizationSlug]/page.tsx
Normal file
14
app/(app)/app/[organizationSlug]/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
|
type ScopedAppIndexPageProps = {
|
||||||
|
params: Promise<{
|
||||||
|
organizationSlug: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ScopedAppIndexPage({ params }: ScopedAppIndexPageProps): Promise<never> {
|
||||||
|
const { organizationSlug } = await params;
|
||||||
|
redirect(getWorkspaceHref(organizationSlug, "/dashboard"));
|
||||||
|
}
|
||||||
10
app/(app)/app/[organizationSlug]/settings/audit/page.tsx
Normal file
10
app/(app)/app/[organizationSlug]/settings/audit/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import AuditPage from "../../../settings/audit/page";
|
||||||
|
import type { SearchParams } from "@/modules/settings/lib/pagination";
|
||||||
|
|
||||||
|
type ScopedAuditPageProps = {
|
||||||
|
searchParams?: Promise<SearchParams>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ScopedAuditPage({ searchParams }: ScopedAuditPageProps): Promise<React.ReactElement> {
|
||||||
|
return AuditPage({ searchParams });
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import GlobalSettingsPage from "../../../settings/global/page";
|
||||||
|
|
||||||
|
export default async function ScopedGlobalSettingsPage(): Promise<React.ReactElement> {
|
||||||
|
return GlobalSettingsPage();
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import HealthSettingsPage from "../../../settings/health/page";
|
||||||
|
|
||||||
|
export default async function ScopedHealthSettingsPage(): Promise<React.ReactElement> {
|
||||||
|
return HealthSettingsPage();
|
||||||
|
}
|
||||||
9
app/(app)/app/[organizationSlug]/settings/layout.tsx
Normal file
9
app/(app)/app/[organizationSlug]/settings/layout.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import SettingsLayout from "../../settings/layout";
|
||||||
|
|
||||||
|
type ScopedSettingsLayoutProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ScopedSettingsLayout({ children }: ScopedSettingsLayoutProps): React.ReactElement {
|
||||||
|
return <SettingsLayout>{children}</SettingsLayout>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import OrganizationDetailPage from "../../../../settings/organizations/[id]/page";
|
||||||
|
|
||||||
|
type ScopedOrganizationDetailPageProps = {
|
||||||
|
params: Promise<{
|
||||||
|
organizationSlug: string;
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ScopedOrganizationDetailPage({
|
||||||
|
params,
|
||||||
|
}: ScopedOrganizationDetailPageProps): Promise<React.ReactElement> {
|
||||||
|
const { id } = await params;
|
||||||
|
return OrganizationDetailPage({ params: Promise.resolve({ id }) });
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import OrganizationsPage from "../../../settings/organizations/page";
|
||||||
|
import type { SearchParams } from "@/modules/settings/lib/pagination";
|
||||||
|
|
||||||
|
type ScopedOrganizationsPageProps = {
|
||||||
|
searchParams?: Promise<SearchParams>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ScopedOrganizationsPage({
|
||||||
|
searchParams,
|
||||||
|
}: ScopedOrganizationsPageProps): Promise<React.ReactElement> {
|
||||||
|
return OrganizationsPage({ searchParams });
|
||||||
|
}
|
||||||
5
app/(app)/app/[organizationSlug]/settings/page.tsx
Normal file
5
app/(app)/app/[organizationSlug]/settings/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import SettingsPage from "../../settings/page";
|
||||||
|
|
||||||
|
export default async function ScopedSettingsPage(): Promise<React.ReactElement> {
|
||||||
|
return SettingsPage();
|
||||||
|
}
|
||||||
10
app/(app)/app/[organizationSlug]/settings/roles/page.tsx
Normal file
10
app/(app)/app/[organizationSlug]/settings/roles/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import RolesPage from "../../../settings/roles/page";
|
||||||
|
import type { SearchParams } from "@/modules/settings/lib/pagination";
|
||||||
|
|
||||||
|
type ScopedRolesPageProps = {
|
||||||
|
searchParams?: Promise<SearchParams>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ScopedRolesPage({ searchParams }: ScopedRolesPageProps): Promise<React.ReactElement> {
|
||||||
|
return RolesPage({ searchParams });
|
||||||
|
}
|
||||||
10
app/(app)/app/[organizationSlug]/settings/status/page.tsx
Normal file
10
app/(app)/app/[organizationSlug]/settings/status/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import StatusPage from "../../../settings/status/page";
|
||||||
|
import type { SearchParams } from "@/modules/settings/lib/pagination";
|
||||||
|
|
||||||
|
type ScopedStatusPageProps = {
|
||||||
|
searchParams?: Promise<SearchParams>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ScopedStatusPage({ searchParams }: ScopedStatusPageProps): Promise<React.ReactElement> {
|
||||||
|
return StatusPage({ searchParams });
|
||||||
|
}
|
||||||
10
app/(app)/app/[organizationSlug]/settings/users/page.tsx
Normal file
10
app/(app)/app/[organizationSlug]/settings/users/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import UsersPage from "../../../settings/users/page";
|
||||||
|
import type { SearchParams } from "@/modules/settings/lib/pagination";
|
||||||
|
|
||||||
|
type ScopedUsersPageProps = {
|
||||||
|
searchParams?: Promise<SearchParams>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ScopedUsersPage({ searchParams }: ScopedUsersPageProps): Promise<React.ReactElement> {
|
||||||
|
return UsersPage({ searchParams });
|
||||||
|
}
|
||||||
@@ -1,21 +1,5 @@
|
|||||||
import { Radio } from "lucide-react";
|
import { AlertsModulePage } from "@/modules/shared/components/ReservedModulePages";
|
||||||
|
|
||||||
import { ModulePage } from "@/modules/shared/components/ModulePage";
|
|
||||||
|
|
||||||
export default function AlertsPage(): React.ReactElement {
|
export default function AlertsPage(): React.ReactElement {
|
||||||
return (
|
return <AlertsModulePage />;
|
||||||
<ModulePage
|
|
||||||
title="规则预警"
|
|
||||||
eyebrow="规则中心与风险识别"
|
|
||||||
description="三期引入健康阈值、任务逾期、设备故障、应急事件和风险老人识别规则。"
|
|
||||||
icon={Radio}
|
|
||||||
phase="三期预留"
|
|
||||||
metrics={[
|
|
||||||
{ label: "规则类型", value: "5", detail: "健康、用药、护理、设备、应急" },
|
|
||||||
{ label: "预警状态", value: "4", detail: "生成、确认、处理、关闭" },
|
|
||||||
{ label: "风险维度", value: "3", detail: "健康异常、事件频次、护理强度" },
|
|
||||||
]}
|
|
||||||
workflows={["配置预警规则", "匹配业务来源数据", "生成预警记录", "确认处理并关闭"]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { BedDouble, DoorOpen, History, Wrench } from "lucide-react";
|
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||||
|
import {
|
||||||
|
listOperationalAdmissions,
|
||||||
|
listOperationalBeds,
|
||||||
|
listOperationalElders,
|
||||||
|
listOperationalRooms,
|
||||||
|
} from "@/modules/core/server/operations";
|
||||||
import { hasPermission } from "@/modules/core/server/permissions";
|
import { hasPermission } from "@/modules/core/server/permissions";
|
||||||
import { readData } from "@/modules/core/server/store";
|
import { BedsWorkspace } from "@/modules/facilities/components/BedsWorkspace";
|
||||||
|
import { listElderBedContextData } from "@/modules/operations/server/context";
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
export default async function BedsPage(): Promise<React.ReactElement> {
|
export default async function BedsPage(): Promise<React.ReactElement> {
|
||||||
const context = await getCurrentAuthContext();
|
const context = await getCurrentAuthContext();
|
||||||
@@ -14,221 +19,34 @@ export default async function BedsPage(): Promise<React.ReactElement> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hasPermission(context.permissions, "facility:read")) {
|
if (!hasPermission(context.permissions, "facility:read")) {
|
||||||
redirect("/app/dashboard");
|
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await readData();
|
|
||||||
const organizationId = context.organization?.id;
|
const organizationId = context.organization?.id;
|
||||||
const rooms = organizationId ? data.rooms.filter((room) => room.organizationId === organizationId) : data.rooms;
|
const [rooms, beds, admissions, elders] = await Promise.all([
|
||||||
const beds = organizationId ? data.beds.filter((bed) => bed.organizationId === organizationId) : data.beds;
|
listOperationalRooms(organizationId),
|
||||||
const admissions = organizationId
|
listOperationalBeds(organizationId),
|
||||||
? data.admissions.filter((admission) => admission.organizationId === organizationId)
|
listOperationalAdmissions(organizationId),
|
||||||
: data.admissions;
|
listOperationalElders(organizationId),
|
||||||
|
]);
|
||||||
const occupiedBeds = beds.filter((bed) => bed.status === "occupied").length;
|
const contextData = organizationId
|
||||||
const maintenanceBeds = beds.filter((bed) => bed.status === "maintenance").length;
|
? await listElderBedContextData({
|
||||||
const occupancyRate = beds.length > 0 ? Math.round((occupiedBeds / beds.length) * 100) : 0;
|
organizationId,
|
||||||
|
permissions: context.permissions,
|
||||||
|
elders,
|
||||||
|
beds,
|
||||||
|
admissions,
|
||||||
|
})
|
||||||
|
: { bedContexts: {}, elderContexts: {} };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
|
<BedsWorkspace
|
||||||
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
|
bedContexts={contextData.bedContexts}
|
||||||
<div>
|
initialAdmissions={admissions}
|
||||||
<Badge variant="success">Facility + Admission</Badge>
|
initialBeds={beds}
|
||||||
<h1 className="mt-3 text-3xl font-semibold tracking-normal">床位房间</h1>
|
initialElders={elders}
|
||||||
<p className="mt-2 text-sm text-muted-foreground">房间主数据、床位状态、当前占用和入住历史。</p>
|
initialRooms={rooms}
|
||||||
</div>
|
permissions={context.permissions}
|
||||||
</section>
|
/>
|
||||||
|
|
||||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
|
||||||
<div>
|
|
||||||
<CardDescription>房间</CardDescription>
|
|
||||||
<CardTitle className="mt-2 text-3xl">{rooms.length}</CardTitle>
|
|
||||||
</div>
|
|
||||||
<DoorOpen className="size-5 text-primary" aria-hidden="true" />
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
|
||||||
<div>
|
|
||||||
<CardDescription>床位</CardDescription>
|
|
||||||
<CardTitle className="mt-2 text-3xl">{beds.length}</CardTitle>
|
|
||||||
</div>
|
|
||||||
<BedDouble className="size-5 text-primary" aria-hidden="true" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-0">
|
|
||||||
<p className="text-sm text-muted-foreground">占用率 {occupancyRate}%</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card className={maintenanceBeds > 0 ? "border-amber-200 bg-amber-50/70" : undefined}>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
|
||||||
<div>
|
|
||||||
<CardDescription className={maintenanceBeds > 0 ? "text-amber-700" : undefined}>维修床位</CardDescription>
|
|
||||||
<CardTitle className={maintenanceBeds > 0 ? "mt-2 text-3xl text-amber-700" : "mt-2 text-3xl"}>
|
|
||||||
{maintenanceBeds}
|
|
||||||
</CardTitle>
|
|
||||||
</div>
|
|
||||||
<Wrench className={maintenanceBeds > 0 ? "size-5 text-amber-700" : "size-5 text-primary"} aria-hidden="true" />
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
|
||||||
<div>
|
|
||||||
<CardDescription>入住历史</CardDescription>
|
|
||||||
<CardTitle className="mt-2 text-3xl">{admissions.length}</CardTitle>
|
|
||||||
</div>
|
|
||||||
<History className="size-5 text-primary" aria-hidden="true" />
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="grid gap-4 xl:grid-cols-[0.9fr_1.1fr]">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>房间台账</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="overflow-x-auto rounded-md border">
|
|
||||||
<table className="w-full min-w-[620px] text-sm">
|
|
||||||
<thead className="bg-secondary text-left text-xs text-muted-foreground">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3 font-medium">房间</th>
|
|
||||||
<th className="px-4 py-3 font-medium">编号</th>
|
|
||||||
<th className="px-4 py-3 font-medium">容量</th>
|
|
||||||
<th className="px-4 py-3 font-medium">床位</th>
|
|
||||||
<th className="px-4 py-3 font-medium">状态</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y bg-card">
|
|
||||||
{rooms.map((room) => (
|
|
||||||
<tr key={room.id}>
|
|
||||||
<td className="px-4 py-3 font-medium">{room.name}</td>
|
|
||||||
<td className="px-4 py-3 text-muted-foreground">{room.code}</td>
|
|
||||||
<td className="px-4 py-3">{room.capacity}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
{room.occupiedBedCount}/{room.bedCount}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<Badge variant={room.status === "active" ? "success" : "danger"}>{room.status}</Badge>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{rooms.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td className="px-4 py-8 text-center text-muted-foreground" colSpan={5}>
|
|
||||||
暂无房间,请通过 /api/facilities/rooms 创建
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : null}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>床位状态</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="overflow-x-auto rounded-md border">
|
|
||||||
<table className="w-full min-w-[700px] text-sm">
|
|
||||||
<thead className="bg-secondary text-left text-xs text-muted-foreground">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3 font-medium">房间</th>
|
|
||||||
<th className="px-4 py-3 font-medium">床位</th>
|
|
||||||
<th className="px-4 py-3 font-medium">状态</th>
|
|
||||||
<th className="px-4 py-3 font-medium">当前老人</th>
|
|
||||||
<th className="px-4 py-3 font-medium">备注</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y bg-card">
|
|
||||||
{beds.map((bed) => (
|
|
||||||
<tr key={bed.id}>
|
|
||||||
<td className="px-4 py-3">{bed.roomName}</td>
|
|
||||||
<td className="px-4 py-3 font-medium">{bed.code}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
bed.status === "available"
|
|
||||||
? "success"
|
|
||||||
: bed.status === "occupied"
|
|
||||||
? "secondary"
|
|
||||||
: bed.status === "maintenance"
|
|
||||||
? "warning"
|
|
||||||
: "danger"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{bed.status}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">{bed.currentElderName ?? "-"}</td>
|
|
||||||
<td className="px-4 py-3 text-muted-foreground">{bed.notes || "-"}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{beds.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td className="px-4 py-8 text-center text-muted-foreground" colSpan={5}>
|
|
||||||
暂无床位,请通过 /api/facilities/beds 创建
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : null}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>入住与换床历史</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="overflow-x-auto rounded-md border">
|
|
||||||
<table className="w-full min-w-[820px] text-sm">
|
|
||||||
<thead className="bg-secondary text-left text-xs text-muted-foreground">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3 font-medium">老人</th>
|
|
||||||
<th className="px-4 py-3 font-medium">床位</th>
|
|
||||||
<th className="px-4 py-3 font-medium">状态</th>
|
|
||||||
<th className="px-4 py-3 font-medium">入住时间</th>
|
|
||||||
<th className="px-4 py-3 font-medium">结束时间</th>
|
|
||||||
<th className="px-4 py-3 font-medium">备注</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y bg-card">
|
|
||||||
{admissions.map((admission) => (
|
|
||||||
<tr key={admission.id}>
|
|
||||||
<td className="px-4 py-3 font-medium">{admission.elderName}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
{admission.roomName}-{admission.bedCode}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<Badge variant={admission.status === "active" ? "success" : "secondary"}>{admission.status}</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-muted-foreground">
|
|
||||||
{new Date(admission.admittedAt).toLocaleString("zh-CN")}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-muted-foreground">
|
|
||||||
{admission.dischargedAt ? new Date(admission.dischargedAt).toLocaleString("zh-CN") : "-"}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-muted-foreground">{admission.notes || "-"}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{admissions.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
|
|
||||||
暂无入住历史
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : null}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
import { ClipboardCheck } from "lucide-react";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { ModulePage } from "@/modules/shared/components/ModulePage";
|
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||||
|
import { hasPermission } from "@/modules/core/server/permissions";
|
||||||
|
import { listCareExecutionData } from "@/modules/care/server/operations";
|
||||||
|
import { CareWorkspaceClient } from "@/modules/care/components/CareWorkspaceClient";
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
export default function CarePage(): React.ReactElement {
|
export default async function CarePage(): Promise<React.ReactElement> {
|
||||||
return (
|
const context = await getCurrentAuthContext();
|
||||||
<ModulePage
|
if (!context) {
|
||||||
title="护理服务"
|
redirect("/login");
|
||||||
eyebrow="护理计划、任务与巡检"
|
}
|
||||||
description="按护理等级制定计划,生成任务并支持接收、执行、完成和逾期标记。"
|
|
||||||
icon={ClipboardCheck}
|
if (!hasPermission(context.permissions, "care:read")) {
|
||||||
phase="一期"
|
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||||
metrics={[
|
}
|
||||||
{ label: "今日任务", value: "184", detail: "已完成 142 项" },
|
|
||||||
{ label: "逾期任务", value: "4", detail: "集中在午间巡检和复测任务" },
|
const organizationId = context.organization?.id;
|
||||||
{ label: "巡检打卡", value: "91%", detail: "一楼护理区已全部完成" },
|
if (!organizationId) {
|
||||||
]}
|
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||||
workflows={["配置护理等级", "创建护理计划", "派发护理任务", "巡检打卡并提交记录"]}
|
}
|
||||||
/>
|
|
||||||
);
|
const data = await listCareExecutionData(organizationId);
|
||||||
|
return <CareWorkspaceClient canManage={hasPermission(context.permissions, "care:manage")} initialData={data} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,143 @@
|
|||||||
import { DashboardHome } from "@/modules/dashboard/components/DashboardHome";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function DashboardPage(): React.ReactElement {
|
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||||
return <DashboardHome />;
|
import {
|
||||||
|
listOperationalAdmissions,
|
||||||
|
listOperationalBeds,
|
||||||
|
listOperationalElders,
|
||||||
|
listOperationalIncidents,
|
||||||
|
} from "@/modules/core/server/operations";
|
||||||
|
import { hasPermission } from "@/modules/core/server/permissions";
|
||||||
|
import type { BedStatus, Permission } from "@/modules/core/types";
|
||||||
|
import { listCareExecutionData } from "@/modules/care/server/operations";
|
||||||
|
import { DashboardHome, type DashboardMetric } from "@/modules/dashboard/components/DashboardHome";
|
||||||
|
import { listEmergencyIncidentData } from "@/modules/emergency/server/operations";
|
||||||
|
import { listHealthAdminData } from "@/modules/health/server/operations";
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
|
const BED_STATUSES: BedStatus[] = ["available", "occupied", "maintenance", "disabled"];
|
||||||
|
const DASHBOARD_PERMISSIONS: Permission[] = ["elder:read", "facility:read", "admission:read", "incident:read", "care:read", "health:read"];
|
||||||
|
|
||||||
|
export default async function DashboardPage(): Promise<React.ReactElement> {
|
||||||
|
const context = await getCurrentAuthContext();
|
||||||
|
if (!context) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizationId = context.organization?.id;
|
||||||
|
const canViewDashboard = DASHBOARD_PERMISSIONS.some((permission) => hasPermission(context.permissions, permission));
|
||||||
|
if (!canViewDashboard) {
|
||||||
|
redirect(getWorkspaceHref(context.organization?.slug, "/family"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const canReadCare = hasPermission(context.permissions, "care:read");
|
||||||
|
const canReadHealth = hasPermission(context.permissions, "health:read");
|
||||||
|
const canReadIncidents = hasPermission(context.permissions, "incident:read");
|
||||||
|
|
||||||
|
const [elders, beds, admissions, incidents, careData, healthData, emergencyData] = await Promise.all([
|
||||||
|
listOperationalElders(organizationId),
|
||||||
|
listOperationalBeds(organizationId),
|
||||||
|
listOperationalAdmissions(organizationId),
|
||||||
|
listOperationalIncidents(organizationId),
|
||||||
|
organizationId && canReadCare ? listCareExecutionData(organizationId) : undefined,
|
||||||
|
organizationId && canReadHealth ? listHealthAdminData(organizationId) : undefined,
|
||||||
|
organizationId && canReadIncidents ? listEmergencyIncidentData(organizationId) : undefined,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const activeElders = elders.filter((elder) => elder.status === "active").length;
|
||||||
|
const pendingElders = elders.filter((elder) => elder.status === "pending").length;
|
||||||
|
const occupiedBeds = beds.filter((bed) => bed.status === "occupied").length;
|
||||||
|
const availableBeds = beds.filter((bed) => bed.status === "available").length;
|
||||||
|
const activeAdmissions = admissions.filter((admission) => admission.status === "active").length;
|
||||||
|
const openIncidents = incidents.filter((incident) => incident.status !== "closed").length;
|
||||||
|
const pendingCare = careData?.metrics.pending ?? 0;
|
||||||
|
const inProgressCare = careData?.metrics.inProgress ?? 0;
|
||||||
|
const pendingHealthReviews = healthData?.metrics.pendingReviews ?? 0;
|
||||||
|
const activeChronicConditions = healthData?.metrics.activeChronicConditions ?? 0;
|
||||||
|
const openEmergency = emergencyData?.metrics.open ?? 0;
|
||||||
|
const criticalEmergency = emergencyData?.metrics.critical ?? 0;
|
||||||
|
|
||||||
|
const metrics: DashboardMetric[] = [
|
||||||
|
{ label: "在院老人", value: String(activeElders), trend: `待入住 ${pendingElders}`, icon: "users" },
|
||||||
|
{ label: "床位占用", value: `${occupiedBeds}/${beds.length}`, trend: `空闲 ${availableBeds}`, icon: "beds" },
|
||||||
|
{ label: "护理任务", value: String(pendingCare + inProgressCare), trend: `待处理 ${pendingCare} / 进行中 ${inProgressCare}`, icon: "care" },
|
||||||
|
{ label: "健康复核", value: String(pendingHealthReviews), trend: `慢病管理 ${activeChronicConditions}`, icon: "health" },
|
||||||
|
{ label: "当前入住", value: String(activeAdmissions), trend: `历史 ${admissions.length}`, icon: "admissions" },
|
||||||
|
{ label: "应急事件", value: String(openEmergency), trend: `紧急 ${criticalEmergency} / 未关闭 ${openIncidents}`, icon: "incidents" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const occupancyData = BED_STATUSES.map((status) => ({
|
||||||
|
label: status,
|
||||||
|
count: beds.filter((bed) => bed.status === status).length,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const recentAdmissions = admissions.slice(0, 6).map((admission) => ({
|
||||||
|
id: admission.id,
|
||||||
|
elderName: admission.elderName,
|
||||||
|
bedLabel: `${admission.roomName}-${admission.bedCode}`,
|
||||||
|
status: admission.status,
|
||||||
|
admittedAt: admission.admittedAt,
|
||||||
|
dischargedAt: admission.dischargedAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const visibleIncidents = incidents
|
||||||
|
.filter((incident) => incident.status !== "closed")
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((incident) => ({
|
||||||
|
id: incident.id,
|
||||||
|
severity: incident.severity,
|
||||||
|
status: incident.status,
|
||||||
|
title: incident.title,
|
||||||
|
source: incident.source,
|
||||||
|
createdAt: incident.createdAt,
|
||||||
|
}));
|
||||||
|
const careTasks = (careData?.tasks ?? [])
|
||||||
|
.filter((task) => task.status === "pending" || task.status === "in_progress" || task.priority === "urgent" || task.priority === "high")
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((task) => ({
|
||||||
|
id: task.id,
|
||||||
|
assigneeLabel: task.assigneeLabel,
|
||||||
|
elderName: task.elderName,
|
||||||
|
priority: task.priority,
|
||||||
|
scheduledAt: task.scheduledAt,
|
||||||
|
status: task.status,
|
||||||
|
title: task.title,
|
||||||
|
}));
|
||||||
|
const healthReviews = (healthData?.reviews ?? [])
|
||||||
|
.filter((review) => review.status === "pending" || review.severity === "critical")
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((review) => ({
|
||||||
|
id: review.id,
|
||||||
|
elderName: review.elderName,
|
||||||
|
severity: review.severity,
|
||||||
|
status: review.status,
|
||||||
|
title: review.title,
|
||||||
|
}));
|
||||||
|
const emergencyIncidents = (emergencyData?.incidents ?? [])
|
||||||
|
.filter((incident) => incident.status === "open" || incident.status === "acknowledged" || incident.severity === "critical")
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((incident) => ({
|
||||||
|
id: incident.id,
|
||||||
|
severity: incident.severity,
|
||||||
|
status: incident.status,
|
||||||
|
title: incident.title,
|
||||||
|
source: incident.source,
|
||||||
|
createdAt: incident.createdAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardHome
|
||||||
|
bedsHref={getWorkspaceHref(context.organization?.slug, "/beds")}
|
||||||
|
careHref={getWorkspaceHref(context.organization?.slug, "/care")}
|
||||||
|
careTasks={careTasks}
|
||||||
|
emergencyHref={getWorkspaceHref(context.organization?.slug, "/emergency")}
|
||||||
|
emergencyIncidents={emergencyIncidents}
|
||||||
|
healthHref={getWorkspaceHref(context.organization?.slug, "/health")}
|
||||||
|
healthReviews={healthReviews}
|
||||||
|
incidents={visibleIncidents}
|
||||||
|
metrics={metrics}
|
||||||
|
occupancyData={occupancyData}
|
||||||
|
recentAdmissions={recentAdmissions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,5 @@
|
|||||||
import { Wrench } from "lucide-react";
|
import { DevicesModulePage } from "@/modules/shared/components/ReservedModulePages";
|
||||||
|
|
||||||
import { ModulePage } from "@/modules/shared/components/ModulePage";
|
|
||||||
|
|
||||||
export default function DevicesPage(): React.ReactElement {
|
export default function DevicesPage(): React.ReactElement {
|
||||||
return (
|
return <DevicesModulePage />;
|
||||||
<ModulePage
|
|
||||||
title="设备运维"
|
|
||||||
eyebrow="设备台账与维修工单"
|
|
||||||
description="维护设备编号、类型、位置、状态和责任人,支持故障上报与维修归档。"
|
|
||||||
icon={Wrench}
|
|
||||||
phase="一期"
|
|
||||||
metrics={[
|
|
||||||
{ label: "设备总数", value: "312", detail: "床旁呼叫、监测设备、公共设施" },
|
|
||||||
{ label: "故障设备", value: "2", detail: "均已生成维修工单" },
|
|
||||||
{ label: "待派单", value: "1", detail: "需维修人员确认" },
|
|
||||||
]}
|
|
||||||
workflows={["登记设备台账", "上报设备故障", "派发维修工单", "记录维修结果并恢复状态"]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||||
import { readData } from "@/modules/core/server/store";
|
import { listOperationalElders } from "@/modules/core/server/operations";
|
||||||
|
import { hasPermission } from "@/modules/core/server/permissions";
|
||||||
import { EldersClient } from "@/modules/elders/components/EldersClient";
|
import { EldersClient } from "@/modules/elders/components/EldersClient";
|
||||||
|
import { listElderBedContextData } from "@/modules/operations/server/context";
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
export default async function EldersPage(): Promise<React.ReactElement> {
|
export default async function EldersPage(): Promise<React.ReactElement> {
|
||||||
const context = await getCurrentAuthContext();
|
const context = await getCurrentAuthContext();
|
||||||
@@ -10,6 +13,23 @@ export default async function EldersPage(): Promise<React.ReactElement> {
|
|||||||
redirect("/login");
|
redirect("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await readData();
|
if (!hasPermission(context.permissions, "elder:read")) {
|
||||||
return <EldersClient initialElders={data.elders} permissions={context.permissions} />;
|
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizationId = context.organization?.id;
|
||||||
|
if (!organizationId) {
|
||||||
|
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const elders = await listOperationalElders(organizationId);
|
||||||
|
const contextData = await listElderBedContextData({
|
||||||
|
organizationId,
|
||||||
|
permissions: context.permissions,
|
||||||
|
elders,
|
||||||
|
beds: [],
|
||||||
|
admissions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return <EldersClient elderContexts={contextData.elderContexts} initialElders={elders} permissions={context.permissions} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
import { ShieldAlert } from "lucide-react";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { ModulePage } from "@/modules/shared/components/ModulePage";
|
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||||
|
import { hasPermission } from "@/modules/core/server/permissions";
|
||||||
|
import { EmergencyWorkspaceClient } from "@/modules/emergency/components/EmergencyWorkspaceClient";
|
||||||
|
import { listEmergencyIncidentData } from "@/modules/emergency/server/operations";
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
export default function EmergencyPage(): React.ReactElement {
|
export default async function EmergencyPage(): Promise<React.ReactElement> {
|
||||||
return (
|
const context = await getCurrentAuthContext();
|
||||||
<ModulePage
|
if (!context) {
|
||||||
title="安全应急"
|
redirect("/login");
|
||||||
eyebrow="紧急呼叫与事件处理"
|
}
|
||||||
description="支持老人或护理人员提交紧急呼叫,形成接警、派发、处理、完成、归档闭环。"
|
|
||||||
icon={ShieldAlert}
|
if (!hasPermission(context.permissions, "incident:read")) {
|
||||||
phase="一期"
|
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||||
metrics={[
|
}
|
||||||
{ label: "今日呼叫", value: "6", detail: "平均响应 2 分 18 秒" },
|
|
||||||
{ label: "处理中", value: "1", detail: "已通知护理主管与医护人员" },
|
const organizationId = context.organization?.id;
|
||||||
{ label: "已归档", value: "21", detail: "本周事件处理率 100%" },
|
if (!organizationId) {
|
||||||
]}
|
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||||
workflows={["提交紧急呼叫", "生成应急事件", "派发责任人员", "记录处理结果并通知相关人员"]}
|
}
|
||||||
/>
|
|
||||||
);
|
const data = await listEmergencyIncidentData(organizationId);
|
||||||
|
return <EmergencyWorkspaceClient canManage={hasPermission(context.permissions, "incident:manage")} initialData={data} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,5 @@
|
|||||||
import { TabletSmartphone } from "lucide-react";
|
import { FamilyModulePage } from "@/modules/shared/components/ReservedModulePages";
|
||||||
|
|
||||||
import { ModulePage } from "@/modules/shared/components/ModulePage";
|
|
||||||
|
|
||||||
export default function FamilyPage(): React.ReactElement {
|
export default function FamilyPage(): React.ReactElement {
|
||||||
return (
|
return <FamilyModulePage />;
|
||||||
<ModulePage
|
|
||||||
title="家属服务"
|
|
||||||
eyebrow="家属端与探访反馈"
|
|
||||||
description="二期补齐家属查看老人动态、健康记录、护理记录、探访预约和服务反馈能力。"
|
|
||||||
icon={TabletSmartphone}
|
|
||||||
phase="二期预留"
|
|
||||||
metrics={[
|
|
||||||
{ label: "绑定家属", value: "214", detail: "一个老人可绑定多个授权家属" },
|
|
||||||
{ label: "探访预约", value: "12", detail: "待审核 4 条" },
|
|
||||||
{ label: "服务反馈", value: "7", detail: "2 条需要机构回复" },
|
|
||||||
]}
|
|
||||||
workflows={["绑定老人和家属账号", "查看授权范围内数据", "提交探访预约", "反馈处理与归档"]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,5 @@
|
|||||||
import { HeartPulse } from "lucide-react";
|
import { renderHealthWorkspacePage } from "@/modules/health/components/HealthWorkspacePage";
|
||||||
|
|
||||||
import { ModulePage } from "@/modules/shared/components/ModulePage";
|
export default async function HealthPage(): Promise<React.ReactElement> {
|
||||||
|
return renderHealthWorkspacePage();
|
||||||
export default function HealthPage(): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<ModulePage
|
|
||||||
title="健康照护"
|
|
||||||
eyebrow="健康档案与生命体征"
|
|
||||||
description="记录体温、血压、心率、血氧、慢病信息和异常复核建议。"
|
|
||||||
icon={HeartPulse}
|
|
||||||
phase="一期"
|
|
||||||
metrics={[
|
|
||||||
{ label: "今日记录", value: "236", detail: "覆盖重点老人 100%" },
|
|
||||||
{ label: "异常体征", value: "9", detail: "血压异常 5 条,血氧异常 1 条" },
|
|
||||||
{ label: "待医护复核", value: "3", detail: "需在 30 分钟内处理" },
|
|
||||||
]}
|
|
||||||
workflows={["录入生命体征", "维护健康摘要", "识别异常指标", "形成处理建议并归档"]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,12 @@ export default async function AppLayout({ children }: AppLayoutProps): Promise<R
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell account={context.account} permissions={context.permissions}>
|
<AppShell
|
||||||
|
account={context.account}
|
||||||
|
organization={context.organization}
|
||||||
|
organizations={context.organizations}
|
||||||
|
permissions={context.permissions}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,21 +1,5 @@
|
|||||||
import { Bell } from "lucide-react";
|
import { NoticesModulePage } from "@/modules/shared/components/ReservedModulePages";
|
||||||
|
|
||||||
import { ModulePage } from "@/modules/shared/components/ModulePage";
|
|
||||||
|
|
||||||
export default function NoticesPage(): React.ReactElement {
|
export default function NoticesPage(): React.ReactElement {
|
||||||
return (
|
return <NoticesModulePage />;
|
||||||
<ModulePage
|
|
||||||
title="公告通知"
|
|
||||||
eyebrow="公告发布与阅读状态"
|
|
||||||
description="按角色和范围发布公告,区分草稿、发布、未读和已读状态。"
|
|
||||||
icon={Bell}
|
|
||||||
phase="一期"
|
|
||||||
metrics={[
|
|
||||||
{ label: "已发布公告", value: "18", detail: "本月面向全员 5 条" },
|
|
||||||
{ label: "未读消息", value: "64", detail: "护理人员未读占比最高" },
|
|
||||||
{ label: "草稿", value: "3", detail: "草稿不会对目标用户可见" },
|
|
||||||
]}
|
|
||||||
workflows={["创建公告草稿", "选择目标角色与范围", "发布通知", "跟踪阅读状态"]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function AppIndexPage(): never {
|
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||||
redirect("/app/dashboard");
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
|
export default async function AppIndexPage(): Promise<never> {
|
||||||
|
const context = await getCurrentAuthContext();
|
||||||
|
redirect(getWorkspaceHref(context?.organization?.slug, "/dashboard"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Table } from "@/components/ui/table";
|
||||||
|
import { listAuditLogs } from "@/modules/core/server/audit";
|
||||||
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||||
import { hasPermission } from "@/modules/core/server/permissions";
|
import { hasPermission } from "@/modules/core/server/permissions";
|
||||||
import { readData } from "@/modules/core/server/store";
|
import { TablePagination, TableToolbar } from "@/modules/settings/components/TableToolbar";
|
||||||
import { TableToolbar } from "@/modules/settings/components/TableToolbar";
|
|
||||||
import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination";
|
import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination";
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
type AuditPageProps = {
|
type AuditPageProps = {
|
||||||
searchParams?: Promise<SearchParams>;
|
searchParams?: Promise<SearchParams>;
|
||||||
@@ -19,15 +20,19 @@ export default async function AuditPage({ searchParams }: AuditPageProps): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hasPermission(context.permissions, "audit:read")) {
|
if (!hasPermission(context.permissions, "audit:read")) {
|
||||||
redirect("/app/dashboard");
|
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pagePath = getWorkspaceHref(context.organization?.slug, "/settings/audit");
|
||||||
const params = (await searchParams) ?? {};
|
const params = (await searchParams) ?? {};
|
||||||
const query = getSearchParam(params, "q");
|
const query = getSearchParam(params, "q");
|
||||||
const page = getPage(params);
|
const page = getPage(params);
|
||||||
const data = await readData();
|
const logs = await listAuditLogs({
|
||||||
|
organizationId: context.account.platformRoleId ? undefined : context.organization?.id,
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
const filteredLogs = data.auditLogs.filter((log) =>
|
const filteredLogs = logs.filter((log) =>
|
||||||
[log.actorEmail ?? "系统", log.action, log.targetType, log.targetId ?? "", log.result, log.reason]
|
[log.actorEmail ?? "系统", log.action, log.targetType, log.targetId ?? "", log.result, log.reason]
|
||||||
.join(" ")
|
.join(" ")
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -38,61 +43,60 @@ export default async function AuditPage({ searchParams }: AuditPageProps): Promi
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<section>
|
|
||||||
<Badge variant="success">Audit</Badge>
|
|
||||||
<h2 className="mt-3 text-xl font-semibold">审计日志</h2>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">关键操作、拒绝访问和故障处理留痕。</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>审计列表</CardTitle>
|
<CardTitle>审计列表</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<TableToolbar
|
<TableToolbar
|
||||||
page={Math.min(page, pageCount)}
|
pathname={pagePath}
|
||||||
pageCount={pageCount}
|
|
||||||
pathname="/app/settings/audit"
|
|
||||||
query={query}
|
query={query}
|
||||||
searchPlaceholder="搜索操作者、动作、对象、结果"
|
searchPlaceholder="搜索操作者、动作、对象、结果"
|
||||||
total={filteredLogs.length}
|
total={filteredLogs.length}
|
||||||
/>
|
/>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[900px] text-sm">
|
<Table className="w-full min-w-[900px] text-sm">
|
||||||
<thead className="bg-secondary text-left text-xs text-muted-foreground">
|
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
|
||||||
<tr>
|
<Table.Row>
|
||||||
<th className="px-4 py-3 font-medium">时间</th>
|
<Table.Head className="px-4 py-3 font-medium">时间</Table.Head>
|
||||||
<th className="px-4 py-3 font-medium">操作者</th>
|
<Table.Head className="px-4 py-3 font-medium">操作者</Table.Head>
|
||||||
<th className="px-4 py-3 font-medium">动作</th>
|
<Table.Head className="px-4 py-3 font-medium">动作</Table.Head>
|
||||||
<th className="px-4 py-3 font-medium">对象</th>
|
<Table.Head className="px-4 py-3 font-medium">对象</Table.Head>
|
||||||
<th className="px-4 py-3 font-medium">结果</th>
|
<Table.Head className="px-4 py-3 font-medium">结果</Table.Head>
|
||||||
<th className="px-4 py-3 font-medium">原因</th>
|
<Table.Head className="px-4 py-3 font-medium">原因</Table.Head>
|
||||||
</tr>
|
</Table.Row>
|
||||||
</thead>
|
</Table.Header>
|
||||||
<tbody className="divide-y bg-card">
|
<Table.Body className="divide-y bg-card">
|
||||||
{visibleLogs.map((log) => (
|
{visibleLogs.map((log) => (
|
||||||
<tr className="table-row-enter hover:bg-secondary/45" key={log.id}>
|
<Table.Row className="table-row-enter hover:bg-secondary/45" key={log.id}>
|
||||||
<td className="px-4 py-3 text-muted-foreground">{new Date(log.timestamp).toLocaleString("zh-CN")}</td>
|
<Table.Cell className="px-4 py-3 text-muted-foreground">{new Date(log.timestamp).toLocaleString("zh-CN")}</Table.Cell>
|
||||||
<td className="px-4 py-3">{log.actorEmail ?? "系统"}</td>
|
<Table.Cell className="px-4 py-3">{log.actorEmail ?? "系统"}</Table.Cell>
|
||||||
<td className="px-4 py-3">{log.action}</td>
|
<Table.Cell className="px-4 py-3">{log.action}</Table.Cell>
|
||||||
<td className="px-4 py-3 text-muted-foreground">
|
<Table.Cell className="px-4 py-3 text-muted-foreground">
|
||||||
{log.targetType}
|
{log.targetType}
|
||||||
{log.targetId ? ` / ${log.targetId.slice(0, 8)}` : ""}
|
{log.targetId ? ` / ${log.targetId.slice(0, 8)}` : ""}
|
||||||
</td>
|
</Table.Cell>
|
||||||
<td className="px-4 py-3">{log.result}</td>
|
<Table.Cell className="px-4 py-3">{log.result}</Table.Cell>
|
||||||
<td className="px-4 py-3 text-muted-foreground">{log.reason}</td>
|
<Table.Cell className="px-4 py-3 text-muted-foreground">{log.reason}</Table.Cell>
|
||||||
</tr>
|
</Table.Row>
|
||||||
))}
|
))}
|
||||||
{visibleLogs.length === 0 ? (
|
{visibleLogs.length === 0 ? (
|
||||||
<tr>
|
<Table.Row>
|
||||||
<td className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
|
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
|
||||||
暂无匹配审计
|
暂无匹配审计
|
||||||
</td>
|
</Table.Cell>
|
||||||
</tr>
|
</Table.Row>
|
||||||
) : null}
|
) : null}
|
||||||
</tbody>
|
</Table.Body>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
<TablePagination
|
||||||
|
page={Math.min(page, pageCount)}
|
||||||
|
pageCount={pageCount}
|
||||||
|
pathname={pagePath}
|
||||||
|
query={query}
|
||||||
|
total={filteredLogs.length}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||||
import { hasPermission } from "@/modules/core/server/permissions";
|
import { hasPermission } from "@/modules/core/server/permissions";
|
||||||
import { getSystemSettings } from "@/modules/core/server/system-settings";
|
import { getSystemSettings } from "@/modules/core/server/system-settings";
|
||||||
import { GlobalSettingsClient } from "@/modules/settings/components/GlobalSettingsClient";
|
import { GlobalSettingsClient } from "@/modules/settings/components/GlobalSettingsClient";
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
export default async function GlobalSettingsPage(): Promise<React.ReactElement> {
|
export default async function GlobalSettingsPage(): Promise<React.ReactElement> {
|
||||||
const context = await getCurrentAuthContext();
|
const context = await getCurrentAuthContext();
|
||||||
@@ -13,20 +13,15 @@ export default async function GlobalSettingsPage(): Promise<React.ReactElement>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hasPermission(context.permissions, "platform:manage")) {
|
if (!hasPermission(context.permissions, "platform:manage")) {
|
||||||
redirect("/app/dashboard");
|
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = await getSystemSettings();
|
const settings = await getSystemSettings();
|
||||||
|
const organizationSettingsHref = getWorkspaceHref(context.organization?.slug, "/settings/organizations");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<section>
|
<GlobalSettingsClient organizationSettingsHref={organizationSettingsHref} settings={settings} />
|
||||||
<Badge variant="success">Global</Badge>
|
|
||||||
<h2 className="mt-3 text-xl font-semibold">全局配置</h2>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">平台级注册策略、OIDC 默认配置和用户头像声明映射。</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<GlobalSettingsClient settings={settings} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
5
app/(app)/app/settings/health/page.tsx
Normal file
5
app/(app)/app/settings/health/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { renderHealthWorkspacePage } from "@/modules/health/components/HealthWorkspacePage";
|
||||||
|
|
||||||
|
export default async function HealthSettingsPage(): Promise<React.ReactElement> {
|
||||||
|
return renderHealthWorkspacePage();
|
||||||
|
}
|
||||||
@@ -3,12 +3,5 @@ type SettingsLayoutProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function SettingsLayout({ children }: SettingsLayoutProps): React.ReactElement {
|
export default function SettingsLayout({ children }: SettingsLayoutProps): React.ReactElement {
|
||||||
return (
|
return <div className="mx-auto flex w-full max-w-7xl flex-col gap-5">{children}</div>;
|
||||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
|
|
||||||
<section className="border-b pb-4">
|
|
||||||
<h1 className="text-2xl font-semibold tracking-normal">系统设置</h1>
|
|
||||||
</section>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { ArrowLeft, Building2, Users } from "lucide-react";
|
import { ArrowLeft, Building2, Users } from "lucide-react";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { LinkButton } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Table } from "@/components/ui/table";
|
||||||
import { getCurrentAuthContext, toPublicAccount } from "@/modules/core/server/auth";
|
import { getCurrentAuthContext, toPublicAccount } from "@/modules/core/server/auth";
|
||||||
import { getRoleDefinitions, hasPermission } from "@/modules/core/server/permissions";
|
import { getRoleDefinitions, hasPermission } from "@/modules/core/server/permissions";
|
||||||
import { readData } from "@/modules/core/server/store";
|
import {
|
||||||
|
listSettingsAccounts,
|
||||||
|
listSettingsJoinRequests,
|
||||||
|
listSettingsMemberships,
|
||||||
|
listSettingsOrganizationInvitations,
|
||||||
|
listSettingsOrganizations,
|
||||||
|
} from "@/modules/core/server/settings";
|
||||||
import { OrganizationDetailClient } from "@/modules/settings/components/OrganizationDetailClient";
|
import { OrganizationDetailClient } from "@/modules/settings/components/OrganizationDetailClient";
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
type OrganizationDetailPageProps = {
|
type OrganizationDetailPageProps = {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -25,23 +32,28 @@ export default async function OrganizationDetailPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hasPermission(context.permissions, "organization:read")) {
|
if (!hasPermission(context.permissions, "organization:read")) {
|
||||||
redirect("/app/dashboard");
|
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const data = await readData();
|
const organizations = await listSettingsOrganizations({ organizationId: id });
|
||||||
const organization = data.organizations.find((item) => item.id === id);
|
const organization = organizations[0];
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.organization?.id && context.organization.id !== organization.id) {
|
if (!context.account.platformRoleId && context.organization?.id && context.organization.id !== organization.id) {
|
||||||
redirect("/app/settings/organizations");
|
redirect(getWorkspaceHref(context.organization?.slug, "/settings/organizations"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const roles = await getRoleDefinitions(organization.id);
|
const [roles, organizationMemberships, organizationAccounts, invitations, joinRequests] = await Promise.all([
|
||||||
const organizationMemberships = data.memberships.filter((membership) => membership.organizationId === organization.id);
|
getRoleDefinitions(organization.id),
|
||||||
const accountById = new Map(data.accounts.map((account) => [account.id, account]));
|
listSettingsMemberships({ organizationId: organization.id }),
|
||||||
|
listSettingsAccounts({ organizationId: organization.id }),
|
||||||
|
listSettingsOrganizationInvitations({ organizationId: organization.id }),
|
||||||
|
listSettingsJoinRequests({ organizationId: organization.id }),
|
||||||
|
]);
|
||||||
|
const accountById = new Map(organizationAccounts.map((account) => [account.id, account]));
|
||||||
const members = organizationMemberships
|
const members = organizationMemberships
|
||||||
.map((membership) => {
|
.map((membership) => {
|
||||||
const account = accountById.get(membership.accountId);
|
const account = accountById.get(membership.accountId);
|
||||||
@@ -55,21 +67,16 @@ export default async function OrganizationDetailPage({
|
|||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((item): item is NonNullable<typeof item> => item !== null);
|
.filter((item): item is NonNullable<typeof item> => item !== null);
|
||||||
const invitations = data.organizationInvitations.filter((invitation) => invitation.organizationId === organization.id);
|
const pendingRequests = joinRequests.filter((request) => request.status === "pending");
|
||||||
const pendingRequests = data.joinRequests.filter(
|
|
||||||
(request) => request.organizationId === organization.id && request.status === "pending",
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
|
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Button asChild size="sm" variant="outline">
|
<LinkButton href={getWorkspaceHref(context.organization?.slug, "/settings/organizations")} size="sm" variant="outline">
|
||||||
<Link href="/app/settings/organizations">
|
<ArrowLeft className="size-4" aria-hidden="true" />
|
||||||
<ArrowLeft className="size-4" aria-hidden="true" />
|
返回机构列表
|
||||||
返回机构列表
|
</LinkButton>
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<div className="mt-5 flex items-center gap-3">
|
<div className="mt-5 flex items-center gap-3">
|
||||||
<span className="inline-flex size-11 items-center justify-center rounded-md bg-secondary text-primary">
|
<span className="inline-flex size-11 items-center justify-center rounded-md bg-secondary text-primary">
|
||||||
<Building2 className="size-5" aria-hidden="true" />
|
<Building2 className="size-5" aria-hidden="true" />
|
||||||
@@ -131,40 +138,40 @@ export default async function OrganizationDetailPage({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[760px] text-sm">
|
<Table className="w-full min-w-[760px] text-sm">
|
||||||
<thead className="bg-secondary text-left text-xs text-muted-foreground">
|
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
|
||||||
<tr>
|
<Table.Row>
|
||||||
<th className="px-4 py-3 font-medium">账号</th>
|
<Table.Head className="px-4 py-3 font-medium">账号</Table.Head>
|
||||||
<th className="px-4 py-3 font-medium">角色</th>
|
<Table.Head className="px-4 py-3 font-medium">角色</Table.Head>
|
||||||
<th className="px-4 py-3 font-medium">成员状态</th>
|
<Table.Head className="px-4 py-3 font-medium">成员状态</Table.Head>
|
||||||
<th className="px-4 py-3 font-medium">账号状态</th>
|
<Table.Head className="px-4 py-3 font-medium">账号状态</Table.Head>
|
||||||
<th className="px-4 py-3 text-right font-medium">加入时间</th>
|
<Table.Head className="px-4 py-3 text-right font-medium">加入时间</Table.Head>
|
||||||
</tr>
|
</Table.Row>
|
||||||
</thead>
|
</Table.Header>
|
||||||
<tbody className="divide-y bg-card">
|
<Table.Body className="divide-y bg-card">
|
||||||
{members.map(({ account, membership }) => (
|
{members.map(({ account, membership }) => (
|
||||||
<tr className="hover:bg-secondary/45" key={membership.id}>
|
<Table.Row className="hover:bg-secondary/45" key={membership.id}>
|
||||||
<td className="px-4 py-3">
|
<Table.Cell className="px-4 py-3">
|
||||||
<p className="font-medium">{account.name}</p>
|
<p className="font-medium">{account.name}</p>
|
||||||
<p className="text-xs text-muted-foreground">{account.email}</p>
|
<p className="text-xs text-muted-foreground">{account.email}</p>
|
||||||
</td>
|
</Table.Cell>
|
||||||
<td className="px-4 py-3">{membership.roleLabel}</td>
|
<Table.Cell className="px-4 py-3">{membership.roleLabel}</Table.Cell>
|
||||||
<td className="px-4 py-3">{membership.status}</td>
|
<Table.Cell className="px-4 py-3">{membership.status}</Table.Cell>
|
||||||
<td className="px-4 py-3">{account.status}</td>
|
<Table.Cell className="px-4 py-3">{account.status}</Table.Cell>
|
||||||
<td className="px-4 py-3 text-right text-muted-foreground">
|
<Table.Cell className="px-4 py-3 text-right text-muted-foreground">
|
||||||
{new Date(membership.createdAt).toLocaleString("zh-CN")}
|
{new Date(membership.createdAt).toLocaleString("zh-CN")}
|
||||||
</td>
|
</Table.Cell>
|
||||||
</tr>
|
</Table.Row>
|
||||||
))}
|
))}
|
||||||
{members.length === 0 ? (
|
{members.length === 0 ? (
|
||||||
<tr>
|
<Table.Row>
|
||||||
<td className="px-4 py-8 text-center text-muted-foreground" colSpan={5}>
|
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={5}>
|
||||||
暂无成员
|
暂无成员
|
||||||
</td>
|
</Table.Cell>
|
||||||
</tr>
|
</Table.Row>
|
||||||
) : null}
|
) : null}
|
||||||
</tbody>
|
</Table.Body>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Table } from "@/components/ui/table";
|
||||||
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||||
import { getRoleDefinitions, hasPermission } from "@/modules/core/server/permissions";
|
import { getRoleDefinitions, hasPermission } from "@/modules/core/server/permissions";
|
||||||
import { readData } from "@/modules/core/server/store";
|
import { listSettingsOrganizations } from "@/modules/core/server/settings";
|
||||||
import {
|
import {
|
||||||
OrganizationManagementClient,
|
OrganizationManagementClient,
|
||||||
OrganizationRowActions,
|
OrganizationRowActions,
|
||||||
} from "@/modules/settings/components/OrganizationManagementClient";
|
} from "@/modules/settings/components/OrganizationManagementClient";
|
||||||
import { TableToolbar } from "@/modules/settings/components/TableToolbar";
|
import { TablePagination, TableToolbar } from "@/modules/settings/components/TableToolbar";
|
||||||
import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination";
|
import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination";
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
type OrganizationsPageProps = {
|
type OrganizationsPageProps = {
|
||||||
searchParams?: Promise<SearchParams>;
|
searchParams?: Promise<SearchParams>;
|
||||||
@@ -24,15 +25,17 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hasPermission(context.permissions, "organization:read")) {
|
if (!hasPermission(context.permissions, "organization:read")) {
|
||||||
redirect("/app/dashboard");
|
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pagePath = getWorkspaceHref(context.organization?.slug, "/settings/organizations");
|
||||||
const params = (await searchParams) ?? {};
|
const params = (await searchParams) ?? {};
|
||||||
const query = getSearchParam(params, "q");
|
const query = getSearchParam(params, "q");
|
||||||
const page = getPage(params);
|
const page = getPage(params);
|
||||||
const data = await readData();
|
const organizationScopeId = context.account.platformRoleId ? undefined : context.organization?.id;
|
||||||
|
const organizations = await listSettingsOrganizations(organizationScopeId ? { organizationId: organizationScopeId } : {});
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
const filteredOrganizations = data.organizations.filter((organization) =>
|
const filteredOrganizations = organizations.filter((organization) =>
|
||||||
[organization.name, organization.slug, organization.status].join(" ").toLowerCase().includes(normalizedQuery),
|
[organization.name, organization.slug, organization.status].join(" ").toLowerCase().includes(normalizedQuery),
|
||||||
);
|
);
|
||||||
const pageCount = getPageCount(filteredOrganizations.length);
|
const pageCount = getPageCount(filteredOrganizations.length);
|
||||||
@@ -51,14 +54,11 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
|
{canManageOrganizations ? (
|
||||||
<div>
|
<div className="flex justify-end">
|
||||||
<Badge variant="success">Organizations</Badge>
|
<OrganizationManagementClient />
|
||||||
<h2 className="mt-3 text-xl font-semibold">机构管理</h2>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">多机构租户、启停状态、成员邀请和机构角色初始化。</p>
|
|
||||||
</div>
|
</div>
|
||||||
{canManageOrganizations ? <OrganizationManagementClient /> : null}
|
) : null}
|
||||||
</section>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -66,59 +66,64 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<TableToolbar
|
<TableToolbar
|
||||||
page={Math.min(page, pageCount)}
|
pathname={pagePath}
|
||||||
pageCount={pageCount}
|
|
||||||
pathname="/app/settings/organizations"
|
|
||||||
query={query}
|
query={query}
|
||||||
searchPlaceholder="搜索机构名称、标识、状态"
|
searchPlaceholder="搜索机构名称、标识、状态"
|
||||||
total={filteredOrganizations.length}
|
total={filteredOrganizations.length}
|
||||||
/>
|
/>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[820px] text-sm">
|
<Table className="w-full min-w-[820px] text-sm">
|
||||||
<thead className="bg-secondary text-left text-xs text-muted-foreground">
|
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
|
||||||
<tr>
|
<Table.Row>
|
||||||
<th className="px-4 py-3 font-medium">机构</th>
|
<Table.Head className="px-4 py-3 font-medium">机构</Table.Head>
|
||||||
<th className="px-4 py-3 font-medium">Slug</th>
|
<Table.Head className="px-4 py-3 font-medium">Slug</Table.Head>
|
||||||
<th className="px-4 py-3 font-medium">状态</th>
|
<Table.Head className="px-4 py-3 font-medium">状态</Table.Head>
|
||||||
<th className="px-4 py-3 text-right font-medium">创建时间</th>
|
<Table.Head className="px-4 py-3 text-right font-medium">创建时间</Table.Head>
|
||||||
<th className="px-4 py-3 text-right font-medium">操作</th>
|
<Table.Head className="px-4 py-3 text-right font-medium">操作</Table.Head>
|
||||||
</tr>
|
</Table.Row>
|
||||||
</thead>
|
</Table.Header>
|
||||||
<tbody className="divide-y bg-card">
|
<Table.Body className="divide-y bg-card">
|
||||||
{visibleOrganizations.map((organization) => (
|
{visibleOrganizations.map((organization) => (
|
||||||
<tr className="table-row-enter hover:bg-secondary/45" key={organization.id}>
|
<Table.Row className="table-row-enter hover:bg-secondary/45" key={organization.id}>
|
||||||
<td className="px-4 py-3">
|
<Table.Cell className="px-4 py-3">
|
||||||
<Link
|
<Link
|
||||||
className="font-medium text-primary underline-offset-4 hover:underline"
|
className="font-medium text-primary underline-offset-4 hover:underline"
|
||||||
href={`/app/settings/organizations/${organization.id}`}
|
href={getWorkspaceHref(context.organization?.slug, `/settings/organizations/${organization.id}`)}
|
||||||
>
|
>
|
||||||
{organization.name}
|
{organization.name}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</Table.Cell>
|
||||||
<td className="px-4 py-3 text-muted-foreground">{organization.slug}</td>
|
<Table.Cell className="px-4 py-3 text-muted-foreground">{organization.slug}</Table.Cell>
|
||||||
<td className="px-4 py-3">{organization.status === "active" ? "启用" : "停用"}</td>
|
<Table.Cell className="px-4 py-3">{organization.status === "active" ? "启用" : "停用"}</Table.Cell>
|
||||||
<td className="px-4 py-3 text-right text-muted-foreground">
|
<Table.Cell className="px-4 py-3 text-right text-muted-foreground">
|
||||||
{new Date(organization.createdAt).toLocaleString("zh-CN")}
|
{new Date(organization.createdAt).toLocaleString("zh-CN")}
|
||||||
</td>
|
</Table.Cell>
|
||||||
<td className="px-4 py-3">
|
<Table.Cell className="px-4 py-3">
|
||||||
<OrganizationRowActions
|
<OrganizationRowActions
|
||||||
canInvite={canInviteMembers}
|
canInvite={canInviteMembers}
|
||||||
organization={organization}
|
organization={organization}
|
||||||
roles={rolesByOrganizationId.get(organization.id) ?? []}
|
roles={rolesByOrganizationId.get(organization.id) ?? []}
|
||||||
/>
|
/>
|
||||||
</td>
|
</Table.Cell>
|
||||||
</tr>
|
</Table.Row>
|
||||||
))}
|
))}
|
||||||
{visibleOrganizations.length === 0 ? (
|
{visibleOrganizations.length === 0 ? (
|
||||||
<tr>
|
<Table.Row>
|
||||||
<td className="px-4 py-8 text-center text-muted-foreground" colSpan={5}>
|
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={5}>
|
||||||
暂无匹配机构
|
暂无匹配机构
|
||||||
</td>
|
</Table.Cell>
|
||||||
</tr>
|
</Table.Row>
|
||||||
) : null}
|
) : null}
|
||||||
</tbody>
|
</Table.Body>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
<TablePagination
|
||||||
|
page={Math.min(page, pageCount)}
|
||||||
|
pageCount={pageCount}
|
||||||
|
pathname={pagePath}
|
||||||
|
query={query}
|
||||||
|
total={filteredOrganizations.length}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
export default async function SettingsPage(): Promise<React.ReactElement> {
|
export default async function SettingsPage(): Promise<React.ReactElement> {
|
||||||
redirect("/app/settings/users");
|
const context = await getCurrentAuthContext();
|
||||||
|
redirect(getWorkspaceHref(context?.organization?.slug, "/settings/users"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Table } from "@/components/ui/table";
|
||||||
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||||
import { getRoleDefinitions, hasPermission, PERMISSION_DEFINITIONS } from "@/modules/core/server/permissions";
|
import { getRoleDefinitions, hasPermission, PERMISSION_DEFINITIONS } from "@/modules/core/server/permissions";
|
||||||
import { RoleManagementClient, RoleRowActions } from "@/modules/settings/components/RoleManagementClient";
|
import { RoleManagementClient, RoleRowActions } from "@/modules/settings/components/RoleManagementClient";
|
||||||
import { TableToolbar } from "@/modules/settings/components/TableToolbar";
|
import { TablePagination, TableToolbar } from "@/modules/settings/components/TableToolbar";
|
||||||
import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination";
|
import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination";
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
type RolesPageProps = {
|
type RolesPageProps = {
|
||||||
searchParams?: Promise<SearchParams>;
|
searchParams?: Promise<SearchParams>;
|
||||||
@@ -19,9 +20,10 @@ export default async function RolesPage({ searchParams }: RolesPageProps): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hasPermission(context.permissions, "role:read")) {
|
if (!hasPermission(context.permissions, "role:read")) {
|
||||||
redirect("/app/dashboard");
|
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pagePath = getWorkspaceHref(context.organization?.slug, "/settings/roles");
|
||||||
const params = (await searchParams) ?? {};
|
const params = (await searchParams) ?? {};
|
||||||
const query = getSearchParam(params, "q");
|
const query = getSearchParam(params, "q");
|
||||||
const page = getPage(params);
|
const page = getPage(params);
|
||||||
@@ -36,14 +38,11 @@ export default async function RolesPage({ searchParams }: RolesPageProps): Promi
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
|
{canManageRoles ? (
|
||||||
<div>
|
<div className="flex justify-end">
|
||||||
<Badge variant="success">RBAC</Badge>
|
<RoleManagementClient permissions={PERMISSION_DEFINITIONS} />
|
||||||
<h2 className="mt-3 text-xl font-semibold">角色权限</h2>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">系统权限点、内置角色和机构自定义角色。</p>
|
|
||||||
</div>
|
</div>
|
||||||
{canManageRoles ? <RoleManagementClient permissions={PERMISSION_DEFINITIONS} /> : null}
|
) : null}
|
||||||
</section>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -51,51 +50,56 @@ export default async function RolesPage({ searchParams }: RolesPageProps): Promi
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<TableToolbar
|
<TableToolbar
|
||||||
page={Math.min(page, pageCount)}
|
pathname={pagePath}
|
||||||
pageCount={pageCount}
|
|
||||||
pathname="/app/settings/roles"
|
|
||||||
query={query}
|
query={query}
|
||||||
searchPlaceholder="搜索角色、权限、说明"
|
searchPlaceholder="搜索角色、权限、说明"
|
||||||
total={filteredRoles.length}
|
total={filteredRoles.length}
|
||||||
/>
|
/>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[980px] text-sm">
|
<Table className="w-full min-w-[980px] text-sm">
|
||||||
<thead className="bg-secondary text-left text-xs text-muted-foreground">
|
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
|
||||||
<tr>
|
<Table.Row>
|
||||||
<th className="px-4 py-3 font-medium">角色</th>
|
<Table.Head className="px-4 py-3 font-medium">角色</Table.Head>
|
||||||
<th className="px-4 py-3 font-medium">范围</th>
|
<Table.Head className="px-4 py-3 font-medium">范围</Table.Head>
|
||||||
<th className="px-4 py-3 font-medium">类型</th>
|
<Table.Head className="px-4 py-3 font-medium">类型</Table.Head>
|
||||||
<th className="px-4 py-3 font-medium">状态</th>
|
<Table.Head className="px-4 py-3 font-medium">状态</Table.Head>
|
||||||
<th className="px-4 py-3 font-medium">权限</th>
|
<Table.Head className="px-4 py-3 font-medium">权限</Table.Head>
|
||||||
<th className="px-4 py-3 text-right font-medium">操作</th>
|
<Table.Head className="px-4 py-3 text-right font-medium">操作</Table.Head>
|
||||||
</tr>
|
</Table.Row>
|
||||||
</thead>
|
</Table.Header>
|
||||||
<tbody className="divide-y bg-card">
|
<Table.Body className="divide-y bg-card">
|
||||||
{visibleRoles.map((role) => (
|
{visibleRoles.map((role) => (
|
||||||
<tr className="table-row-enter align-top hover:bg-secondary/45" key={role.id}>
|
<Table.Row className="table-row-enter align-top hover:bg-secondary/45" key={role.id}>
|
||||||
<td className="px-4 py-3">
|
<Table.Cell className="px-4 py-3">
|
||||||
<p className="font-medium">{role.label}</p>
|
<p className="font-medium">{role.label}</p>
|
||||||
<p className="text-xs text-muted-foreground">{role.description}</p>
|
<p className="text-xs text-muted-foreground">{role.description}</p>
|
||||||
</td>
|
</Table.Cell>
|
||||||
<td className="px-4 py-3">{role.scope === "platform" ? "平台" : "机构"}</td>
|
<Table.Cell className="px-4 py-3">{role.scope === "platform" ? "平台" : "机构"}</Table.Cell>
|
||||||
<td className="px-4 py-3">{role.isSystem ? "内置" : "自定义"}</td>
|
<Table.Cell className="px-4 py-3">{role.isSystem ? "内置" : "自定义"}</Table.Cell>
|
||||||
<td className="px-4 py-3">{role.isEnabled ? "启用" : "停用"}</td>
|
<Table.Cell className="px-4 py-3">{role.isEnabled ? "启用" : "停用"}</Table.Cell>
|
||||||
<td className="px-4 py-3 text-muted-foreground">{role.permissions.join(", ") || "-"}</td>
|
<Table.Cell className="px-4 py-3 text-muted-foreground">{role.permissions.join(", ") || "-"}</Table.Cell>
|
||||||
<td className="px-4 py-3">
|
<Table.Cell className="px-4 py-3">
|
||||||
<RoleRowActions permissions={PERMISSION_DEFINITIONS} role={role} />
|
<RoleRowActions permissions={PERMISSION_DEFINITIONS} role={role} />
|
||||||
</td>
|
</Table.Cell>
|
||||||
</tr>
|
</Table.Row>
|
||||||
))}
|
))}
|
||||||
{visibleRoles.length === 0 ? (
|
{visibleRoles.length === 0 ? (
|
||||||
<tr>
|
<Table.Row>
|
||||||
<td className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
|
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
|
||||||
暂无匹配角色
|
暂无匹配角色
|
||||||
</td>
|
</Table.Cell>
|
||||||
</tr>
|
</Table.Row>
|
||||||
) : null}
|
) : null}
|
||||||
</tbody>
|
</Table.Body>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
<TablePagination
|
||||||
|
page={Math.min(page, pageCount)}
|
||||||
|
pageCount={pageCount}
|
||||||
|
pathname={pagePath}
|
||||||
|
query={query}
|
||||||
|
total={filteredRoles.length}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,13 +2,20 @@ import { redirect } from "next/navigation";
|
|||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Table } from "@/components/ui/table";
|
||||||
import { checkDatabaseConnection } from "@/modules/core/server/db";
|
import { checkDatabaseConnection } from "@/modules/core/server/db";
|
||||||
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||||
import { hasPermission } from "@/modules/core/server/permissions";
|
import { hasPermission } from "@/modules/core/server/permissions";
|
||||||
import { readData } from "@/modules/core/server/store";
|
import {
|
||||||
|
countSettingsBeds,
|
||||||
|
listSettingsAccounts,
|
||||||
|
listSettingsIncidents,
|
||||||
|
listSettingsOrganizations,
|
||||||
|
} from "@/modules/core/server/settings";
|
||||||
import { IncidentStatusActions } from "@/modules/settings/components/IncidentStatusActions";
|
import { IncidentStatusActions } from "@/modules/settings/components/IncidentStatusActions";
|
||||||
import { TableToolbar } from "@/modules/settings/components/TableToolbar";
|
import { TablePagination, TableToolbar } from "@/modules/settings/components/TableToolbar";
|
||||||
import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination";
|
import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination";
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
type StatusPageProps = {
|
type StatusPageProps = {
|
||||||
searchParams?: Promise<SearchParams>;
|
searchParams?: Promise<SearchParams>;
|
||||||
@@ -21,16 +28,25 @@ export default async function StatusPage({ searchParams }: StatusPageProps): Pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hasPermission(context.permissions, "incident:read")) {
|
if (!hasPermission(context.permissions, "incident:read")) {
|
||||||
redirect("/app/dashboard");
|
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pagePath = getWorkspaceHref(context.organization?.slug, "/settings/status");
|
||||||
const params = (await searchParams) ?? {};
|
const params = (await searchParams) ?? {};
|
||||||
const query = getSearchParam(params, "q");
|
const query = getSearchParam(params, "q");
|
||||||
const page = getPage(params);
|
const page = getPage(params);
|
||||||
const [data, databaseHealth] = await Promise.all([readData(), checkDatabaseConnection()]);
|
const organizationScopeId = context.account.platformRoleId ? undefined : context.organization?.id;
|
||||||
|
const scopeParams = organizationScopeId ? { organizationId: organizationScopeId } : {};
|
||||||
|
const [organizations, accounts, bedCount, incidents, databaseHealth] = await Promise.all([
|
||||||
|
listSettingsOrganizations(scopeParams),
|
||||||
|
listSettingsAccounts(scopeParams),
|
||||||
|
countSettingsBeds(scopeParams),
|
||||||
|
listSettingsIncidents(scopeParams),
|
||||||
|
checkDatabaseConnection(),
|
||||||
|
]);
|
||||||
const canManageIncidents = hasPermission(context.permissions, "incident:manage");
|
const canManageIncidents = hasPermission(context.permissions, "incident:manage");
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
const filteredIncidents = data.incidents.filter((incident) =>
|
const filteredIncidents = incidents.filter((incident) =>
|
||||||
[incident.title, incident.description, incident.source, incident.severity, incident.status]
|
[incident.title, incident.description, incident.source, incident.severity, incident.status]
|
||||||
.join(" ")
|
.join(" ")
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -41,34 +57,33 @@ export default async function StatusPage({ searchParams }: StatusPageProps): Pro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<section>
|
<section className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">{databaseHealth.reason}</p>
|
||||||
<Badge variant={databaseHealth.ok ? "success" : "danger"}>{databaseHealth.ok ? "Healthy" : "Degraded"}</Badge>
|
<Badge variant={databaseHealth.ok ? "success" : "danger"}>{databaseHealth.ok ? "Healthy" : "Degraded"}</Badge>
|
||||||
<h2 className="mt-3 text-xl font-semibold">运行状态</h2>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{databaseHealth.reason}</p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-4 md:grid-cols-4">
|
<section className="grid gap-4 md:grid-cols-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-3xl">{data.organizations.length}</CardTitle>
|
<CardTitle className="text-3xl">{organizations.length}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0 text-sm text-muted-foreground">机构</CardContent>
|
<CardContent className="pt-0 text-sm text-muted-foreground">机构</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-3xl">{data.accounts.length}</CardTitle>
|
<CardTitle className="text-3xl">{accounts.length}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0 text-sm text-muted-foreground">账号</CardContent>
|
<CardContent className="pt-0 text-sm text-muted-foreground">账号</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-3xl">{data.beds.length}</CardTitle>
|
<CardTitle className="text-3xl">{bedCount}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0 text-sm text-muted-foreground">床位</CardContent>
|
<CardContent className="pt-0 text-sm text-muted-foreground">床位</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-3xl">{data.incidents.length}</CardTitle>
|
<CardTitle className="text-3xl">{incidents.length}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0 text-sm text-muted-foreground">故障事件</CardContent>
|
<CardContent className="pt-0 text-sm text-muted-foreground">故障事件</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -80,57 +95,62 @@ export default async function StatusPage({ searchParams }: StatusPageProps): Pro
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<TableToolbar
|
<TableToolbar
|
||||||
page={Math.min(page, pageCount)}
|
pathname={pagePath}
|
||||||
pageCount={pageCount}
|
|
||||||
pathname="/app/settings/status"
|
|
||||||
query={query}
|
query={query}
|
||||||
searchPlaceholder="搜索故障标题、来源、状态"
|
searchPlaceholder="搜索故障标题、来源、状态"
|
||||||
total={filteredIncidents.length}
|
total={filteredIncidents.length}
|
||||||
/>
|
/>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[960px] text-sm">
|
<Table className="w-full min-w-[960px] text-sm">
|
||||||
<thead className="bg-secondary text-left text-xs text-muted-foreground">
|
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
|
||||||
<tr>
|
<Table.Row>
|
||||||
<th className="px-4 py-3 font-medium">标题</th>
|
<Table.Head className="px-4 py-3 font-medium">标题</Table.Head>
|
||||||
<th className="px-4 py-3 font-medium">严重级别</th>
|
<Table.Head className="px-4 py-3 font-medium">严重级别</Table.Head>
|
||||||
<th className="px-4 py-3 font-medium">状态</th>
|
<Table.Head className="px-4 py-3 font-medium">状态</Table.Head>
|
||||||
<th className="px-4 py-3 font-medium">来源</th>
|
<Table.Head className="px-4 py-3 font-medium">来源</Table.Head>
|
||||||
<th className="px-4 py-3 text-right font-medium">创建时间</th>
|
<Table.Head className="px-4 py-3 text-right font-medium">创建时间</Table.Head>
|
||||||
<th className="px-4 py-3 text-right font-medium">操作</th>
|
<Table.Head className="px-4 py-3 text-right font-medium">操作</Table.Head>
|
||||||
</tr>
|
</Table.Row>
|
||||||
</thead>
|
</Table.Header>
|
||||||
<tbody className="divide-y bg-card">
|
<Table.Body className="divide-y bg-card">
|
||||||
{visibleIncidents.map((incident) => (
|
{visibleIncidents.map((incident) => (
|
||||||
<tr className="table-row-enter align-top hover:bg-secondary/45" key={incident.id}>
|
<Table.Row className="table-row-enter align-top hover:bg-secondary/45" key={incident.id}>
|
||||||
<td className="px-4 py-3">
|
<Table.Cell className="px-4 py-3">
|
||||||
<p className="font-medium">{incident.title}</p>
|
<p className="font-medium">{incident.title}</p>
|
||||||
<p className="text-xs text-muted-foreground">{incident.description}</p>
|
<p className="text-xs text-muted-foreground">{incident.description}</p>
|
||||||
</td>
|
</Table.Cell>
|
||||||
<td className="px-4 py-3">{incident.severity}</td>
|
<Table.Cell className="px-4 py-3">{incident.severity}</Table.Cell>
|
||||||
<td className="px-4 py-3">{incident.status}</td>
|
<Table.Cell className="px-4 py-3">{incident.status}</Table.Cell>
|
||||||
<td className="px-4 py-3 text-muted-foreground">{incident.source}</td>
|
<Table.Cell className="px-4 py-3 text-muted-foreground">{incident.source}</Table.Cell>
|
||||||
<td className="px-4 py-3 text-right text-muted-foreground">
|
<Table.Cell className="px-4 py-3 text-right text-muted-foreground">
|
||||||
{new Date(incident.createdAt).toLocaleString("zh-CN")}
|
{new Date(incident.createdAt).toLocaleString("zh-CN")}
|
||||||
</td>
|
</Table.Cell>
|
||||||
<td className="px-4 py-3">
|
<Table.Cell className="px-4 py-3">
|
||||||
{canManageIncidents ? (
|
{canManageIncidents ? (
|
||||||
<IncidentStatusActions incident={incident} />
|
<IncidentStatusActions incident={incident} />
|
||||||
) : (
|
) : (
|
||||||
<span className="block text-right text-xs text-muted-foreground">只读</span>
|
<span className="block text-right text-xs text-muted-foreground">只读</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</Table.Cell>
|
||||||
</tr>
|
</Table.Row>
|
||||||
))}
|
))}
|
||||||
{visibleIncidents.length === 0 ? (
|
{visibleIncidents.length === 0 ? (
|
||||||
<tr>
|
<Table.Row>
|
||||||
<td className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
|
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
|
||||||
暂无匹配故障
|
暂无匹配故障
|
||||||
</td>
|
</Table.Cell>
|
||||||
</tr>
|
</Table.Row>
|
||||||
) : null}
|
) : null}
|
||||||
</tbody>
|
</Table.Body>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
<TablePagination
|
||||||
|
page={Math.min(page, pageCount)}
|
||||||
|
pageCount={pageCount}
|
||||||
|
pathname={pagePath}
|
||||||
|
query={query}
|
||||||
|
total={filteredIncidents.length}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,15 +2,22 @@ import { redirect } from "next/navigation";
|
|||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Table } from "@/components/ui/table";
|
||||||
import { getCurrentAuthContext, toPublicAccount } from "@/modules/core/server/auth";
|
import { getCurrentAuthContext, toPublicAccount } from "@/modules/core/server/auth";
|
||||||
import { getRoleDefinitions, hasPermission } from "@/modules/core/server/permissions";
|
import { getRoleDefinitions, hasPermission } from "@/modules/core/server/permissions";
|
||||||
import { readData } from "@/modules/core/server/store";
|
import {
|
||||||
|
listSettingsAccounts,
|
||||||
|
listSettingsJoinRequests,
|
||||||
|
listSettingsMemberships,
|
||||||
|
listSettingsOrganizations,
|
||||||
|
} from "@/modules/core/server/settings";
|
||||||
import { ROLE_LABELS } from "@/modules/core/types";
|
import { ROLE_LABELS } from "@/modules/core/types";
|
||||||
import type { RoleId } from "@/modules/core/types";
|
import type { RoleId } from "@/modules/core/types";
|
||||||
import { TableToolbar } from "@/modules/settings/components/TableToolbar";
|
import { TablePagination, TableToolbar } from "@/modules/settings/components/TableToolbar";
|
||||||
import { UserAccountActions, UserManagementClient } from "@/modules/settings/components/UserManagementClient";
|
import { UserAccountActions, UserManagementClient } from "@/modules/settings/components/UserManagementClient";
|
||||||
import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination";
|
import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination";
|
||||||
import { UserAvatar } from "@/modules/shared/components/UserAvatar";
|
import { UserAvatar } from "@/modules/shared/components/UserAvatar";
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
type UsersPageProps = {
|
type UsersPageProps = {
|
||||||
searchParams?: Promise<SearchParams>;
|
searchParams?: Promise<SearchParams>;
|
||||||
@@ -31,22 +38,30 @@ export default async function UsersPage({ searchParams }: UsersPageProps): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hasPermission(context.permissions, "account:read")) {
|
if (!hasPermission(context.permissions, "account:read")) {
|
||||||
redirect("/app/dashboard");
|
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pagePath = getWorkspaceHref(context.organization?.slug, "/settings/users");
|
||||||
const params = (await searchParams) ?? {};
|
const params = (await searchParams) ?? {};
|
||||||
const query = getSearchParam(params, "q");
|
const query = getSearchParam(params, "q");
|
||||||
const page = getPage(params);
|
const page = getPage(params);
|
||||||
const data = await readData();
|
const canReadAllAccounts = Boolean(context.account.platformRoleId);
|
||||||
const roles = await getRoleDefinitions(context.organization?.id);
|
const organizationScopeId = canReadAllAccounts ? undefined : context.organization?.id;
|
||||||
|
const [organizations, accountsData, memberships, joinRequests, roles] = await Promise.all([
|
||||||
|
listSettingsOrganizations(organizationScopeId ? { organizationId: organizationScopeId } : {}),
|
||||||
|
listSettingsAccounts(organizationScopeId ? { organizationId: organizationScopeId } : {}),
|
||||||
|
listSettingsMemberships(organizationScopeId ? { organizationId: organizationScopeId } : {}),
|
||||||
|
listSettingsJoinRequests(organizationScopeId ? { organizationId: organizationScopeId } : {}),
|
||||||
|
getRoleDefinitions(context.organization?.id),
|
||||||
|
]);
|
||||||
const roleById = new Map(roles.map((role) => [role.id, role]));
|
const roleById = new Map(roles.map((role) => [role.id, role]));
|
||||||
const organizationById = new Map(data.organizations.map((organization) => [organization.id, organization]));
|
const organizationById = new Map(organizations.map((organization) => [organization.id, organization]));
|
||||||
const accounts = data.accounts.map((account) => {
|
const accounts = accountsData.map((account) => {
|
||||||
const membership = context.organization?.id
|
const membership = context.organization?.id
|
||||||
? data.memberships.find(
|
? memberships.find(
|
||||||
(item) => item.accountId === account.id && item.organizationId === context.organization?.id && item.status === "active",
|
(item) => item.accountId === account.id && item.organizationId === context.organization?.id && item.status === "active",
|
||||||
)
|
)
|
||||||
: data.memberships.find((item) => item.accountId === account.id && item.status === "active");
|
: memberships.find((item) => item.accountId === account.id && item.status === "active");
|
||||||
const organization = membership ? organizationById.get(membership.organizationId) : undefined;
|
const organization = membership ? organizationById.get(membership.organizationId) : undefined;
|
||||||
const platformRole = account.platformRoleId ? roleById.get(account.platformRoleId) : undefined;
|
const platformRole = account.platformRoleId ? roleById.get(account.platformRoleId) : undefined;
|
||||||
|
|
||||||
@@ -64,17 +79,11 @@ export default async function UsersPage({ searchParams }: UsersPageProps): Promi
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<section>
|
|
||||||
<Badge variant="success">Accounts</Badge>
|
|
||||||
<h2 className="mt-3 text-xl font-semibold">用户管理</h2>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">账号创建、机构加入审批、角色分配和账号列表。</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<UserManagementClient
|
<UserManagementClient
|
||||||
canManageAccounts={hasPermission(context.permissions, "account:manage")}
|
canManageAccounts={hasPermission(context.permissions, "account:manage")}
|
||||||
organizations={data.organizations}
|
organizations={organizations}
|
||||||
roles={roles}
|
roles={roles}
|
||||||
joinRequests={data.joinRequests}
|
joinRequests={joinRequests}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@@ -83,29 +92,27 @@ export default async function UsersPage({ searchParams }: UsersPageProps): Promi
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<TableToolbar
|
<TableToolbar
|
||||||
page={Math.min(page, pageCount)}
|
pathname={pagePath}
|
||||||
pageCount={pageCount}
|
|
||||||
pathname="/app/settings/users"
|
|
||||||
query={query}
|
query={query}
|
||||||
searchPlaceholder="搜索姓名、邮箱、角色、状态"
|
searchPlaceholder="搜索姓名、邮箱、角色、状态"
|
||||||
total={filteredAccounts.length}
|
total={filteredAccounts.length}
|
||||||
/>
|
/>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[920px] text-sm">
|
<Table className="w-full min-w-[920px] text-sm">
|
||||||
<thead className="bg-secondary text-left text-xs text-muted-foreground">
|
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
|
||||||
<tr>
|
<Table.Row>
|
||||||
<th className="px-4 py-3 font-medium">账号</th>
|
<Table.Head className="px-4 py-3 font-medium">账号</Table.Head>
|
||||||
<th className="px-4 py-3 font-medium">角色</th>
|
<Table.Head className="px-4 py-3 font-medium">角色</Table.Head>
|
||||||
<th className="px-4 py-3 font-medium">机构</th>
|
<Table.Head className="px-4 py-3 font-medium">机构</Table.Head>
|
||||||
<th className="px-4 py-3 font-medium">状态</th>
|
<Table.Head className="px-4 py-3 font-medium">状态</Table.Head>
|
||||||
<th className="px-4 py-3 text-right font-medium">创建时间</th>
|
<Table.Head className="px-4 py-3 text-right font-medium">创建时间</Table.Head>
|
||||||
<th className="px-4 py-3 text-right font-medium">操作</th>
|
<Table.Head className="px-4 py-3 text-right font-medium">操作</Table.Head>
|
||||||
</tr>
|
</Table.Row>
|
||||||
</thead>
|
</Table.Header>
|
||||||
<tbody className="divide-y bg-card">
|
<Table.Body className="divide-y bg-card">
|
||||||
{visibleAccounts.map((account) => (
|
{visibleAccounts.map((account) => (
|
||||||
<tr className="table-row-enter hover:bg-secondary/45" key={account.id}>
|
<Table.Row className="table-row-enter hover:bg-secondary/45" key={account.id}>
|
||||||
<td className="px-4 py-3">
|
<Table.Cell className="px-4 py-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<UserAvatar avatarUrl={account.avatarUrl} name={account.name} size="sm" />
|
<UserAvatar avatarUrl={account.avatarUrl} name={account.name} size="sm" />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -113,37 +120,44 @@ export default async function UsersPage({ searchParams }: UsersPageProps): Promi
|
|||||||
<p className="truncate text-xs text-muted-foreground">{account.email}</p>
|
<p className="truncate text-xs text-muted-foreground">{account.email}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</Table.Cell>
|
||||||
<td className="px-4 py-3">{formatRoleLabel(account.role)}</td>
|
<Table.Cell className="px-4 py-3">{formatRoleLabel(account.role)}</Table.Cell>
|
||||||
<td className="px-4 py-3 text-muted-foreground">{account.organization ?? "-"}</td>
|
<Table.Cell className="px-4 py-3 text-muted-foreground">{account.organization ?? "-"}</Table.Cell>
|
||||||
<td className="px-4 py-3">
|
<Table.Cell className="px-4 py-3">
|
||||||
<Badge variant={account.status === "active" ? "success" : account.status === "pending" ? "warning" : "danger"}>
|
<Badge variant={account.status === "active" ? "success" : account.status === "pending" ? "warning" : "danger"}>
|
||||||
{account.status === "active" ? "启用" : account.status === "pending" ? "待审批" : "停用"}
|
{account.status === "active" ? "启用" : account.status === "pending" ? "待审批" : "停用"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</Table.Cell>
|
||||||
<td className="px-4 py-3 text-right text-muted-foreground">
|
<Table.Cell className="px-4 py-3 text-right text-muted-foreground">
|
||||||
{new Date(account.createdAt).toLocaleString("zh-CN")}
|
{new Date(account.createdAt).toLocaleString("zh-CN")}
|
||||||
</td>
|
</Table.Cell>
|
||||||
<td className="px-4 py-3">
|
<Table.Cell className="px-4 py-3">
|
||||||
<UserAccountActions
|
<UserAccountActions
|
||||||
account={account}
|
account={account}
|
||||||
currentAccountId={context.account.id}
|
currentAccountId={context.account.id}
|
||||||
organizations={data.organizations}
|
organizations={organizations}
|
||||||
roles={roles}
|
roles={roles}
|
||||||
/>
|
/>
|
||||||
</td>
|
</Table.Cell>
|
||||||
</tr>
|
</Table.Row>
|
||||||
))}
|
))}
|
||||||
{visibleAccounts.length === 0 ? (
|
{visibleAccounts.length === 0 ? (
|
||||||
<tr>
|
<Table.Row>
|
||||||
<td className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
|
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
|
||||||
暂无匹配账号
|
暂无匹配账号
|
||||||
</td>
|
</Table.Cell>
|
||||||
</tr>
|
</Table.Row>
|
||||||
) : null}
|
) : null}
|
||||||
</tbody>
|
</Table.Body>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
<TablePagination
|
||||||
|
page={Math.min(page, pageCount)}
|
||||||
|
pageCount={pageCount}
|
||||||
|
pathname={pagePath}
|
||||||
|
query={query}
|
||||||
|
total={filteredAccounts.length}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
7
app/(app)/app/template.tsx
Normal file
7
app/(app)/app/template.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
type AppTemplateProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AppTemplate({ children }: AppTemplateProps): React.ReactElement {
|
||||||
|
return <div className="route-transition min-h-full">{children}</div>;
|
||||||
|
}
|
||||||
66
app/api/account/profile/route.ts
Normal file
66
app/api/account/profile/route.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
|
||||||
|
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||||
|
import { getCurrentAuthContext, toPublicAccount } from "@/modules/core/server/auth";
|
||||||
|
import { getDatabase } from "@/modules/core/server/db";
|
||||||
|
import { accounts } from "@/modules/core/server/schema";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
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() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: Request): Promise<Response> {
|
||||||
|
const context = await getCurrentAuthContext();
|
||||||
|
if (!context) {
|
||||||
|
return jsonFailure("未登录或会话已过期", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!isRecord(body)) {
|
||||||
|
return jsonFailure("请求数据格式无效");
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = readString(body, "name");
|
||||||
|
const avatarUrl = readString(body, "avatarUrl");
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return jsonFailure("用户名称不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
const database = getDatabase();
|
||||||
|
const rows = await database
|
||||||
|
.update(accounts)
|
||||||
|
.set({
|
||||||
|
name,
|
||||||
|
avatarUrl,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(accounts.id, context.account.id))
|
||||||
|
.returning();
|
||||||
|
const account = rows[0];
|
||||||
|
if (!account) {
|
||||||
|
return jsonFailure("账号不存在", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicAccount = toPublicAccount(account, context.account.role, context.organization);
|
||||||
|
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: publicAccount,
|
||||||
|
organizationId: context.organization?.id,
|
||||||
|
action: "account.profile.update",
|
||||||
|
targetType: "account",
|
||||||
|
targetId: publicAccount.id,
|
||||||
|
result: "success",
|
||||||
|
reason: "更新个人资料",
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonSuccess("用户资料已保存", { account: publicAccount });
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user