feat: add care execution workspace
This commit is contained in:
@@ -270,3 +270,70 @@ await database
|
|||||||
.set({ status, updatedAt: new Date() })
|
.set({ status, updatedAt: new Date() })
|
||||||
.where(and(eq(healthAnomalyReviews.id, id), eq(healthAnomalyReviews.organizationId, organizationId)));
|
.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)));
|
||||||
|
```
|
||||||
|
|||||||
104
.trellis/tasks/07-02-care-execution-workspace/design.md
Normal file
104
.trellis/tasks/07-02-care-execution-workspace/design.md
Normal 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.
|
||||||
61
.trellis/tasks/07-02-care-execution-workspace/implement.md
Normal file
61
.trellis/tasks/07-02-care-execution-workspace/implement.md
Normal 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.
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"name": "care-execution-workspace",
|
"name": "care-execution-workspace",
|
||||||
"title": "护理服务执行工作台",
|
"title": "护理服务执行工作台",
|
||||||
"description": "",
|
"description": "",
|
||||||
"status": "planning",
|
"status": "in_progress",
|
||||||
"dev_type": null,
|
"dev_type": null,
|
||||||
"scope": null,
|
"scope": null,
|
||||||
"package": null,
|
"package": null,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import CarePage from "../../care/page";
|
import CarePage from "../../care/page";
|
||||||
|
|
||||||
export default function ScopedCarePage(): React.ReactElement {
|
export default async function ScopedCarePage(): Promise<React.ReactElement> {
|
||||||
return CarePage();
|
return CarePage();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
import { ClipboardCheck } from "lucide-react";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { ModulePage } from "@/modules/shared/components/ModulePage";
|
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||||
|
import { hasPermission } from "@/modules/core/server/permissions";
|
||||||
|
import { listCareExecutionData } from "@/modules/care/server/operations";
|
||||||
|
import { CareWorkspaceClient } from "@/modules/care/components/CareWorkspaceClient";
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
export default function CarePage(): React.ReactElement {
|
export default async function CarePage(): Promise<React.ReactElement> {
|
||||||
return (
|
const context = await getCurrentAuthContext();
|
||||||
<ModulePage
|
if (!context) {
|
||||||
title="护理服务"
|
redirect("/login");
|
||||||
eyebrow="护理计划、任务与巡检"
|
}
|
||||||
description="待接入护理等级、护理计划、任务派发、执行记录和逾期状态数据源。"
|
|
||||||
icon={ClipboardCheck}
|
if (!hasPermission(context.permissions, "care:read")) {
|
||||||
phase="待接入"
|
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||||
/>
|
}
|
||||||
);
|
|
||||||
|
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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
234
app/api/care/care-routes.test.ts
Normal file
234
app/api/care/care-routes.test.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||||
|
import { requirePermission } from "@/modules/core/server/auth";
|
||||||
|
import type { AuthContext } from "@/modules/core/types";
|
||||||
|
import type { CareExecutionData, CareTask } from "@/modules/care/types";
|
||||||
|
import { listCareExecutionData, updateCareTaskStatus } from "@/modules/care/server/operations";
|
||||||
|
|
||||||
|
vi.mock("@/modules/core/server/auth", () => ({
|
||||||
|
requirePermission: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/modules/core/server/audit", () => ({
|
||||||
|
recordAuditLog: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/modules/care/server/operations", () => ({
|
||||||
|
isCareMutationFailure: vi.fn((value: unknown) => typeof value === "object" && value !== null && "success" in value && value.success === false),
|
||||||
|
listCareExecutionData: vi.fn(),
|
||||||
|
updateCareTaskStatus: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const account: AuthContext["account"] = {
|
||||||
|
id: "account-1",
|
||||||
|
name: "Admin",
|
||||||
|
email: "admin@example.com",
|
||||||
|
avatarUrl: "",
|
||||||
|
role: "org_admin",
|
||||||
|
status: "active",
|
||||||
|
createdAt: "2026-07-02T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-07-02T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
function createAuthContext(overrides: Partial<AuthContext> = {}): AuthContext {
|
||||||
|
return {
|
||||||
|
account,
|
||||||
|
organization: {
|
||||||
|
id: "org-1",
|
||||||
|
name: "Demo Org",
|
||||||
|
slug: "demo-org",
|
||||||
|
status: "active",
|
||||||
|
registrationEnabled: true,
|
||||||
|
oidcEnabled: false,
|
||||||
|
oidcIssuerUrl: "",
|
||||||
|
oidcClientId: "",
|
||||||
|
oidcHasClientSecret: false,
|
||||||
|
oidcScopes: "openid profile email",
|
||||||
|
oidcRedirectUri: "",
|
||||||
|
oidcAvatarClaim: "picture",
|
||||||
|
oidcAutoProvision: false,
|
||||||
|
createdAt: "2026-07-02T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-07-02T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
organizations: [],
|
||||||
|
permissions: ["care:read", "care:manage"],
|
||||||
|
session: {
|
||||||
|
id: "session-1",
|
||||||
|
accountId: "account-1",
|
||||||
|
activeOrganizationId: "org-1",
|
||||||
|
createdAt: "2026-07-02T00:00:00.000Z",
|
||||||
|
expiresAt: "2026-07-09T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const careTask: CareTask = {
|
||||||
|
id: "task-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
elderId: "elder-1",
|
||||||
|
elderName: "王桂兰",
|
||||||
|
title: "晨间协助洗漱",
|
||||||
|
careType: "daily_care",
|
||||||
|
priority: "normal",
|
||||||
|
status: "pending",
|
||||||
|
scheduledAt: "2026-07-02T08:00:00.000Z",
|
||||||
|
assigneeLabel: "一号护理组",
|
||||||
|
executionNotes: "",
|
||||||
|
completedAt: undefined,
|
||||||
|
completedByAccountId: undefined,
|
||||||
|
completedByName: undefined,
|
||||||
|
createdAt: "2026-07-02T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-07-02T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const careExecutionData: CareExecutionData = {
|
||||||
|
metrics: {
|
||||||
|
pending: 1,
|
||||||
|
inProgress: 1,
|
||||||
|
completedToday: 1,
|
||||||
|
highPriority: 1,
|
||||||
|
},
|
||||||
|
tasks: [careTask],
|
||||||
|
};
|
||||||
|
|
||||||
|
function allowAuth(): void {
|
||||||
|
vi.mocked(requirePermission).mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
context: createAuthContext(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonRequest(body: Record<string, unknown>): Request {
|
||||||
|
return new Request("http://localhost/api/care/tasks", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson(response: Response): Promise<Record<string, unknown>> {
|
||||||
|
return (await response.json()) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
allowAuth();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("care execution API routes", () => {
|
||||||
|
it("loads care execution data for the active organization", async () => {
|
||||||
|
vi.mocked(listCareExecutionData).mockResolvedValue(careExecutionData);
|
||||||
|
|
||||||
|
const { GET } = await import("./tasks/route");
|
||||||
|
const response = await GET();
|
||||||
|
const body = await readJson(response);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.data).toEqual(careExecutionData);
|
||||||
|
expect(listCareExecutionData).toHaveBeenCalledWith("org-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns permission failures before loading care tasks", async () => {
|
||||||
|
const denied = Response.json({ success: false, reason: "权限不足" }, { status: 403 });
|
||||||
|
vi.mocked(requirePermission).mockResolvedValue({ success: false, response: denied });
|
||||||
|
|
||||||
|
const { GET } = await import("./tasks/route");
|
||||||
|
const response = await GET();
|
||||||
|
const body = await readJson(response);
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(body.reason).toBe("权限不足");
|
||||||
|
expect(listCareExecutionData).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires an active organization before loading care tasks", async () => {
|
||||||
|
vi.mocked(requirePermission).mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
context: createAuthContext({ organization: undefined }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { GET } = await import("./tasks/route");
|
||||||
|
const response = await GET();
|
||||||
|
const body = await readJson(response);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(body.reason).toBe("请选择机构后查看护理任务");
|
||||||
|
expect(listCareExecutionData).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a care task as in progress", async () => {
|
||||||
|
vi.mocked(updateCareTaskStatus).mockResolvedValue({
|
||||||
|
...careTask,
|
||||||
|
status: "in_progress",
|
||||||
|
updatedAt: "2026-07-02T08:10:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { PATCH } = await import("./tasks/[id]/route");
|
||||||
|
const response = await PATCH(jsonRequest({ status: "in_progress" }), {
|
||||||
|
params: Promise.resolve({ id: "task-1" }),
|
||||||
|
});
|
||||||
|
const body = await readJson(response);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(updateCareTaskStatus).toHaveBeenCalledWith(expect.objectContaining({ accountId: "account-1", id: "task-1", organizationId: "org-1" }));
|
||||||
|
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "care.task.update" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes a care task with notes", async () => {
|
||||||
|
vi.mocked(updateCareTaskStatus).mockResolvedValue({
|
||||||
|
...careTask,
|
||||||
|
status: "completed",
|
||||||
|
executionNotes: "已完成并观察无异常",
|
||||||
|
completedAt: "2026-07-02T08:30:00.000Z",
|
||||||
|
completedByAccountId: "account-1",
|
||||||
|
completedByName: "Admin",
|
||||||
|
updatedAt: "2026-07-02T08:30:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { PATCH } = await import("./tasks/[id]/route");
|
||||||
|
const response = await PATCH(jsonRequest({ status: "completed", executionNotes: "已完成并观察无异常" }), {
|
||||||
|
params: Promise.resolve({ id: "task-1" }),
|
||||||
|
});
|
||||||
|
const body = await readJson(response);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(updateCareTaskStatus).toHaveBeenCalledWith(expect.objectContaining({ executionNotes: "已完成并观察无异常", status: "completed" }));
|
||||||
|
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "care.task.update" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid care task status", async () => {
|
||||||
|
const { PATCH } = await import("./tasks/[id]/route");
|
||||||
|
const response = await PATCH(jsonRequest({ status: "done" }), {
|
||||||
|
params: Promise.resolve({ id: "task-1" }),
|
||||||
|
});
|
||||||
|
const body = await readJson(response);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(body.reason).toBe("护理任务状态无效");
|
||||||
|
expect(updateCareTaskStatus).not.toHaveBeenCalled();
|
||||||
|
expect(recordAuditLog).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns structured failures for missing or cross-organization care tasks", async () => {
|
||||||
|
vi.mocked(updateCareTaskStatus).mockResolvedValue({
|
||||||
|
success: false,
|
||||||
|
reason: "护理任务不存在",
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { PATCH } = await import("./tasks/[id]/route");
|
||||||
|
const response = await PATCH(jsonRequest({ status: "in_progress" }), {
|
||||||
|
params: Promise.resolve({ id: "task-from-other-org" }),
|
||||||
|
});
|
||||||
|
const body = await readJson(response);
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(body.reason).toBe("护理任务不存在");
|
||||||
|
expect(recordAuditLog).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
56
app/api/care/tasks/[id]/route.ts
Normal file
56
app/api/care/tasks/[id]/route.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
|
||||||
|
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||||
|
import { requirePermission } from "@/modules/core/server/auth";
|
||||||
|
import { isCareMutationFailure, updateCareTaskStatus } from "@/modules/care/server/operations";
|
||||||
|
import { validateCareTaskStatusUpdateInput } from "@/modules/care/types";
|
||||||
|
|
||||||
|
type RouteContext = {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function PATCH(request: Request, context: RouteContext): Promise<Response> {
|
||||||
|
const { id } = await context.params;
|
||||||
|
const auth = await requirePermission("care:manage", {
|
||||||
|
action: "care.task.update",
|
||||||
|
targetType: "care_task",
|
||||||
|
targetId: id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!auth.success) {
|
||||||
|
return auth.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizationId = auth.context.organization?.id;
|
||||||
|
if (!organizationId) {
|
||||||
|
return jsonFailure("请选择机构后处理护理任务", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = validateCareTaskStatusUpdateInput(await readJsonBody(request));
|
||||||
|
if (!input.success) {
|
||||||
|
return jsonFailure(input.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await updateCareTaskStatus({
|
||||||
|
...input.data,
|
||||||
|
id,
|
||||||
|
organizationId,
|
||||||
|
accountId: auth.context.account.id,
|
||||||
|
});
|
||||||
|
if (isCareMutationFailure(task)) {
|
||||||
|
return jsonFailure(task.reason, task.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: auth.context.account,
|
||||||
|
organizationId,
|
||||||
|
action: "care.task.update",
|
||||||
|
targetType: "care_task",
|
||||||
|
targetId: task.id,
|
||||||
|
result: "success",
|
||||||
|
reason: `处理护理任务:${task.title}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonSuccess("护理任务已更新", { task });
|
||||||
|
}
|
||||||
22
app/api/care/tasks/route.ts
Normal file
22
app/api/care/tasks/route.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { jsonFailure, jsonSuccess } from "@/modules/core/server/api";
|
||||||
|
import { requirePermission } from "@/modules/core/server/auth";
|
||||||
|
import { listCareExecutionData } from "@/modules/care/server/operations";
|
||||||
|
|
||||||
|
export async function GET(): Promise<Response> {
|
||||||
|
const auth = await requirePermission("care:read", {
|
||||||
|
action: "care.task.list",
|
||||||
|
targetType: "care_task",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!auth.success) {
|
||||||
|
return auth.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizationId = auth.context.organization?.id;
|
||||||
|
if (!organizationId) {
|
||||||
|
return jsonFailure("请选择机构后查看护理任务", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await listCareExecutionData(organizationId);
|
||||||
|
return jsonSuccess("护理任务已加载", { data });
|
||||||
|
}
|
||||||
27
drizzle/0004_silly_carnage.sql
Normal file
27
drizzle/0004_silly_carnage.sql
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
CREATE TYPE "public"."care_task_priority" AS ENUM('low', 'normal', 'high', 'urgent');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."care_task_status" AS ENUM('pending', 'in_progress', 'completed', 'cancelled');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."care_task_type" AS ENUM('daily_care', 'meal', 'medication', 'rehab', 'inspection', 'cleaning', 'other');--> statement-breakpoint
|
||||||
|
CREATE TABLE "care_tasks" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"organization_id" uuid NOT NULL,
|
||||||
|
"elder_id" uuid,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"care_type" "care_task_type" NOT NULL,
|
||||||
|
"priority" "care_task_priority" DEFAULT 'normal' NOT NULL,
|
||||||
|
"status" "care_task_status" DEFAULT 'pending' NOT NULL,
|
||||||
|
"scheduled_at" timestamp with time zone NOT NULL,
|
||||||
|
"assignee_label" text DEFAULT '' NOT NULL,
|
||||||
|
"execution_notes" text DEFAULT '' NOT NULL,
|
||||||
|
"completed_at" timestamp with time zone,
|
||||||
|
"created_by_account_id" uuid,
|
||||||
|
"completed_by_account_id" uuid,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "care_tasks" ADD CONSTRAINT "care_tasks_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "care_tasks" ADD CONSTRAINT "care_tasks_elder_id_elders_id_fk" FOREIGN KEY ("elder_id") REFERENCES "public"."elders"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "care_tasks" ADD CONSTRAINT "care_tasks_created_by_account_id_accounts_id_fk" FOREIGN KEY ("created_by_account_id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "care_tasks" ADD CONSTRAINT "care_tasks_completed_by_account_id_accounts_id_fk" FOREIGN KEY ("completed_by_account_id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "care_tasks_queue_lookup" ON "care_tasks" USING btree ("organization_id","status","priority");--> statement-breakpoint
|
||||||
|
CREATE INDEX "care_tasks_schedule_lookup" ON "care_tasks" USING btree ("organization_id","scheduled_at");
|
||||||
3000
drizzle/meta/0004_snapshot.json
Normal file
3000
drizzle/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,13 @@
|
|||||||
"when": 1783063091807,
|
"when": 1783063091807,
|
||||||
"tag": "0003_gray_dust",
|
"tag": "0003_gray_dust",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783064001398,
|
||||||
|
"tag": "0004_silly_carnage",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
287
modules/care/components/CareWorkspaceClient.tsx
Normal file
287
modules/care/components/CareWorkspaceClient.tsx
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FormEvent, useMemo, useState } from "react";
|
||||||
|
import { CheckCircle2, Clock3, Play, Search, Siren } from "lucide-react";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Dialog } from "@/components/ui/dialog";
|
||||||
|
import { Input, Textarea } from "@/components/ui/input";
|
||||||
|
import { Select } from "@/components/ui/select";
|
||||||
|
import { Table } from "@/components/ui/table";
|
||||||
|
import type { ApiResult } from "@/modules/core/server/api";
|
||||||
|
import type { CareExecutionData, CareTask, CareTaskPriority, CareTaskStatus, CareTaskType } from "@/modules/care/types";
|
||||||
|
import { CARE_TASK_PRIORITY_LABELS, CARE_TASK_STATUS_LABELS, CARE_TASK_TYPE_LABELS } from "@/modules/care/types";
|
||||||
|
|
||||||
|
type CareWorkspaceClientProps = {
|
||||||
|
canManage: boolean;
|
||||||
|
initialData: CareExecutionData;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDateTime(value: string | undefined): string {
|
||||||
|
return value ? new Date(value).toLocaleString("zh-CN") : "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusVariant(status: CareTaskStatus): "success" | "secondary" | "warning" {
|
||||||
|
if (status === "completed") {
|
||||||
|
return "success";
|
||||||
|
}
|
||||||
|
|
||||||
|
return status === "pending" ? "warning" : "secondary";
|
||||||
|
}
|
||||||
|
|
||||||
|
function priorityVariant(priority: CareTaskPriority): "danger" | "secondary" | "warning" {
|
||||||
|
if (priority === "urgent") {
|
||||||
|
return "danger";
|
||||||
|
}
|
||||||
|
|
||||||
|
return priority === "high" ? "warning" : "secondary";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CareWorkspaceClient({ canManage, initialData }: CareWorkspaceClientProps): React.ReactElement {
|
||||||
|
const [data, setData] = useState(initialData);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<"all" | CareTaskStatus>("all");
|
||||||
|
const [priorityFilter, setPriorityFilter] = useState<"all" | CareTaskPriority>("all");
|
||||||
|
const [typeFilter, setTypeFilter] = useState<"all" | CareTaskType>("all");
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [isPending, setIsPending] = useState(false);
|
||||||
|
const [completeTarget, setCompleteTarget] = useState<CareTask | undefined>();
|
||||||
|
const [executionNotes, setExecutionNotes] = useState("");
|
||||||
|
|
||||||
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
const filteredTasks = useMemo(
|
||||||
|
() =>
|
||||||
|
data.tasks.filter((task) => {
|
||||||
|
const matchesQuery =
|
||||||
|
!normalizedQuery ||
|
||||||
|
[task.title, task.elderName, task.assigneeLabel, task.executionNotes].join(" ").toLowerCase().includes(normalizedQuery);
|
||||||
|
const matchesStatus = statusFilter === "all" || task.status === statusFilter;
|
||||||
|
const matchesPriority = priorityFilter === "all" || task.priority === priorityFilter;
|
||||||
|
const matchesType = typeFilter === "all" || task.careType === typeFilter;
|
||||||
|
return matchesQuery && matchesStatus && matchesPriority && matchesType;
|
||||||
|
}),
|
||||||
|
[data.tasks, normalizedQuery, priorityFilter, statusFilter, typeFilter],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function refreshData(): Promise<void> {
|
||||||
|
const response = await fetch("/api/care/tasks");
|
||||||
|
const result = (await response.json()) as ApiResult<{ data: CareExecutionData }>;
|
||||||
|
if (result.success) {
|
||||||
|
setData(result.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateTask(task: CareTask, status: CareTaskStatus, notes = task.executionNotes): Promise<void> {
|
||||||
|
if (!canManage) {
|
||||||
|
setMessage("当前角色无权处理护理任务");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPending(true);
|
||||||
|
setMessage("");
|
||||||
|
const response = await fetch(`/api/care/tasks/${task.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ status, executionNotes: notes }),
|
||||||
|
});
|
||||||
|
const result = (await response.json()) as ApiResult<{ task: CareTask }>;
|
||||||
|
setIsPending(false);
|
||||||
|
setMessage(result.reason);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setCompleteTarget(undefined);
|
||||||
|
setExecutionNotes("");
|
||||||
|
await refreshData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCompleteDialog(task: CareTask): void {
|
||||||
|
setCompleteTarget(task);
|
||||||
|
setExecutionNotes(task.executionNotes);
|
||||||
|
setMessage("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCompleteDialog(): void {
|
||||||
|
if (isPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCompleteTarget(undefined);
|
||||||
|
setExecutionNotes("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitComplete(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!completeTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateTask(completeTarget, "completed", executionNotes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
|
||||||
|
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<MetricCard icon={Clock3} label="待处理" value={data.metrics.pending} />
|
||||||
|
<MetricCard icon={Play} label="进行中" value={data.metrics.inProgress} />
|
||||||
|
<MetricCard icon={CheckCircle2} label="今日完成" value={data.metrics.completedToday} />
|
||||||
|
<MetricCard icon={Siren} label="高优先级" value={data.metrics.highPriority} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-3 border-b pb-4 xl:flex-row xl:items-center xl:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-semibold tracking-normal">护理服务</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">按老人、任务类型、优先级和执行状态推进每日护理服务。</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2 xl:flex xl:items-center">
|
||||||
|
<label className="relative block sm:col-span-2 xl:col-span-1">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
className="w-full pl-9 xl:w-72"
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder="搜索老人、任务或执行人"
|
||||||
|
value={query}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
aria-label="任务状态"
|
||||||
|
onValueChange={(value) => setStatusFilter(value as "all" | CareTaskStatus)}
|
||||||
|
options={[
|
||||||
|
{ value: "all", label: "全部状态" },
|
||||||
|
...Object.entries(CARE_TASK_STATUS_LABELS).map(([value, label]) => ({ value, label })),
|
||||||
|
]}
|
||||||
|
value={statusFilter}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
aria-label="优先级"
|
||||||
|
onValueChange={(value) => setPriorityFilter(value as "all" | CareTaskPriority)}
|
||||||
|
options={[
|
||||||
|
{ value: "all", label: "全部优先级" },
|
||||||
|
...Object.entries(CARE_TASK_PRIORITY_LABELS).map(([value, label]) => ({ value, label })),
|
||||||
|
]}
|
||||||
|
value={priorityFilter}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
aria-label="护理类型"
|
||||||
|
onValueChange={(value) => setTypeFilter(value as "all" | CareTaskType)}
|
||||||
|
options={[
|
||||||
|
{ value: "all", label: "全部类型" },
|
||||||
|
...Object.entries(CARE_TASK_TYPE_LABELS).map(([value, label]) => ({ value, label })),
|
||||||
|
]}
|
||||||
|
value={typeFilter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{message ? (
|
||||||
|
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>护理任务</CardTitle>
|
||||||
|
<CardDescription>任务状态更新会写入审计日志,完成时保存执行备注和完成时间。</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="overflow-x-auto">
|
||||||
|
<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 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">
|
||||||
|
{filteredTasks.map((task) => (
|
||||||
|
<Table.Row key={task.id}>
|
||||||
|
<Table.Cell className="px-4 py-3">
|
||||||
|
<p className="font-medium">{task.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{task.executionNotes || "-"}</p>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell className="px-4 py-3 text-muted-foreground">{task.elderName || "公共区域"}</Table.Cell>
|
||||||
|
<Table.Cell className="px-4 py-3 text-muted-foreground">{CARE_TASK_TYPE_LABELS[task.careType]}</Table.Cell>
|
||||||
|
<Table.Cell className="px-4 py-3">
|
||||||
|
<Badge variant={priorityVariant(task.priority)}>{CARE_TASK_PRIORITY_LABELS[task.priority]}</Badge>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell className="px-4 py-3">
|
||||||
|
<Badge variant={statusVariant(task.status)}>{CARE_TASK_STATUS_LABELS[task.status]}</Badge>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDateTime(task.scheduledAt)}</Table.Cell>
|
||||||
|
<Table.Cell className="px-4 py-3 text-muted-foreground">{task.assigneeLabel || "-"}</Table.Cell>
|
||||||
|
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDateTime(task.completedAt)}</Table.Cell>
|
||||||
|
<Table.Cell className="px-4 py-3">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{task.status === "pending" ? (
|
||||||
|
<Button disabled={!canManage || isPending} onClick={() => updateTask(task, "in_progress")} size="sm" type="button" variant="outline">
|
||||||
|
开始
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{task.status === "pending" || task.status === "in_progress" ? (
|
||||||
|
<Button disabled={!canManage || isPending} onClick={() => openCompleteDialog(task)} size="sm" type="button">
|
||||||
|
完成
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
))}
|
||||||
|
{filteredTasks.length === 0 ? (
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={9}>
|
||||||
|
暂无护理任务
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
) : null}
|
||||||
|
</Table.Body>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog className="max-w-lg" description={completeTarget?.title} onClose={closeCompleteDialog} open={completeTarget !== undefined} title="完成护理任务">
|
||||||
|
<form className="grid gap-3" onSubmit={submitComplete}>
|
||||||
|
<Textarea label="执行备注" onChange={(event) => setExecutionNotes(event.target.value)} value={executionNotes} />
|
||||||
|
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<Button disabled={isPending} onClick={closeCompleteDialog} type="button" variant="outline">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button disabled={isPending} type="submit">
|
||||||
|
保存完成
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCard({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<{ "aria-hidden"?: boolean; className?: string }>;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between p-4">
|
||||||
|
<div>
|
||||||
|
<CardDescription>{label}</CardDescription>
|
||||||
|
<CardTitle className="mt-2 text-3xl">{value}</CardTitle>
|
||||||
|
</div>
|
||||||
|
<Icon className="size-5 text-primary" aria-hidden={true} />
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
modules/care/server/operations.ts
Normal file
141
modules/care/server/operations.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||||
|
|
||||||
|
import { getDatabase } from "@/modules/core/server/db";
|
||||||
|
import { accounts, careTasks, elders } from "@/modules/core/server/schema";
|
||||||
|
import type { CareExecutionData, CareTask, CareTaskStatusUpdateInput } from "@/modules/care/types";
|
||||||
|
|
||||||
|
export type CareMutationFailure = {
|
||||||
|
success: false;
|
||||||
|
reason: string;
|
||||||
|
status: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AccountNameRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ElderNameRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isCareMutationFailure(value: unknown): value is CareMutationFailure {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
"success" in value &&
|
||||||
|
(value as { success?: unknown }).success === false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function iso(value: Date): string {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalIso(value: Date | null): string | undefined {
|
||||||
|
return value ? iso(value) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNameMap(rows: AccountNameRow[] | ElderNameRow[]): Map<string, string> {
|
||||||
|
return new Map(rows.map((row) => [row.id, row.name]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCareTask(
|
||||||
|
row: typeof careTasks.$inferSelect,
|
||||||
|
elderNameById: Map<string, string>,
|
||||||
|
accountNameById: Map<string, string>,
|
||||||
|
): CareTask {
|
||||||
|
const elderId = row.elderId ?? undefined;
|
||||||
|
const completedByAccountId = row.completedByAccountId ?? undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
organizationId: row.organizationId,
|
||||||
|
elderId,
|
||||||
|
elderName: elderId ? elderNameById.get(elderId) ?? "" : "",
|
||||||
|
title: row.title,
|
||||||
|
careType: row.careType,
|
||||||
|
priority: row.priority,
|
||||||
|
status: row.status,
|
||||||
|
scheduledAt: iso(row.scheduledAt),
|
||||||
|
assigneeLabel: row.assigneeLabel,
|
||||||
|
executionNotes: row.executionNotes,
|
||||||
|
completedAt: optionalIso(row.completedAt),
|
||||||
|
completedByAccountId,
|
||||||
|
completedByName: completedByAccountId ? accountNameById.get(completedByAccountId) : undefined,
|
||||||
|
createdAt: iso(row.createdAt),
|
||||||
|
updatedAt: iso(row.updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCareExecutionData(organizationId: string): Promise<CareExecutionData> {
|
||||||
|
const database = getDatabase();
|
||||||
|
const todayStart = new Date();
|
||||||
|
todayStart.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const taskRows = await database
|
||||||
|
.select()
|
||||||
|
.from(careTasks)
|
||||||
|
.where(eq(careTasks.organizationId, organizationId))
|
||||||
|
.orderBy(desc(careTasks.scheduledAt));
|
||||||
|
|
||||||
|
const elderIds = Array.from(new Set(taskRows.map((task) => task.elderId).filter((elderId): elderId is string => typeof elderId === "string")));
|
||||||
|
const accountIds = Array.from(
|
||||||
|
new Set(taskRows.map((task) => task.completedByAccountId).filter((accountId): accountId is string => typeof accountId === "string")),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [elderRows, accountRows] = await Promise.all([
|
||||||
|
elderIds.length > 0
|
||||||
|
? database.select({ id: elders.id, name: elders.name }).from(elders).where(and(eq(elders.organizationId, organizationId), inArray(elders.id, elderIds)))
|
||||||
|
: [],
|
||||||
|
accountIds.length > 0 ? database.select({ id: accounts.id, name: accounts.name }).from(accounts).where(inArray(accounts.id, accountIds)) : [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const tasks = taskRows.map((task) => toCareTask(task, buildNameMap(elderRows), buildNameMap(accountRows)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
metrics: {
|
||||||
|
pending: taskRows.filter((task) => task.status === "pending").length,
|
||||||
|
inProgress: taskRows.filter((task) => task.status === "in_progress").length,
|
||||||
|
completedToday: taskRows.filter((task) => task.status === "completed" && task.completedAt !== null && task.completedAt >= todayStart).length,
|
||||||
|
highPriority: taskRows.filter((task) => task.priority === "high" || task.priority === "urgent").length,
|
||||||
|
},
|
||||||
|
tasks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCareTaskStatus(
|
||||||
|
input: CareTaskStatusUpdateInput & { accountId: string; id: string; organizationId: string },
|
||||||
|
): Promise<CareTask | CareMutationFailure> {
|
||||||
|
const database = getDatabase();
|
||||||
|
const completedAt = input.status === "completed" ? new Date() : null;
|
||||||
|
const completedByAccountId = input.status === "completed" ? input.accountId : null;
|
||||||
|
|
||||||
|
const rows = await database
|
||||||
|
.update(careTasks)
|
||||||
|
.set({
|
||||||
|
status: input.status,
|
||||||
|
executionNotes: input.executionNotes,
|
||||||
|
completedAt,
|
||||||
|
completedByAccountId,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(eq(careTasks.id, input.id), eq(careTasks.organizationId, input.organizationId)))
|
||||||
|
.returning();
|
||||||
|
const task = rows[0];
|
||||||
|
if (!task) {
|
||||||
|
return { success: false, reason: "护理任务不存在", status: 404 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [elderRows, accountRows] = await Promise.all([
|
||||||
|
task.elderId
|
||||||
|
? database.select({ id: elders.id, name: elders.name }).from(elders).where(and(eq(elders.id, task.elderId), eq(elders.organizationId, input.organizationId)))
|
||||||
|
: [],
|
||||||
|
task.completedByAccountId
|
||||||
|
? database.select({ id: accounts.id, name: accounts.name }).from(accounts).where(eq(accounts.id, task.completedByAccountId))
|
||||||
|
: [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return toCareTask(task, buildNameMap(elderRows), buildNameMap(accountRows));
|
||||||
|
}
|
||||||
112
modules/care/types.ts
Normal file
112
modules/care/types.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
export const CARE_TASK_TYPE_VALUES = ["daily_care", "meal", "medication", "rehab", "inspection", "cleaning", "other"] as const;
|
||||||
|
export type CareTaskType = (typeof CARE_TASK_TYPE_VALUES)[number];
|
||||||
|
|
||||||
|
export const CARE_TASK_PRIORITY_VALUES = ["low", "normal", "high", "urgent"] as const;
|
||||||
|
export type CareTaskPriority = (typeof CARE_TASK_PRIORITY_VALUES)[number];
|
||||||
|
|
||||||
|
export const CARE_TASK_STATUS_VALUES = ["pending", "in_progress", "completed", "cancelled"] as const;
|
||||||
|
export type CareTaskStatus = (typeof CARE_TASK_STATUS_VALUES)[number];
|
||||||
|
|
||||||
|
export const CARE_TASK_TYPE_LABELS: Record<CareTaskType, string> = {
|
||||||
|
daily_care: "日常照护",
|
||||||
|
meal: "助餐",
|
||||||
|
medication: "用药",
|
||||||
|
rehab: "康复",
|
||||||
|
inspection: "巡检",
|
||||||
|
cleaning: "清洁",
|
||||||
|
other: "其他",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CARE_TASK_PRIORITY_LABELS: Record<CareTaskPriority, string> = {
|
||||||
|
low: "低",
|
||||||
|
normal: "普通",
|
||||||
|
high: "高",
|
||||||
|
urgent: "紧急",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CARE_TASK_STATUS_LABELS: Record<CareTaskStatus, string> = {
|
||||||
|
pending: "待处理",
|
||||||
|
in_progress: "进行中",
|
||||||
|
completed: "已完成",
|
||||||
|
cancelled: "已取消",
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CareTask = {
|
||||||
|
id: string;
|
||||||
|
organizationId: string;
|
||||||
|
elderId?: string;
|
||||||
|
elderName: string;
|
||||||
|
title: string;
|
||||||
|
careType: CareTaskType;
|
||||||
|
priority: CareTaskPriority;
|
||||||
|
status: CareTaskStatus;
|
||||||
|
scheduledAt: string;
|
||||||
|
assigneeLabel: string;
|
||||||
|
executionNotes: string;
|
||||||
|
completedAt?: string;
|
||||||
|
completedByAccountId?: string;
|
||||||
|
completedByName?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CareExecutionMetrics = {
|
||||||
|
pending: number;
|
||||||
|
inProgress: number;
|
||||||
|
completedToday: number;
|
||||||
|
highPriority: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CareExecutionData = {
|
||||||
|
metrics: CareExecutionMetrics;
|
||||||
|
tasks: CareTask[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CareTaskStatusUpdateInput = {
|
||||||
|
status: CareTaskStatus;
|
||||||
|
executionNotes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ValidationSuccess<T> = {
|
||||||
|
success: true;
|
||||||
|
data: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ValidationFailure = {
|
||||||
|
success: false;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ValidationResult<T> = ValidationSuccess<T> | ValidationFailure;
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(source: Record<string, unknown>, key: string): string {
|
||||||
|
const value = source[key];
|
||||||
|
return typeof value === "string" ? value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEnum<T extends string>(value: unknown, values: readonly T[]): T | null {
|
||||||
|
return typeof value === "string" ? values.find((item) => item === value) ?? null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCareTaskStatusUpdateInput(value: unknown): ValidationResult<CareTaskStatusUpdateInput> {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return { success: false, reason: "请求数据格式无效" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = readEnum(value.status, CARE_TASK_STATUS_VALUES);
|
||||||
|
if (!status) {
|
||||||
|
return { success: false, reason: "护理任务状态无效" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
status,
|
||||||
|
executionNotes: readString(value, "executionNotes"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
beds,
|
beds,
|
||||||
buildings,
|
buildings,
|
||||||
campuses,
|
campuses,
|
||||||
|
careTasks,
|
||||||
chronicConditions,
|
chronicConditions,
|
||||||
elders,
|
elders,
|
||||||
floors,
|
floors,
|
||||||
@@ -92,6 +93,18 @@ type SeedHealthReview = {
|
|||||||
vitalIndex: number;
|
vitalIndex: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SeedCareTask = {
|
||||||
|
assigneeLabel: string;
|
||||||
|
careType: "daily_care" | "meal" | "medication" | "rehab" | "inspection" | "cleaning" | "other";
|
||||||
|
completedHoursAgo?: number;
|
||||||
|
elderName: string;
|
||||||
|
executionNotes: string;
|
||||||
|
priority: "low" | "normal" | "high" | "urgent";
|
||||||
|
scheduledHoursOffset: number;
|
||||||
|
status: "pending" | "in_progress" | "completed" | "cancelled";
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_ROOMS: SeedRoom[] = [
|
const DEFAULT_ROOMS: SeedRoom[] = [
|
||||||
{ campusName: "主院区", buildingName: "护理楼", floorName: "一层", floorLevel: 1, code: "A101", name: "阳光房", capacity: 2 },
|
{ campusName: "主院区", buildingName: "护理楼", floorName: "一层", floorLevel: 1, code: "A101", name: "阳光房", capacity: 2 },
|
||||||
{ campusName: "主院区", buildingName: "护理楼", floorName: "一层", floorLevel: 1, code: "A102", name: "康养房", capacity: 2 },
|
{ campusName: "主院区", buildingName: "护理楼", floorName: "一层", floorLevel: 1, code: "A102", name: "康养房", capacity: 2 },
|
||||||
@@ -374,6 +387,60 @@ const DEFAULT_HEALTH_REVIEWS: SeedHealthReview[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const DEFAULT_CARE_TASKS: SeedCareTask[] = [
|
||||||
|
{
|
||||||
|
elderName: "王桂兰",
|
||||||
|
title: "晨间协助洗漱与翻身",
|
||||||
|
careType: "daily_care",
|
||||||
|
priority: "normal",
|
||||||
|
status: "pending",
|
||||||
|
scheduledHoursOffset: 1,
|
||||||
|
assigneeLabel: "一号护理组",
|
||||||
|
executionNotes: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
elderName: "孙秀梅",
|
||||||
|
title: "膝关节康复训练陪同",
|
||||||
|
careType: "rehab",
|
||||||
|
priority: "high",
|
||||||
|
status: "in_progress",
|
||||||
|
scheduledHoursOffset: -1,
|
||||||
|
assigneeLabel: "康复护理组",
|
||||||
|
executionNotes: "已完成热身,等待康复师复核动作。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
elderName: "周玉珍",
|
||||||
|
title: "餐前血糖记录与助餐",
|
||||||
|
careType: "meal",
|
||||||
|
priority: "normal",
|
||||||
|
status: "completed",
|
||||||
|
scheduledHoursOffset: -4,
|
||||||
|
completedHoursAgo: 3,
|
||||||
|
assigneeLabel: "二号护理组",
|
||||||
|
executionNotes: "餐前血糖已记录,午餐摄入约八成。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
elderName: "赵国华",
|
||||||
|
title: "重点照护夜间巡检补记",
|
||||||
|
careType: "inspection",
|
||||||
|
priority: "urgent",
|
||||||
|
status: "pending",
|
||||||
|
scheduledHoursOffset: -2,
|
||||||
|
assigneeLabel: "夜班护理组",
|
||||||
|
executionNotes: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
elderName: "刘长青",
|
||||||
|
title: "用药提醒与胸痛询问",
|
||||||
|
careType: "medication",
|
||||||
|
priority: "high",
|
||||||
|
status: "pending",
|
||||||
|
scheduledHoursOffset: 3,
|
||||||
|
assigneeLabel: "三号护理组",
|
||||||
|
executionNotes: "",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function hasRows(rows: unknown[]): boolean {
|
function hasRows(rows: unknown[]): boolean {
|
||||||
return rows.length > 0;
|
return rows.length > 0;
|
||||||
}
|
}
|
||||||
@@ -558,6 +625,31 @@ export async function seedDefaultWorkspaceData(organizationId: string): Promise<
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
await transaction.insert(careTasks).values(
|
||||||
|
DEFAULT_CARE_TASKS.map((task) => {
|
||||||
|
const elderId = elderIdByName.get(task.elderName);
|
||||||
|
if (!elderId) {
|
||||||
|
throw new Error("默认护理任务初始化失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedAt = task.completedHoursAgo === undefined ? undefined : new Date(now.getTime() - task.completedHoursAgo * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
return {
|
||||||
|
organizationId,
|
||||||
|
elderId,
|
||||||
|
title: task.title,
|
||||||
|
careType: task.careType,
|
||||||
|
priority: task.priority,
|
||||||
|
status: task.status,
|
||||||
|
scheduledAt: new Date(now.getTime() + task.scheduledHoursOffset * 60 * 60 * 1000),
|
||||||
|
assigneeLabel: task.assigneeLabel,
|
||||||
|
executionNotes: task.executionNotes,
|
||||||
|
completedAt,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
await transaction.insert(healthProfiles).values(
|
await transaction.insert(healthProfiles).values(
|
||||||
DEFAULT_HEALTH_PROFILES.map((profile) => {
|
DEFAULT_HEALTH_PROFILES.map((profile) => {
|
||||||
const elderId = elderIdByName.get(profile.elderName);
|
const elderId = elderIdByName.get(profile.elderName);
|
||||||
@@ -577,7 +669,6 @@ export async function seedDefaultWorkspaceData(organizationId: string): Promise<
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const vitalRows = await transaction
|
const vitalRows = await transaction
|
||||||
.insert(vitalRecords)
|
.insert(vitalRecords)
|
||||||
.values(
|
.values(
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
|
|||||||
{ id: "audit:read", label: "查看审计", category: "审计", description: "查看关键操作审计日志。" },
|
{ id: "audit:read", label: "查看审计", category: "审计", description: "查看关键操作审计日志。" },
|
||||||
{ id: "incident:read", label: "查看故障", category: "运维", description: "查看系统健康和故障中心。" },
|
{ id: "incident:read", label: "查看故障", category: "运维", description: "查看系统健康和故障中心。" },
|
||||||
{ id: "incident:manage", label: "处理故障", category: "运维", description: "确认、处理和关闭故障事件。" },
|
{ id: "incident:manage", label: "处理故障", category: "运维", description: "确认、处理和关闭故障事件。" },
|
||||||
|
{ id: "care:read", label: "查看护理任务", category: "护理", description: "查看护理服务任务、执行状态和记录。" },
|
||||||
|
{ id: "care:manage", label: "处理护理任务", category: "护理", description: "启动、完成和维护护理服务执行记录。" },
|
||||||
{ id: "health:read", label: "查看健康数据", category: "健康", description: "查看老人健康档案、生命体征和异常复核。" },
|
{ id: "health:read", label: "查看健康数据", category: "健康", description: "查看老人健康档案、生命体征和异常复核。" },
|
||||||
{ id: "health:manage", label: "管理健康数据", category: "健康", description: "维护健康档案、生命体征、慢病和异常复核。" },
|
{ id: "health:manage", label: "管理健康数据", category: "健康", description: "维护健康档案、生命体征、慢病和异常复核。" },
|
||||||
{ id: "facility:read", label: "查看房间床位", category: "床位", description: "查看房间、床位和占用状态。" },
|
{ id: "facility:read", label: "查看房间床位", category: "床位", description: "查看房间、床位和占用状态。" },
|
||||||
@@ -91,6 +93,8 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [
|
|||||||
"permission:read",
|
"permission:read",
|
||||||
"audit:read",
|
"audit:read",
|
||||||
"incident:read",
|
"incident:read",
|
||||||
|
"care:read",
|
||||||
|
"care:manage",
|
||||||
"health:read",
|
"health:read",
|
||||||
"health:manage",
|
"health:manage",
|
||||||
"facility:read",
|
"facility:read",
|
||||||
@@ -111,6 +115,8 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [
|
|||||||
"account:read",
|
"account:read",
|
||||||
"role:read",
|
"role:read",
|
||||||
"audit:read",
|
"audit:read",
|
||||||
|
"care:read",
|
||||||
|
"care:manage",
|
||||||
"health:read",
|
"health:read",
|
||||||
"health:manage",
|
"health:manage",
|
||||||
"facility:read",
|
"facility:read",
|
||||||
@@ -127,13 +133,13 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [
|
|||||||
key: "caregiver",
|
key: "caregiver",
|
||||||
scope: "organization",
|
scope: "organization",
|
||||||
description: "照护人员,查看床位和维护老人照护信息。",
|
description: "照护人员,查看床位和维护老人照护信息。",
|
||||||
permissions: ["health:read", "health:manage", "facility:read", "admission:read", "elder:read", "elder:update"],
|
permissions: ["care:read", "care:manage", "health:read", "health:manage", "facility:read", "admission:read", "elder:read", "elder:update"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "viewer",
|
key: "viewer",
|
||||||
scope: "organization",
|
scope: "organization",
|
||||||
description: "普通用户,只读查看老人、床位和入住信息。",
|
description: "普通用户,只读查看老人、床位和入住信息。",
|
||||||
permissions: ["health:read", "facility:read", "admission:read", "elder:read"],
|
permissions: ["care:read", "health:read", "facility:read", "admission:read", "elder:read"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "family",
|
key: "family",
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export const joinRequestStatusEnum = pgEnum("join_request_status", ["pending", "
|
|||||||
export const vitalRecordSourceEnum = pgEnum("vital_record_source", ["manual", "device", "import"]);
|
export const vitalRecordSourceEnum = pgEnum("vital_record_source", ["manual", "device", "import"]);
|
||||||
export const chronicConditionStatusEnum = pgEnum("chronic_condition_status", ["active", "controlled", "resolved"]);
|
export const chronicConditionStatusEnum = pgEnum("chronic_condition_status", ["active", "controlled", "resolved"]);
|
||||||
export const healthReviewStatusEnum = pgEnum("health_review_status", ["pending", "reviewed", "resolved"]);
|
export const healthReviewStatusEnum = pgEnum("health_review_status", ["pending", "reviewed", "resolved"]);
|
||||||
|
export const careTaskTypeEnum = pgEnum("care_task_type", ["daily_care", "meal", "medication", "rehab", "inspection", "cleaning", "other"]);
|
||||||
|
export const careTaskPriorityEnum = pgEnum("care_task_priority", ["low", "normal", "high", "urgent"]);
|
||||||
|
export const careTaskStatusEnum = pgEnum("care_task_status", ["pending", "in_progress", "completed", "cancelled"]);
|
||||||
|
|
||||||
export const systemSettings = pgTable("system_settings", {
|
export const systemSettings = pgTable("system_settings", {
|
||||||
id: text("id").primaryKey().default("global"),
|
id: text("id").primaryKey().default("global"),
|
||||||
@@ -299,6 +302,27 @@ export const healthAnomalyReviews = pgTable("health_anomaly_reviews", {
|
|||||||
reviewQueueLookup: index("health_anomaly_reviews_queue_lookup").on(table.organizationId, table.status, table.severity),
|
reviewQueueLookup: index("health_anomaly_reviews_queue_lookup").on(table.organizationId, table.status, table.severity),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const careTasks = pgTable("care_tasks", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||||||
|
elderId: uuid("elder_id").references(() => elders.id, { onDelete: "set null" }),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
careType: careTaskTypeEnum("care_type").notNull(),
|
||||||
|
priority: careTaskPriorityEnum("priority").notNull().default("normal"),
|
||||||
|
status: careTaskStatusEnum("status").notNull().default("pending"),
|
||||||
|
scheduledAt: timestamp("scheduled_at", { withTimezone: true }).notNull(),
|
||||||
|
assigneeLabel: text("assignee_label").notNull().default(""),
|
||||||
|
executionNotes: text("execution_notes").notNull().default(""),
|
||||||
|
completedAt: timestamp("completed_at", { withTimezone: true }),
|
||||||
|
createdByAccountId: uuid("created_by_account_id").references(() => accounts.id),
|
||||||
|
completedByAccountId: uuid("completed_by_account_id").references(() => accounts.id),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => ({
|
||||||
|
queueLookup: index("care_tasks_queue_lookup").on(table.organizationId, table.status, table.priority),
|
||||||
|
scheduleLookup: index("care_tasks_schedule_lookup").on(table.organizationId, table.scheduledAt),
|
||||||
|
}));
|
||||||
|
|
||||||
export const admissions = pgTable("admissions", {
|
export const admissions = pgTable("admissions", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export const PERMISSIONS = [
|
|||||||
"audit:read",
|
"audit:read",
|
||||||
"incident:read",
|
"incident:read",
|
||||||
"incident:manage",
|
"incident:manage",
|
||||||
|
"care:read",
|
||||||
|
"care:manage",
|
||||||
"health:read",
|
"health:read",
|
||||||
"health:manage",
|
"health:manage",
|
||||||
"facility:read",
|
"facility:read",
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export const navGroups: NavGroup[] = [
|
|||||||
path: "/care",
|
path: "/care",
|
||||||
label: "护理服务",
|
label: "护理服务",
|
||||||
icon: "care",
|
icon: "care",
|
||||||
permission: "elder:update",
|
permission: "care:read",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/emergency",
|
path: "/emergency",
|
||||||
|
|||||||
Reference in New Issue
Block a user