Compare commits

..

46 Commits

Author SHA1 Message Date
0bc296a8ee feat: wire health workspace pages 2026-07-03 02:04:52 -07:00
8af60c2d2a chore: record journal 2026-07-03 01:03:50 -07:00
d2404616bc fix: refine settings dialog layout 2026-07-03 01:03:34 -07:00
b5e2aa6df1 chore: record journal 2026-07-03 00:58:22 -07:00
2095e9d804 chore(task): archive 07-02-operations-module-roadmap 2026-07-03 00:58:22 -07:00
b7f64ad3da chore: record journal 2026-07-03 00:57:51 -07:00
4792aa5a77 chore(task): archive 07-02-operations-dashboard-integration 2026-07-03 00:57:51 -07:00
9d73457f79 feat: integrate operations dashboard summaries 2026-07-03 00:57:42 -07:00
3d55975975 chore: record journal 2026-07-03 00:46:37 -07:00
4a9a7351e3 chore(task): archive 07-02-elder-bed-context-optimization 2026-07-03 00:46:18 -07:00
0ce8cb9644 feat: add elder bed operational context 2026-07-03 00:46:17 -07:00
f620d6c3dd chore: record journal 2026-07-03 00:41:44 -07:00
fe9db61bbb chore(task): archive 07-02-emergency-incident-workspace 2026-07-03 00:41:09 -07:00
a0e50a9e83 feat: add emergency incident workspace 2026-07-03 00:41:09 -07:00
63b90394ad chore: record journal 2026-07-03 00:35:14 -07:00
dddabc706c chore(task): archive 07-02-care-execution-workspace 2026-07-03 00:35:13 -07:00
b6b15acc69 feat: add care execution workspace 2026-07-03 00:35:04 -07:00
df7c2efcc5 chore: record journal 2026-07-03 00:25:38 -07:00
20f68303e2 chore(task): archive 07-02-care-service-workspace 2026-07-03 00:25:30 -07:00
41807ff557 feat: add health data management workspace 2026-07-03 00:25:11 -07:00
dc034b6d85 chore: record workspace routing session 2026-07-02 20:15:17 -07:00
1fcbddbf39 chore(task): archive nextjs crud rbac audit 2026-07-02 20:14:36 -07:00
3ab0e3e034 feat: scope workspace routes by organization slug 2026-07-02 20:11:57 -07:00
fae97a7046 chore: record settings cleanup session 2026-07-02 20:01:13 -07:00
affad1f59d fix: stabilize settings controls 2026-07-02 19:54:16 -07:00
5412bbb143 fix: use real settings data boundaries 2026-07-02 19:53:41 -07:00
1cdf89c608 feat: add real account workspace controls 2026-07-02 19:52:29 -07:00
1b8fae0116 docs: require real facility hierarchy 2026-07-02 19:16:58 -07:00
5fe7e5a1b8 fix: tighten real operations data paths 2026-07-02 19:16:29 -07:00
8b2fb0930e docs: document business form defaults 2026-07-02 19:00:16 -07:00
6efe3a6615 fix: require explicit business form choices 2026-07-02 18:59:42 -07:00
12c3ca560c chore: drop stale settings overview 2026-07-02 18:42:48 -07:00
a8e8bb2bc1 chore: remove generated artifacts from repo 2026-07-02 18:42:05 -07:00
0b4ed0c0f6 fix: remove fake module data 2026-07-02 18:25:09 -07:00
7718d9759c fix: tighten workspace controls 2026-07-02 18:24:35 -07:00
fd548eaa7b chore: harden local dev environment 2026-07-02 18:01:42 -07:00
ac06490bb9 docs: sync drizzle task contracts 2026-07-02 18:01:21 -07:00
381233c675 fix: stabilize kumo ui components 2026-07-02 18:00:58 -07:00
c181e6fbdd fix: surface active beds on elder records 2026-07-02 17:43:00 -07:00
9480030391 feat: complete admission workspace 2026-07-02 17:30:24 -07:00
c394d85236 fix: harden kumo base component styling 2026-07-02 10:21:25 -07:00
04f2dfc229 fix: polish kumo dialog and select styling 2026-07-02 09:45:21 -07:00
22e61f5efe fix: suppress root hydration warning 2026-07-02 08:56:52 -07:00
89a59956ac fix: make settings controls editable 2026-07-02 08:25:07 -07:00
67fcb8fabd fix: make kumo adapters server safe 2026-07-02 07:46:36 -07:00
0f0bd8813d feat: adopt kumo green ui system 2026-07-02 07:12:31 -07:00
207 changed files with 21014 additions and 4190 deletions

10
.gitignore vendored
View File

@@ -1,8 +1,18 @@
.data/
.env*.local
.next/
node_modules/
tsconfig.tsbuildinfo
output/
tmp/
auth-*-snapshot.md
auth-after-*.md
dashboard-*.md
teatea-dashboard-*.png
teatea-mobile-snapshot.md
register-select-after.png
*.log
.DS_Store

View File

@@ -4,6 +4,70 @@ This document covers backend authentication integration using better-auth, inclu
## 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
better-auth is a modern authentication library for TypeScript applications that provides:

View File

@@ -191,6 +191,70 @@ export async function getOrdersWithItems(params: {
## 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
When using PostgreSQL JSON/JSONB columns, proper casting is required for JSON functions.

View 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)));
```

View File

@@ -20,6 +20,7 @@
| [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 |
| [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 |
| [deployment.md](./deployment.md) | Wulanchabu deployment target and validation contract | Deploying this project |
| [performance.md](./performance.md) | Concurrency, caching, batch processing, streaming | Performance optimization |

View File

@@ -97,6 +97,180 @@ export function InteractiveWidget({ initialData }: { initialData: Data }) {
## 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
```typescript

View File

@@ -155,6 +155,71 @@ app/(app)/
└── 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
Configure in `tsconfig.json`:

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -3,7 +3,7 @@
"name": "nextjs-fullstack-crud-rbac-audit",
"title": "Next.js Full-Stack CRUD, RBAC, and Audit Logs",
"description": "",
"status": "in_progress",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
@@ -11,7 +11,7 @@
"creator": "TalexDreamSoul",
"assignee": "TalexDreamSoul",
"createdAt": "2026-07-01",
"completedAt": null,
"completedAt": "2026-07-02",
"branch": null,
"base_branch": "main",
"worktree_path": null,

View File

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

View File

@@ -0,0 +1,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.

View File

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

View File

@@ -0,0 +1,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.

View File

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

View File

@@ -0,0 +1,26 @@
{
"id": "care-execution-workspace",
"name": "care-execution-workspace",
"title": "护理服务执行工作台",
"description": "",
"status": "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": {}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
{
"id": "care-service-workspace",
"name": "care-service-workspace",
"title": "新增健康照护后台管理页",
"description": "新增独立的健康数据管理后台页面,与现有运营健康照护入口分开。",
"status": "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": {}
}

View File

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

View File

@@ -0,0 +1,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.

View File

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

View File

@@ -0,0 +1,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.

View File

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

View File

@@ -0,0 +1,26 @@
{
"id": "elder-bed-context-optimization",
"name": "elder-bed-context-optimization",
"title": "老人床位联动优化",
"description": "",
"status": "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": {}
}

View File

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

View File

@@ -0,0 +1,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.

View File

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

View File

@@ -0,0 +1,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.

View File

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

View File

@@ -0,0 +1,26 @@
{
"id": "emergency-incident-workspace",
"name": "emergency-incident-workspace",
"title": "安全应急事件工作台",
"description": "",
"status": "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": {}
}

View File

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

View File

@@ -0,0 +1,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.

View File

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

View File

@@ -0,0 +1,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.

View File

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

View File

@@ -0,0 +1,26 @@
{
"id": "operations-dashboard-integration",
"name": "operations-dashboard-integration",
"title": "运营看板整合优化",
"description": "",
"status": "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": {}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
{
"id": "operations-module-roadmap",
"name": "operations-module-roadmap",
"title": "运营模块完善总计划",
"description": "",
"status": "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": {}
}

View File

@@ -8,8 +8,8 @@
<!-- @@@auto:current-status -->
- **Active File**: `journal-1.md`
- **Total Sessions**: 0
- **Last Active**: -
- **Total Sessions**: 9
- **Last Active**: 2026-07-03
<!-- @@@/auto:current-status -->
---
@@ -19,7 +19,7 @@
<!-- @@@auto:active-documents -->
| File | Lines | Status |
|------|-------|--------|
| `journal-1.md` | ~0 | Active |
| `journal-1.md` | ~316 | Active |
<!-- @@@/auto:active-documents -->
---
@@ -29,6 +29,15 @@
<!-- @@@auto:session-history -->
| # | 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 -->
---

View File

@@ -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

View File

@@ -0,0 +1,5 @@
import { AlertsModulePage } from "@/modules/shared/components/ReservedModulePages";
export default function ScopedAlertsPage(): React.ReactElement {
return <AlertsModulePage />;
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
import { DevicesModulePage } from "@/modules/shared/components/ReservedModulePages";
export default function ScopedDevicesPage(): React.ReactElement {
return <DevicesModulePage />;
}

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
import { FamilyModulePage } from "@/modules/shared/components/ReservedModulePages";
export default function ScopedFamilyPage(): React.ReactElement {
return <FamilyModulePage />;
}

View File

@@ -0,0 +1,5 @@
import { renderHealthWorkspacePage } from "@/modules/health/components/HealthWorkspacePage";
export default async function ScopedHealthPage(): Promise<React.ReactElement> {
return renderHealthWorkspacePage();
}

View 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}</>;
}

View File

@@ -0,0 +1,5 @@
import { NoticesModulePage } from "@/modules/shared/components/ReservedModulePages";
export default function ScopedNoticesPage(): React.ReactElement {
return <NoticesModulePage />;
}

View 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"));
}

View 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 });
}

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
import SettingsLayout from "../../settings/layout";
type ScopedSettingsLayoutProps = {
children: React.ReactNode;
};
export default function ScopedSettingsLayout({ children }: ScopedSettingsLayoutProps): React.ReactElement {
return <SettingsLayout>{children}</SettingsLayout>;
}

View File

@@ -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 }) });
}

View File

@@ -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 });
}

View File

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

View 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 });
}

View 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 });
}

View 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 });
}

View File

@@ -1,21 +1,5 @@
import { Radio } from "lucide-react";
import { ModulePage } from "@/modules/shared/components/ModulePage";
import { AlertsModulePage } from "@/modules/shared/components/ReservedModulePages";
export default function AlertsPage(): React.ReactElement {
return (
<ModulePage
title="规则预警"
eyebrow="规则中心与风险识别"
description="三期引入健康阈值、任务逾期、设备故障、应急事件和风险老人识别规则。"
icon={Radio}
phase="三期预留"
metrics={[
{ label: "规则类型", value: "5", detail: "健康、用药、护理、设备、应急" },
{ label: "预警状态", value: "4", detail: "生成、确认、处理、关闭" },
{ label: "风险维度", value: "3", detail: "健康异常、事件频次、护理强度" },
]}
workflows={["配置预警规则", "匹配业务来源数据", "生成预警记录", "确认处理并关闭"]}
/>
);
return <AlertsModulePage />;
}

View File

@@ -1,11 +1,16 @@
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 {
listOperationalAdmissions,
listOperationalBeds,
listOperationalElders,
listOperationalRooms,
} from "@/modules/core/server/operations";
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> {
const context = await getCurrentAuthContext();
@@ -14,221 +19,34 @@ export default async function BedsPage(): Promise<React.ReactElement> {
}
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 rooms = organizationId ? data.rooms.filter((room) => room.organizationId === organizationId) : data.rooms;
const beds = organizationId ? data.beds.filter((bed) => bed.organizationId === organizationId) : data.beds;
const admissions = organizationId
? data.admissions.filter((admission) => admission.organizationId === organizationId)
: data.admissions;
const occupiedBeds = beds.filter((bed) => bed.status === "occupied").length;
const maintenanceBeds = beds.filter((bed) => bed.status === "maintenance").length;
const occupancyRate = beds.length > 0 ? Math.round((occupiedBeds / beds.length) * 100) : 0;
const [rooms, beds, admissions, elders] = await Promise.all([
listOperationalRooms(organizationId),
listOperationalBeds(organizationId),
listOperationalAdmissions(organizationId),
listOperationalElders(organizationId),
]);
const contextData = organizationId
? await listElderBedContextData({
organizationId,
permissions: context.permissions,
elders,
beds,
admissions,
})
: { bedContexts: {}, elderContexts: {} };
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<Badge variant="success">Facility + Admission</Badge>
<h1 className="mt-3 text-3xl font-semibold tracking-normal"></h1>
<p className="mt-2 text-sm text-muted-foreground"></p>
</div>
</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>
<BedsWorkspace
bedContexts={contextData.bedContexts}
initialAdmissions={admissions}
initialBeds={beds}
initialElders={elders}
initialRooms={rooms}
permissions={context.permissions}
/>
);
}

View File

@@ -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 {
return (
<ModulePage
title="护理服务"
eyebrow="护理计划、任务与巡检"
description="按护理等级制定计划,生成任务并支持接收、执行、完成和逾期标记。"
icon={ClipboardCheck}
phase="一期"
metrics={[
{ label: "今日任务", value: "184", detail: "已完成 142 项" },
{ label: "逾期任务", value: "4", detail: "集中在午间巡检和复测任务" },
{ label: "巡检打卡", value: "91%", detail: "一楼护理区已全部完成" },
]}
workflows={["配置护理等级", "创建护理计划", "派发护理任务", "巡检打卡并提交记录"]}
/>
);
export default async function CarePage(): Promise<React.ReactElement> {
const context = await getCurrentAuthContext();
if (!context) {
redirect("/login");
}
if (!hasPermission(context.permissions, "care:read")) {
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
}
const organizationId = context.organization?.id;
if (!organizationId) {
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
}
const data = await listCareExecutionData(organizationId);
return <CareWorkspaceClient canManage={hasPermission(context.permissions, "care:manage")} initialData={data} />;
}

View File

@@ -1,5 +1,143 @@
import { DashboardHome } from "@/modules/dashboard/components/DashboardHome";
import { redirect } from "next/navigation";
export default function DashboardPage(): React.ReactElement {
return <DashboardHome />;
import { getCurrentAuthContext } from "@/modules/core/server/auth";
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}
/>
);
}

View File

@@ -1,21 +1,5 @@
import { Wrench } from "lucide-react";
import { ModulePage } from "@/modules/shared/components/ModulePage";
import { DevicesModulePage } from "@/modules/shared/components/ReservedModulePages";
export default function DevicesPage(): React.ReactElement {
return (
<ModulePage
title="设备运维"
eyebrow="设备台账与维修工单"
description="维护设备编号、类型、位置、状态和责任人,支持故障上报与维修归档。"
icon={Wrench}
phase="一期"
metrics={[
{ label: "设备总数", value: "312", detail: "床旁呼叫、监测设备、公共设施" },
{ label: "故障设备", value: "2", detail: "均已生成维修工单" },
{ label: "待派单", value: "1", detail: "需维修人员确认" },
]}
workflows={["登记设备台账", "上报设备故障", "派发维修工单", "记录维修结果并恢复状态"]}
/>
);
return <DevicesModulePage />;
}

View File

@@ -1,8 +1,11 @@
import { redirect } from "next/navigation";
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 { listElderBedContextData } from "@/modules/operations/server/context";
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
export default async function EldersPage(): Promise<React.ReactElement> {
const context = await getCurrentAuthContext();
@@ -10,6 +13,23 @@ export default async function EldersPage(): Promise<React.ReactElement> {
redirect("/login");
}
const data = await readData();
return <EldersClient initialElders={data.elders} permissions={context.permissions} />;
if (!hasPermission(context.permissions, "elder:read")) {
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} />;
}

View File

@@ -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 {
return (
<ModulePage
title="安全应急"
eyebrow="紧急呼叫与事件处理"
description="支持老人或护理人员提交紧急呼叫,形成接警、派发、处理、完成、归档闭环。"
icon={ShieldAlert}
phase="一期"
metrics={[
{ label: "今日呼叫", value: "6", detail: "平均响应 2 分 18 秒" },
{ label: "处理中", value: "1", detail: "已通知护理主管与医护人员" },
{ label: "已归档", value: "21", detail: "本周事件处理率 100%" },
]}
workflows={["提交紧急呼叫", "生成应急事件", "派发责任人员", "记录处理结果并通知相关人员"]}
/>
);
export default async function EmergencyPage(): Promise<React.ReactElement> {
const context = await getCurrentAuthContext();
if (!context) {
redirect("/login");
}
if (!hasPermission(context.permissions, "incident:read")) {
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
}
const organizationId = context.organization?.id;
if (!organizationId) {
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
}
const data = await listEmergencyIncidentData(organizationId);
return <EmergencyWorkspaceClient canManage={hasPermission(context.permissions, "incident:manage")} initialData={data} />;
}

View File

@@ -1,21 +1,5 @@
import { TabletSmartphone } from "lucide-react";
import { ModulePage } from "@/modules/shared/components/ModulePage";
import { FamilyModulePage } from "@/modules/shared/components/ReservedModulePages";
export default function FamilyPage(): React.ReactElement {
return (
<ModulePage
title="家属服务"
eyebrow="家属端与探访反馈"
description="二期补齐家属查看老人动态、健康记录、护理记录、探访预约和服务反馈能力。"
icon={TabletSmartphone}
phase="二期预留"
metrics={[
{ label: "绑定家属", value: "214", detail: "一个老人可绑定多个授权家属" },
{ label: "探访预约", value: "12", detail: "待审核 4 条" },
{ label: "服务反馈", value: "7", detail: "2 条需要机构回复" },
]}
workflows={["绑定老人和家属账号", "查看授权范围内数据", "提交探访预约", "反馈处理与归档"]}
/>
);
return <FamilyModulePage />;
}

View File

@@ -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 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={["录入生命体征", "维护健康摘要", "识别异常指标", "形成处理建议并归档"]}
/>
);
export default async function HealthPage(): Promise<React.ReactElement> {
return renderHealthWorkspacePage();
}

View File

@@ -26,7 +26,12 @@ export default async function AppLayout({ children }: AppLayoutProps): Promise<R
}
return (
<AppShell account={context.account} permissions={context.permissions}>
<AppShell
account={context.account}
organization={context.organization}
organizations={context.organizations}
permissions={context.permissions}
>
{children}
</AppShell>
);

View File

@@ -1,21 +1,5 @@
import { Bell } from "lucide-react";
import { ModulePage } from "@/modules/shared/components/ModulePage";
import { NoticesModulePage } from "@/modules/shared/components/ReservedModulePages";
export default function NoticesPage(): React.ReactElement {
return (
<ModulePage
title="公告通知"
eyebrow="公告发布与阅读状态"
description="按角色和范围发布公告,区分草稿、发布、未读和已读状态。"
icon={Bell}
phase="一期"
metrics={[
{ label: "已发布公告", value: "18", detail: "本月面向全员 5 条" },
{ label: "未读消息", value: "64", detail: "护理人员未读占比最高" },
{ label: "草稿", value: "3", detail: "草稿不会对目标用户可见" },
]}
workflows={["创建公告草稿", "选择目标角色与范围", "发布通知", "跟踪阅读状态"]}
/>
);
return <NoticesModulePage />;
}

View File

@@ -1,5 +1,9 @@
import { redirect } from "next/navigation";
export default function AppIndexPage(): never {
redirect("/app/dashboard");
import { getCurrentAuthContext } from "@/modules/core/server/auth";
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"));
}

View File

@@ -1,12 +1,13 @@
import { redirect } from "next/navigation";
import { Badge } from "@/components/ui/badge";
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 { hasPermission } from "@/modules/core/server/permissions";
import { readData } from "@/modules/core/server/store";
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 { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
type AuditPageProps = {
searchParams?: Promise<SearchParams>;
@@ -19,15 +20,19 @@ export default async function AuditPage({ searchParams }: AuditPageProps): Promi
}
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 query = getSearchParam(params, "q");
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 filteredLogs = data.auditLogs.filter((log) =>
const filteredLogs = logs.filter((log) =>
[log.actorEmail ?? "系统", log.action, log.targetType, log.targetId ?? "", log.result, log.reason]
.join(" ")
.toLowerCase()
@@ -38,61 +43,60 @@ export default async function AuditPage({ searchParams }: AuditPageProps): Promi
return (
<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>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="p-0">
<TableToolbar
page={Math.min(page, pageCount)}
pageCount={pageCount}
pathname="/app/settings/audit"
pathname={pagePath}
query={query}
searchPlaceholder="搜索操作者、动作、对象、结果"
total={filteredLogs.length}
/>
<div className="overflow-x-auto">
<table className="w-full min-w-[900px] 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">
<Table className="w-full min-w-[900px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{visibleLogs.map((log) => (
<tr 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>
<td className="px-4 py-3">{log.actorEmail ?? "系统"}</td>
<td className="px-4 py-3">{log.action}</td>
<td className="px-4 py-3 text-muted-foreground">
<Table.Row className="table-row-enter hover:bg-secondary/45" key={log.id}>
<Table.Cell className="px-4 py-3 text-muted-foreground">{new Date(log.timestamp).toLocaleString("zh-CN")}</Table.Cell>
<Table.Cell className="px-4 py-3">{log.actorEmail ?? "系统"}</Table.Cell>
<Table.Cell className="px-4 py-3">{log.action}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">
{log.targetType}
{log.targetId ? ` / ${log.targetId.slice(0, 8)}` : ""}
</td>
<td className="px-4 py-3">{log.result}</td>
<td className="px-4 py-3 text-muted-foreground">{log.reason}</td>
</tr>
</Table.Cell>
<Table.Cell className="px-4 py-3">{log.result}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{log.reason}</Table.Cell>
</Table.Row>
))}
{visibleLogs.length === 0 ? (
<tr>
<td className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
</td>
</tr>
</Table.Cell>
</Table.Row>
) : null}
</tbody>
</table>
</Table.Body>
</Table>
</div>
<TablePagination
page={Math.min(page, pageCount)}
pageCount={pageCount}
pathname={pagePath}
query={query}
total={filteredLogs.length}
/>
</CardContent>
</Card>
</div>

View File

@@ -1,10 +1,10 @@
import { redirect } from "next/navigation";
import { Badge } from "@/components/ui/badge";
import { getCurrentAuthContext } from "@/modules/core/server/auth";
import { hasPermission } from "@/modules/core/server/permissions";
import { getSystemSettings } from "@/modules/core/server/system-settings";
import { GlobalSettingsClient } from "@/modules/settings/components/GlobalSettingsClient";
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
export default async function GlobalSettingsPage(): Promise<React.ReactElement> {
const context = await getCurrentAuthContext();
@@ -13,20 +13,15 @@ export default async function GlobalSettingsPage(): Promise<React.ReactElement>
}
if (!hasPermission(context.permissions, "platform:manage")) {
redirect("/app/dashboard");
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
}
const settings = await getSystemSettings();
const organizationSettingsHref = getWorkspaceHref(context.organization?.slug, "/settings/organizations");
return (
<div className="flex flex-col gap-5">
<section>
<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} />
<GlobalSettingsClient organizationSettingsHref={organizationSettingsHref} settings={settings} />
</div>
);
}

View File

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

View File

@@ -3,12 +3,5 @@ type SettingsLayoutProps = {
};
export default function SettingsLayout({ children }: SettingsLayoutProps): React.ReactElement {
return (
<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>
);
return <div className="mx-auto flex w-full max-w-7xl flex-col gap-5">{children}</div>;
}

View File

@@ -1,14 +1,21 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { ArrowLeft, Building2, Users } from "lucide-react";
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 { Table } from "@/components/ui/table";
import { getCurrentAuthContext, toPublicAccount } from "@/modules/core/server/auth";
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 { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
type OrganizationDetailPageProps = {
params: Promise<{
@@ -25,23 +32,28 @@ export default async function OrganizationDetailPage({
}
if (!hasPermission(context.permissions, "organization:read")) {
redirect("/app/dashboard");
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
}
const { id } = await params;
const data = await readData();
const organization = data.organizations.find((item) => item.id === id);
const organizations = await listSettingsOrganizations({ organizationId: id });
const organization = organizations[0];
if (!organization) {
notFound();
}
if (context.organization?.id && context.organization.id !== organization.id) {
redirect("/app/settings/organizations");
if (!context.account.platformRoleId && context.organization?.id && context.organization.id !== organization.id) {
redirect(getWorkspaceHref(context.organization?.slug, "/settings/organizations"));
}
const roles = await getRoleDefinitions(organization.id);
const organizationMemberships = data.memberships.filter((membership) => membership.organizationId === organization.id);
const accountById = new Map(data.accounts.map((account) => [account.id, account]));
const [roles, organizationMemberships, organizationAccounts, invitations, joinRequests] = await Promise.all([
getRoleDefinitions(organization.id),
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
.map((membership) => {
const account = accountById.get(membership.accountId);
@@ -55,21 +67,16 @@ export default async function OrganizationDetailPage({
};
})
.filter((item): item is NonNullable<typeof item> => item !== null);
const invitations = data.organizationInvitations.filter((invitation) => invitation.organizationId === organization.id);
const pendingRequests = data.joinRequests.filter(
(request) => request.organizationId === organization.id && request.status === "pending",
);
const pendingRequests = joinRequests.filter((request) => request.status === "pending");
return (
<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">
<div>
<Button asChild size="sm" variant="outline">
<Link href="/app/settings/organizations">
<ArrowLeft className="size-4" aria-hidden="true" />
</Link>
</Button>
<LinkButton href={getWorkspaceHref(context.organization?.slug, "/settings/organizations")} size="sm" variant="outline">
<ArrowLeft className="size-4" aria-hidden="true" />
</LinkButton>
<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">
<Building2 className="size-5" aria-hidden="true" />
@@ -131,40 +138,40 @@ export default async function OrganizationDetailPage({
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full min-w-[760px] 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 text-right font-medium"></th>
</tr>
</thead>
<tbody className="divide-y bg-card">
<Table className="w-full min-w-[760px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 text-right font-medium"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{members.map(({ account, membership }) => (
<tr className="hover:bg-secondary/45" key={membership.id}>
<td className="px-4 py-3">
<Table.Row className="hover:bg-secondary/45" key={membership.id}>
<Table.Cell className="px-4 py-3">
<p className="font-medium">{account.name}</p>
<p className="text-xs text-muted-foreground">{account.email}</p>
</td>
<td className="px-4 py-3">{membership.roleLabel}</td>
<td className="px-4 py-3">{membership.status}</td>
<td className="px-4 py-3">{account.status}</td>
<td className="px-4 py-3 text-right text-muted-foreground">
</Table.Cell>
<Table.Cell className="px-4 py-3">{membership.roleLabel}</Table.Cell>
<Table.Cell className="px-4 py-3">{membership.status}</Table.Cell>
<Table.Cell className="px-4 py-3">{account.status}</Table.Cell>
<Table.Cell className="px-4 py-3 text-right text-muted-foreground">
{new Date(membership.createdAt).toLocaleString("zh-CN")}
</td>
</tr>
</Table.Cell>
</Table.Row>
))}
{members.length === 0 ? (
<tr>
<td className="px-4 py-8 text-center text-muted-foreground" colSpan={5}>
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={5}>
</td>
</tr>
</Table.Cell>
</Table.Row>
) : null}
</tbody>
</table>
</Table.Body>
</Table>
</div>
</CardContent>
</Card>

View File

@@ -1,17 +1,18 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table } from "@/components/ui/table";
import { getCurrentAuthContext } from "@/modules/core/server/auth";
import { getRoleDefinitions, hasPermission } from "@/modules/core/server/permissions";
import { readData } from "@/modules/core/server/store";
import { listSettingsOrganizations } from "@/modules/core/server/settings";
import {
OrganizationManagementClient,
OrganizationRowActions,
} 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 { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
type OrganizationsPageProps = {
searchParams?: Promise<SearchParams>;
@@ -24,15 +25,17 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
}
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 query = getSearchParam(params, "q");
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 filteredOrganizations = data.organizations.filter((organization) =>
const filteredOrganizations = organizations.filter((organization) =>
[organization.name, organization.slug, organization.status].join(" ").toLowerCase().includes(normalizedQuery),
);
const pageCount = getPageCount(filteredOrganizations.length);
@@ -51,14 +54,11 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
return (
<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">
<div>
<Badge variant="success">Organizations</Badge>
<h2 className="mt-3 text-xl font-semibold"></h2>
<p className="mt-1 text-sm text-muted-foreground"></p>
{canManageOrganizations ? (
<div className="flex justify-end">
<OrganizationManagementClient />
</div>
{canManageOrganizations ? <OrganizationManagementClient /> : null}
</section>
) : null}
<Card>
<CardHeader>
@@ -66,59 +66,64 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
</CardHeader>
<CardContent className="p-0">
<TableToolbar
page={Math.min(page, pageCount)}
pageCount={pageCount}
pathname="/app/settings/organizations"
pathname={pagePath}
query={query}
searchPlaceholder="搜索机构名称、标识、状态"
total={filteredOrganizations.length}
/>
<div className="overflow-x-auto">
<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">Slug</th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
</tr>
</thead>
<tbody className="divide-y bg-card">
<Table className="w-full min-w-[820px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium">Slug</Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 text-right font-medium"></Table.Head>
<Table.Head className="px-4 py-3 text-right font-medium"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{visibleOrganizations.map((organization) => (
<tr className="table-row-enter hover:bg-secondary/45" key={organization.id}>
<td className="px-4 py-3">
<Table.Row className="table-row-enter hover:bg-secondary/45" key={organization.id}>
<Table.Cell className="px-4 py-3">
<Link
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}
</Link>
</td>
<td className="px-4 py-3 text-muted-foreground">{organization.slug}</td>
<td className="px-4 py-3">{organization.status === "active" ? "启用" : "停用"}</td>
<td className="px-4 py-3 text-right text-muted-foreground">
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{organization.slug}</Table.Cell>
<Table.Cell className="px-4 py-3">{organization.status === "active" ? "启用" : "停用"}</Table.Cell>
<Table.Cell className="px-4 py-3 text-right text-muted-foreground">
{new Date(organization.createdAt).toLocaleString("zh-CN")}
</td>
<td className="px-4 py-3">
</Table.Cell>
<Table.Cell className="px-4 py-3">
<OrganizationRowActions
canInvite={canInviteMembers}
organization={organization}
roles={rolesByOrganizationId.get(organization.id) ?? []}
/>
</td>
</tr>
</Table.Cell>
</Table.Row>
))}
{visibleOrganizations.length === 0 ? (
<tr>
<td className="px-4 py-8 text-center text-muted-foreground" colSpan={5}>
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={5}>
</td>
</tr>
</Table.Cell>
</Table.Row>
) : null}
</tbody>
</table>
</Table.Body>
</Table>
</div>
<TablePagination
page={Math.min(page, pageCount)}
pageCount={pageCount}
pathname={pagePath}
query={query}
total={filteredOrganizations.length}
/>
</CardContent>
</Card>
</div>

View File

@@ -1,5 +1,9 @@
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> {
redirect("/app/settings/users");
const context = await getCurrentAuthContext();
redirect(getWorkspaceHref(context?.organization?.slug, "/settings/users"));
}

View File

@@ -1,12 +1,13 @@
import { redirect } from "next/navigation";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table } from "@/components/ui/table";
import { getCurrentAuthContext } from "@/modules/core/server/auth";
import { getRoleDefinitions, hasPermission, PERMISSION_DEFINITIONS } from "@/modules/core/server/permissions";
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 { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
type RolesPageProps = {
searchParams?: Promise<SearchParams>;
@@ -19,9 +20,10 @@ export default async function RolesPage({ searchParams }: RolesPageProps): Promi
}
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 query = getSearchParam(params, "q");
const page = getPage(params);
@@ -36,14 +38,11 @@ export default async function RolesPage({ searchParams }: RolesPageProps): Promi
return (
<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">
<div>
<Badge variant="success">RBAC</Badge>
<h2 className="mt-3 text-xl font-semibold"></h2>
<p className="mt-1 text-sm text-muted-foreground"></p>
{canManageRoles ? (
<div className="flex justify-end">
<RoleManagementClient permissions={PERMISSION_DEFINITIONS} />
</div>
{canManageRoles ? <RoleManagementClient permissions={PERMISSION_DEFINITIONS} /> : null}
</section>
) : null}
<Card>
<CardHeader>
@@ -51,51 +50,56 @@ export default async function RolesPage({ searchParams }: RolesPageProps): Promi
</CardHeader>
<CardContent className="p-0">
<TableToolbar
page={Math.min(page, pageCount)}
pageCount={pageCount}
pathname="/app/settings/roles"
pathname={pagePath}
query={query}
searchPlaceholder="搜索角色、权限、说明"
total={filteredRoles.length}
/>
<div className="overflow-x-auto">
<table className="w-full min-w-[980px] 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 text-right font-medium"></th>
</tr>
</thead>
<tbody className="divide-y bg-card">
<Table className="w-full min-w-[980px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 text-right font-medium"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{visibleRoles.map((role) => (
<tr className="table-row-enter align-top hover:bg-secondary/45" key={role.id}>
<td className="px-4 py-3">
<Table.Row className="table-row-enter align-top hover:bg-secondary/45" key={role.id}>
<Table.Cell className="px-4 py-3">
<p className="font-medium">{role.label}</p>
<p className="text-xs text-muted-foreground">{role.description}</p>
</td>
<td className="px-4 py-3">{role.scope === "platform" ? "平台" : "机构"}</td>
<td className="px-4 py-3">{role.isSystem ? "内置" : "自定义"}</td>
<td className="px-4 py-3">{role.isEnabled ? "启用" : "停用"}</td>
<td className="px-4 py-3 text-muted-foreground">{role.permissions.join(", ") || "-"}</td>
<td className="px-4 py-3">
</Table.Cell>
<Table.Cell className="px-4 py-3">{role.scope === "platform" ? "平台" : "机构"}</Table.Cell>
<Table.Cell className="px-4 py-3">{role.isSystem ? "内置" : "自定义"}</Table.Cell>
<Table.Cell className="px-4 py-3">{role.isEnabled ? "启用" : "停用"}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{role.permissions.join(", ") || "-"}</Table.Cell>
<Table.Cell className="px-4 py-3">
<RoleRowActions permissions={PERMISSION_DEFINITIONS} role={role} />
</td>
</tr>
</Table.Cell>
</Table.Row>
))}
{visibleRoles.length === 0 ? (
<tr>
<td className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
</td>
</tr>
</Table.Cell>
</Table.Row>
) : null}
</tbody>
</table>
</Table.Body>
</Table>
</div>
<TablePagination
page={Math.min(page, pageCount)}
pageCount={pageCount}
pathname={pagePath}
query={query}
total={filteredRoles.length}
/>
</CardContent>
</Card>
</div>

View File

@@ -2,13 +2,20 @@ import { redirect } from "next/navigation";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table } from "@/components/ui/table";
import { checkDatabaseConnection } from "@/modules/core/server/db";
import { getCurrentAuthContext } from "@/modules/core/server/auth";
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 { 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 { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
type StatusPageProps = {
searchParams?: Promise<SearchParams>;
@@ -21,16 +28,25 @@ export default async function StatusPage({ searchParams }: StatusPageProps): Pro
}
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 query = getSearchParam(params, "q");
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 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]
.join(" ")
.toLowerCase()
@@ -41,34 +57,33 @@ export default async function StatusPage({ searchParams }: StatusPageProps): Pro
return (
<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>
<h2 className="mt-3 text-xl font-semibold"></h2>
<p className="mt-1 text-sm text-muted-foreground">{databaseHealth.reason}</p>
</section>
<section className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader>
<CardTitle className="text-3xl">{data.organizations.length}</CardTitle>
<CardTitle className="text-3xl">{organizations.length}</CardTitle>
</CardHeader>
<CardContent className="pt-0 text-sm text-muted-foreground"></CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-3xl">{data.accounts.length}</CardTitle>
<CardTitle className="text-3xl">{accounts.length}</CardTitle>
</CardHeader>
<CardContent className="pt-0 text-sm text-muted-foreground"></CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-3xl">{data.beds.length}</CardTitle>
<CardTitle className="text-3xl">{bedCount}</CardTitle>
</CardHeader>
<CardContent className="pt-0 text-sm text-muted-foreground"></CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-3xl">{data.incidents.length}</CardTitle>
<CardTitle className="text-3xl">{incidents.length}</CardTitle>
</CardHeader>
<CardContent className="pt-0 text-sm text-muted-foreground"></CardContent>
</Card>
@@ -80,57 +95,62 @@ export default async function StatusPage({ searchParams }: StatusPageProps): Pro
</CardHeader>
<CardContent className="p-0">
<TableToolbar
page={Math.min(page, pageCount)}
pageCount={pageCount}
pathname="/app/settings/status"
pathname={pagePath}
query={query}
searchPlaceholder="搜索故障标题、来源、状态"
total={filteredIncidents.length}
/>
<div className="overflow-x-auto">
<table className="w-full min-w-[960px] 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 text-right font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
</tr>
</thead>
<tbody className="divide-y bg-card">
<Table className="w-full min-w-[960px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 text-right font-medium"></Table.Head>
<Table.Head className="px-4 py-3 text-right font-medium"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{visibleIncidents.map((incident) => (
<tr className="table-row-enter align-top hover:bg-secondary/45" key={incident.id}>
<td className="px-4 py-3">
<Table.Row className="table-row-enter align-top hover:bg-secondary/45" key={incident.id}>
<Table.Cell className="px-4 py-3">
<p className="font-medium">{incident.title}</p>
<p className="text-xs text-muted-foreground">{incident.description}</p>
</td>
<td className="px-4 py-3">{incident.severity}</td>
<td className="px-4 py-3">{incident.status}</td>
<td className="px-4 py-3 text-muted-foreground">{incident.source}</td>
<td className="px-4 py-3 text-right text-muted-foreground">
</Table.Cell>
<Table.Cell className="px-4 py-3">{incident.severity}</Table.Cell>
<Table.Cell className="px-4 py-3">{incident.status}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{incident.source}</Table.Cell>
<Table.Cell className="px-4 py-3 text-right text-muted-foreground">
{new Date(incident.createdAt).toLocaleString("zh-CN")}
</td>
<td className="px-4 py-3">
</Table.Cell>
<Table.Cell className="px-4 py-3">
{canManageIncidents ? (
<IncidentStatusActions incident={incident} />
) : (
<span className="block text-right text-xs text-muted-foreground"></span>
)}
</td>
</tr>
</Table.Cell>
</Table.Row>
))}
{visibleIncidents.length === 0 ? (
<tr>
<td className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
</td>
</tr>
</Table.Cell>
</Table.Row>
) : null}
</tbody>
</table>
</Table.Body>
</Table>
</div>
<TablePagination
page={Math.min(page, pageCount)}
pageCount={pageCount}
pathname={pagePath}
query={query}
total={filteredIncidents.length}
/>
</CardContent>
</Card>
</div>

View File

@@ -2,15 +2,22 @@ import { redirect } from "next/navigation";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table } from "@/components/ui/table";
import { getCurrentAuthContext, toPublicAccount } from "@/modules/core/server/auth";
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 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 { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination";
import { UserAvatar } from "@/modules/shared/components/UserAvatar";
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
type UsersPageProps = {
searchParams?: Promise<SearchParams>;
@@ -31,22 +38,30 @@ export default async function UsersPage({ searchParams }: UsersPageProps): Promi
}
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 query = getSearchParam(params, "q");
const page = getPage(params);
const data = await readData();
const roles = await getRoleDefinitions(context.organization?.id);
const canReadAllAccounts = Boolean(context.account.platformRoleId);
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 organizationById = new Map(data.organizations.map((organization) => [organization.id, organization]));
const accounts = data.accounts.map((account) => {
const organizationById = new Map(organizations.map((organization) => [organization.id, organization]));
const accounts = accountsData.map((account) => {
const membership = context.organization?.id
? data.memberships.find(
? memberships.find(
(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 platformRole = account.platformRoleId ? roleById.get(account.platformRoleId) : undefined;
@@ -64,17 +79,11 @@ export default async function UsersPage({ searchParams }: UsersPageProps): Promi
return (
<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
canManageAccounts={hasPermission(context.permissions, "account:manage")}
organizations={data.organizations}
organizations={organizations}
roles={roles}
joinRequests={data.joinRequests}
joinRequests={joinRequests}
/>
<Card>
@@ -83,29 +92,27 @@ export default async function UsersPage({ searchParams }: UsersPageProps): Promi
</CardHeader>
<CardContent className="p-0">
<TableToolbar
page={Math.min(page, pageCount)}
pageCount={pageCount}
pathname="/app/settings/users"
pathname={pagePath}
query={query}
searchPlaceholder="搜索姓名、邮箱、角色、状态"
total={filteredAccounts.length}
/>
<div className="overflow-x-auto">
<table className="w-full min-w-[920px] 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 text-right font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
</tr>
</thead>
<tbody className="divide-y bg-card">
<Table className="w-full min-w-[920px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 text-right font-medium"></Table.Head>
<Table.Head className="px-4 py-3 text-right font-medium"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{visibleAccounts.map((account) => (
<tr className="table-row-enter hover:bg-secondary/45" key={account.id}>
<td className="px-4 py-3">
<Table.Row className="table-row-enter hover:bg-secondary/45" key={account.id}>
<Table.Cell className="px-4 py-3">
<div className="flex items-center gap-3">
<UserAvatar avatarUrl={account.avatarUrl} name={account.name} size="sm" />
<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>
</div>
</div>
</td>
<td className="px-4 py-3">{formatRoleLabel(account.role)}</td>
<td className="px-4 py-3 text-muted-foreground">{account.organization ?? "-"}</td>
<td className="px-4 py-3">
</Table.Cell>
<Table.Cell className="px-4 py-3">{formatRoleLabel(account.role)}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{account.organization ?? "-"}</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={account.status === "active" ? "success" : account.status === "pending" ? "warning" : "danger"}>
{account.status === "active" ? "启用" : account.status === "pending" ? "待审批" : "停用"}
</Badge>
</td>
<td className="px-4 py-3 text-right text-muted-foreground">
</Table.Cell>
<Table.Cell className="px-4 py-3 text-right text-muted-foreground">
{new Date(account.createdAt).toLocaleString("zh-CN")}
</td>
<td className="px-4 py-3">
</Table.Cell>
<Table.Cell className="px-4 py-3">
<UserAccountActions
account={account}
currentAccountId={context.account.id}
organizations={data.organizations}
organizations={organizations}
roles={roles}
/>
</td>
</tr>
</Table.Cell>
</Table.Row>
))}
{visibleAccounts.length === 0 ? (
<tr>
<td className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
</td>
</tr>
</Table.Cell>
</Table.Row>
) : null}
</tbody>
</table>
</Table.Body>
</Table>
</div>
<TablePagination
page={Math.min(page, pageCount)}
pageCount={pageCount}
pathname={pagePath}
query={query}
total={filteredAccounts.length}
/>
</CardContent>
</Card>
</div>

View 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>;
}

View 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