Compare commits

...

7 Commits

70 changed files with 10837 additions and 156 deletions

View File

@@ -251,6 +251,73 @@ return jsonSuccess("健康数据已加载", data);
#### Correct
## Scenario: Collaboration Module Data Management
### 1. Scope / Trigger
- Trigger: changing 设备运维, 公告通知, 规则预警, or 家属服务 pages, APIs, seed data, or schema.
- Applies because these modules are real Drizzle/PostgreSQL-backed collaboration workspaces, not reserved/static module pages.
### 2. Signatures
- `GET /api/devices/assets`, `POST /api/devices/assets`, `PATCH|DELETE /api/devices/assets/[id]`
- `POST /api/devices/tickets`, `PATCH|DELETE /api/devices/tickets/[id]`
- `GET|POST /api/notices`, `POST|PATCH|DELETE /api/notices/[id]`
- `GET|POST /api/alerts/rules`, `PATCH|DELETE /api/alerts/rules/[id]`
- `POST /api/alerts/triggers`, `PATCH|DELETE /api/alerts/triggers/[id]`
- `GET|POST /api/family/contacts`, `PATCH|DELETE /api/family/contacts/[id]`
- `POST /api/family/visits`, `PATCH|DELETE /api/family/visits/[id]`
- `POST /api/family/feedback`, `PATCH|DELETE /api/family/feedback/[id]`
### 3. Contracts
- Reads require module read permission: `device:read`, `notice:read`, `alert:read`, or `family:read`.
- Mutations require module manage permission: `device:manage`, `notice:manage`, `alert:manage`, or `family:manage`.
- All records must be scoped by active `organizationId`; updates/deletes must filter by both `id` and `organizationId`.
- Read APIs return `{ success: true, reason, data }` where `data` is the module workspace DTO.
- Mutation APIs return the changed resource under a resource-specific key and write an audit log after successful persistence.
- Seed demo rows only inside `seedDefaultWorkspaceData(organizationId)` and connect them to real seeded elders/devices/rules where applicable.
### 4. Validation & Error Matrix
- Missing session or permission -> response from `requirePermission`; domain helper must not be called.
- Missing active organization -> `400` with a Chinese reason asking the user to select an organization.
- Invalid enum, empty required title/name/content, invalid date -> `400`.
- Cross-organization or missing referenced record -> `404` with entity-specific reason.
- Insert/update returning no row -> structured mutation failure with status `500` or `404`.
### 5. Good/Base/Bad Cases
- Good: Server page checks read permission, loads persisted workspace data, and passes serializable DTOs to a Client Component.
- Good: Client refreshes by calling the module read API after mutations.
- Good: notice read receipts use `POST /api/notices/[id]` with read permission; create/update/delete use manage permission.
- Base: alert rules and triggers are manually managed in v1; no background rule engine is implied.
- Bad: rendering fake collaboration counters or rows on reserved pages after these modules have real tables.
- Bad: updating a record by `id` alone without the active organization filter.
### 6. Tests Required
- `pnpm db:generate`, then review SQL for additive collaboration enums/tables/indexes/FKs.
- `pnpm lint`, `pnpm type-check`, `pnpm test`, and `pnpm build`.
- API route tests should cover permission denial, missing active organization, successful create/update audit, and missing/cross-organization mutation failures.
### 7. Wrong vs Correct
#### Wrong
```ts
await database.update(alertTriggers).set({ status }).where(eq(alertTriggers.id, id));
```
#### Correct
```ts
await database
.update(alertTriggers)
.set({ status, updatedAt: new Date() })
.where(and(eq(alertTriggers.id, id), eq(alertTriggers.organizationId, organizationId)));
```
```ts
const data = await listHealthAdminData(organizationId);
return jsonSuccess("健康数据已加载", { data });

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,50 @@
# Design
## Architecture
Add four module slices following existing patterns:
- `modules/devices`, `modules/notices`, `modules/alerts`, and `modules/family`
- each module owns `types.ts`, `server/operations.ts`, and a client workspace component
- route handlers under `app/api/<module>` validate permissions, input, organization context, and audit mutations
- app route pages remain Server Components and pass initial data plus `canManage` into client components
## Data Model
Add Postgres enums and tables to `modules/core/server/schema.ts`:
- devices: asset status, ticket status, ticket priority; device assets and maintenance tickets
- notices: notice status; notices and notice read receipts
- alerts: rule type/status, trigger status; alert rules and alert triggers
- family: relation/status, visit status, feedback status/type; family contacts, visit appointments, feedback records
All business tables include `organizationId`, timestamps, and indexes for list/status lookups. FK references use cascade for organization-owned children and set-null where historical records should survive related record deletion.
## Permissions
Extend `Permission` and seeded permission definitions:
- `device:read`, `device:manage`
- `notice:read`, `notice:manage`
- `alert:read`, `alert:manage`
- `family:read`, `family:manage`
`org_admin` and `manager` receive read/manage for all four modules. `viewer` receives read permissions. `caregiver` receives read permissions for devices, alerts, and family plus existing care/health/facility access.
## UI Flow
Each workspace should provide:
- metric cards for key counts
- search and status/type filters
- tables with stable widths and empty states
- dialogs for create/edit/delete confirmation or status updates
- optimistic local refresh by refetching the module API after successful mutations
## Compatibility
Generate a Drizzle migration from schema changes. Existing installations need to run migrations before using the pages. Existing reserved-page component can remain for future modules but must no longer be used by these four routes.
## Rollback
The rollback point is the generated migration plus module files. If implementation gets too large, keep schema and API for all modules but reduce UI duplication by sharing small local helper components only where it does not obscure module boundaries.

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,26 @@
# Implementation Plan
## Steps
1. Load relevant frontend, backend, and shared Trellis specs.
2. Add schema enums/tables and update core permission types/seed role definitions.
3. Generate the Drizzle migration.
4. Add module type contracts, validators, and server operations for devices, notices, alerts, and family.
5. Add API route handlers and route tests for read/manage permission behavior and organization scoping.
6. Add local seed records for the four modules using existing organization, elder, bed, incident, health, and account context where available.
7. Replace reserved route pages with data-backed Server Components and client workspace components.
8. Run `pnpm type-check`, `pnpm test`, and targeted lint/build checks as time allows.
9. Run Trellis check/update-spec finish steps and commit task changes.
## Validation Commands
- `pnpm db:generate`
- `pnpm type-check`
- `pnpm test`
- `pnpm lint`
## Risk Points
- Schema migration generation changes tracked Drizzle metadata; inspect before finalizing.
- Avoid touching unrelated user-modified files except where required by shared types/navigation/seed integration.
- Keep organization scoping explicit in every list and mutation query.

View File

@@ -0,0 +1,42 @@
# Build collaboration modules
## Goal
Replace the four reserved "协同" navigation pages with real persisted business modules for 设备运维, 公告通知, 规则预警, and 家属服务 so the local养老机构工作台 can exercise end-to-end CRUD workflows backed by Drizzle/Postgres data.
## Confirmed Facts
- The current navigation already exposes `/devices`, `/notices`, `/alerts`, and `/family`.
- These routes currently render `ReservedModulePages`, and the frontend spec requires reserved modules not to fabricate operational data.
- The stack uses Next.js App Router, React client components, project-owned Kumo UI adapters, Drizzle/Postgres, route handlers under `app/api`, and permission gates through `requirePermission`.
- Existing seed data is organization-scoped and idempotent for already-initialized workspaces.
## Requirements
- Add real schema, migrations, server operations, API routes, UI pages, permissions, and seed data for all four collaboration modules.
- Implement complete CRUD for each module's core records, plus the key status transitions needed for daily operations.
- Keep all records organization-scoped and prevent cross-organization reads or mutations.
- Add audit logs for create, update, delete, and status transition mutations.
- Replace reserved pages for both unscoped and organization-scoped app routes.
- Keep UI consistent with existing workspaces: server page loads initial data, client workspace handles filters, tables, dialogs, and mutations.
- Preserve TypeScript strictness and avoid new external dependencies.
## Module Scope
- 设备运维: equipment/device assets and maintenance tickets.
- 公告通知: notices, publish/retract lifecycle, and account read receipts.
- 规则预警: alert rules and triggered alert records. V1 persists and manages rules/triggers but does not implement a background rule engine.
- 家属服务: family contacts, visit appointments, and family feedback. V1 does not add family login or external messaging.
## Acceptance Criteria
- [ ] Four collaboration nav pages render real data-backed workspaces instead of reserved module placeholders.
- [ ] Each module supports list, create, edit, delete, and relevant status transitions through API routes.
- [ ] New data is seeded for local/demo organizations without overwriting existing workspace data.
- [ ] New permissions are registered and assigned to system roles consistently.
- [ ] Mutation API routes require manage permissions, read API routes require read permissions, and cross-organization records cannot be mutated.
- [ ] `pnpm db:generate`, `pnpm type-check`, and `pnpm test` pass.
## Out of Scope
- Family-member authentication, family-facing mobile/client portal, SMS/push delivery, and automatic alert-rule evaluation jobs.

View File

@@ -0,0 +1,26 @@
{
"id": "collaboration-modules",
"name": "collaboration-modules",
"title": "Build collaboration modules",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "TalexDreamSoul",
"assignee": "TalexDreamSoul",
"createdAt": "2026-07-03",
"completedAt": "2026-07-03",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

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

View File

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

View File

@@ -0,0 +1,31 @@
# Local mock data
## Goal
Provide richer local mock data so the养老机构工作台 can be opened locally with realistic content across the existing dashboard, beds, elders, care, health, and emergency surfaces.
## Confirmed Facts
- The project already has Drizzle/Postgres schema and a `seedDefaultWorkspaceData(organizationId)` helper in `modules/core/server/default-workspace-data.ts`.
- Existing default data covers rooms, beds, elders, admissions, health profiles, vital records, chronic conditions, health anomaly reviews, and care tasks.
- Local Postgres is configured through `compose.yaml`; Drizzle uses `DATABASE_URL`.
- This is a lightweight task: no schema change, no product workflow redesign, and no new external dependency is required.
## Requirements
- Add a larger, more varied default dataset using existing schema tables and enum values.
- Include enough records to make local pages visibly populated: facilities/beds, elder roster, active admissions, care tasks, health vitals/reviews, chronic conditions, and emergency incidents.
- Keep seeding idempotent for an organization that already has workspace data; do not overwrite user-created local data.
- Preserve existing TypeScript type safety and existing data relationships by resolving records through names/codes inside the seed transaction.
- Avoid production-only assumptions; this data is for local/demo initialization only.
## Acceptance Criteria
- [x] Default workspace seed data has more realistic volume and state variety across the existing modules.
- [x] Seed data inserts without foreign-key errors into a migrated local database.
- [x] `pnpm type-check` passes.
- [x] No schema migration is introduced for this task.
## Notes
- Out of scope: fake auth users, generated production data, cross-organization demo scenarios, and UI redesign.

View File

@@ -0,0 +1,26 @@
{
"id": "local-mock-data",
"name": "local-mock-data",
"title": "Local mock data",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "TalexDreamSoul",
"assignee": "TalexDreamSoul",
"createdAt": "2026-07-03",
"completedAt": "2026-07-03",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -8,7 +8,7 @@
<!-- @@@auto:current-status -->
- **Active File**: `journal-1.md`
- **Total Sessions**: 9
- **Total Sessions**: 11
- **Last Active**: 2026-07-03
<!-- @@@/auto:current-status -->
@@ -19,7 +19,7 @@
<!-- @@@auto:active-documents -->
| File | Lines | Status |
|------|-------|--------|
| `journal-1.md` | ~316 | Active |
| `journal-1.md` | ~382 | Active |
<!-- @@@/auto:active-documents -->
---
@@ -29,6 +29,8 @@
<!-- @@@auto:session-history -->
| # | Date | Title | Commits | Branch |
|---|------|-------|---------|--------|
| 11 | 2026-07-03 | Build collaboration modules | `4ba2a11` | `main` |
| 10 | 2026-07-03 | Local mock data | `36eae0b` | `main` |
| 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` |

View File

@@ -314,3 +314,69 @@ Refined global settings invitation content and fixed settings dialog sizing/cent
### Next Steps
- None - task complete
## Session 10: Local mock data
**Date**: 2026-07-03
**Task**: Local mock data
**Branch**: `main`
### Summary
Expanded default workspace seed data across facilities, elders, admissions, health, care, and emergency incidents; validated lint, type-check, tests, build, and local database seeding.
### Main Changes
(Add details)
### Git Commits
| Hash | Message |
|------|---------|
| `36eae0b` | (see git log) |
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete
## Session 11: Build collaboration modules
**Date**: 2026-07-03
**Task**: Build collaboration modules
**Branch**: `main`
### Summary
Implemented real data-backed collaboration workspaces for devices, notices, alerts, and family services with schema, migrations, seed data, permissions, APIs, UI routes, tests, and backend spec updates.
### Main Changes
(Add details)
### Git Commits
| Hash | Message |
|------|---------|
| `4ba2a11` | (see git log) |
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,26 @@
import { NoticesModulePage } from "@/modules/shared/components/ReservedModulePages";
import { redirect } from "next/navigation";
export default function NoticesPage(): React.ReactElement {
return <NoticesModulePage />;
import { getCurrentAuthContext } from "@/modules/core/server/auth";
import { hasPermission } from "@/modules/core/server/permissions";
import { NoticesWorkspaceClient } from "@/modules/notices/components/NoticesWorkspaceClient";
import { listNoticeCenterData } from "@/modules/notices/server/operations";
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
export default async function NoticesPage(): Promise<React.ReactElement> {
const context = await getCurrentAuthContext();
if (!context) {
redirect("/login");
}
if (!hasPermission(context.permissions, "notice:read")) {
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
}
const organizationId = context.organization?.id;
if (!organizationId) {
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
}
const data = await listNoticeCenterData(organizationId, context.account.id);
return <NoticesWorkspaceClient canManage={hasPermission(context.permissions, "notice:manage")} initialData={data} />;
}

View File

@@ -0,0 +1,74 @@
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
import { deleteAlertRule, isAlertMutationFailure, updateAlertRule } from "@/modules/alerts/server/operations";
import { validateAlertRuleInput } from "@/modules/alerts/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("alert:manage", { action: "alert.rule.update", targetType: "alert_rule", targetId: id });
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后维护规则", 400);
}
const input = validateAlertRuleInput(await readJsonBody(request));
if (!input.success) {
return jsonFailure(input.reason);
}
const rule = await updateAlertRule({ ...input.data, id, organizationId });
if (isAlertMutationFailure(rule)) {
return jsonFailure(rule.reason, rule.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId,
action: "alert.rule.update",
targetType: "alert_rule",
targetId: rule.id,
result: "success",
reason: `更新预警规则:${rule.name}`,
});
return jsonSuccess("预警规则已更新", { rule });
}
export async function DELETE(_request: Request, context: RouteContext): Promise<Response> {
const { id } = await context.params;
const auth = await requirePermission("alert:manage", { action: "alert.rule.delete", targetType: "alert_rule", targetId: id });
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后维护规则", 400);
}
const rule = await deleteAlertRule({ id, organizationId });
if (isAlertMutationFailure(rule)) {
return jsonFailure(rule.reason, rule.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId,
action: "alert.rule.delete",
targetType: "alert_rule",
targetId: rule.id,
result: "success",
reason: `删除预警规则:${rule.name}`,
});
return jsonSuccess("预警规则已删除", { rule });
}

View File

@@ -0,0 +1,54 @@
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
import { createAlertRule, isAlertMutationFailure, listAlertCenterData } from "@/modules/alerts/server/operations";
import { validateAlertRuleInput } from "@/modules/alerts/types";
export async function GET(): Promise<Response> {
const auth = await requirePermission("alert:read", { action: "alert.rule.list", targetType: "alert_rule" });
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后查看规则预警", 400);
}
const data = await listAlertCenterData(organizationId);
return jsonSuccess("规则预警已加载", { data });
}
export async function POST(request: Request): Promise<Response> {
const auth = await requirePermission("alert:manage", { action: "alert.rule.create", targetType: "alert_rule" });
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后维护规则", 400);
}
const input = validateAlertRuleInput(await readJsonBody(request));
if (!input.success) {
return jsonFailure(input.reason);
}
const rule = await createAlertRule({ ...input.data, organizationId });
if (isAlertMutationFailure(rule)) {
return jsonFailure(rule.reason, rule.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId,
action: "alert.rule.create",
targetType: "alert_rule",
targetId: rule.id,
result: "success",
reason: `创建预警规则:${rule.name}`,
});
return jsonSuccess("预警规则已创建", { rule }, 201);
}

View File

@@ -0,0 +1,74 @@
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
import { deleteAlertTrigger, isAlertMutationFailure, updateAlertTrigger } from "@/modules/alerts/server/operations";
import { validateAlertTriggerInput } from "@/modules/alerts/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("alert:manage", { action: "alert.trigger.update", targetType: "alert_trigger", targetId: id });
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后维护触发记录", 400);
}
const input = validateAlertTriggerInput(await readJsonBody(request));
if (!input.success) {
return jsonFailure(input.reason);
}
const trigger = await updateAlertTrigger({ ...input.data, id, organizationId, accountId: auth.context.account.id });
if (isAlertMutationFailure(trigger)) {
return jsonFailure(trigger.reason, trigger.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId,
action: "alert.trigger.update",
targetType: "alert_trigger",
targetId: trigger.id,
result: "success",
reason: `更新预警触发记录:${trigger.title}`,
});
return jsonSuccess("预警触发记录已更新", { trigger });
}
export async function DELETE(_request: Request, context: RouteContext): Promise<Response> {
const { id } = await context.params;
const auth = await requirePermission("alert:manage", { action: "alert.trigger.delete", targetType: "alert_trigger", targetId: id });
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后维护触发记录", 400);
}
const trigger = await deleteAlertTrigger({ id, organizationId });
if (isAlertMutationFailure(trigger)) {
return jsonFailure(trigger.reason, trigger.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId,
action: "alert.trigger.delete",
targetType: "alert_trigger",
targetId: trigger.id,
result: "success",
reason: `删除预警触发记录:${trigger.title}`,
});
return jsonSuccess("预警触发记录已删除", { trigger });
}

View File

@@ -0,0 +1,39 @@
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
import { createAlertTrigger, isAlertMutationFailure } from "@/modules/alerts/server/operations";
import { validateAlertTriggerInput } from "@/modules/alerts/types";
export async function POST(request: Request): Promise<Response> {
const auth = await requirePermission("alert:manage", { action: "alert.trigger.create", targetType: "alert_trigger" });
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后维护触发记录", 400);
}
const input = validateAlertTriggerInput(await readJsonBody(request));
if (!input.success) {
return jsonFailure(input.reason);
}
const trigger = await createAlertTrigger({ ...input.data, organizationId, accountId: auth.context.account.id });
if (isAlertMutationFailure(trigger)) {
return jsonFailure(trigger.reason, trigger.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId,
action: "alert.trigger.create",
targetType: "alert_trigger",
targetId: trigger.id,
result: "success",
reason: `创建预警触发记录:${trigger.title}`,
});
return jsonSuccess("预警触发记录已创建", { trigger }, 201);
}

View File

@@ -0,0 +1,336 @@
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 { AlertCenterData, AlertRule } from "@/modules/alerts/types";
import type { DeviceAsset, DeviceOperationsData } from "@/modules/devices/types";
import type { FamilyContact, FamilyServiceData } from "@/modules/family/types";
import type { Notice } from "@/modules/notices/types";
import {
createAlertRule,
listAlertCenterData,
updateAlertTrigger,
} from "@/modules/alerts/server/operations";
import {
createDeviceAsset,
listDeviceOperationsData,
updateMaintenanceTicket,
} from "@/modules/devices/server/operations";
import {
createFamilyContact,
listFamilyServiceData,
updateFamilyVisit,
} from "@/modules/family/server/operations";
import {
createNotice,
listNoticeCenterData,
markNoticeRead,
} from "@/modules/notices/server/operations";
vi.mock("@/modules/core/server/auth", () => ({
requirePermission: vi.fn(),
}));
vi.mock("@/modules/core/server/audit", () => ({
recordAuditLog: vi.fn(),
}));
vi.mock("@/modules/devices/server/operations", () => ({
createDeviceAsset: vi.fn(),
isDeviceMutationFailure: vi.fn((value: unknown) => typeof value === "object" && value !== null && "success" in value && value.success === false),
listDeviceOperationsData: vi.fn(),
updateMaintenanceTicket: vi.fn(),
}));
vi.mock("@/modules/notices/server/operations", () => ({
createNotice: vi.fn(),
isNoticeMutationFailure: vi.fn((value: unknown) => typeof value === "object" && value !== null && "success" in value && value.success === false),
listNoticeCenterData: vi.fn(),
markNoticeRead: vi.fn(),
}));
vi.mock("@/modules/alerts/server/operations", () => ({
createAlertRule: vi.fn(),
isAlertMutationFailure: vi.fn((value: unknown) => typeof value === "object" && value !== null && "success" in value && value.success === false),
listAlertCenterData: vi.fn(),
updateAlertTrigger: vi.fn(),
}));
vi.mock("@/modules/family/server/operations", () => ({
createFamilyContact: vi.fn(),
isFamilyMutationFailure: vi.fn((value: unknown) => typeof value === "object" && value !== null && "success" in value && value.success === false),
listFamilyServiceData: vi.fn(),
updateFamilyVisit: 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: ["device:read", "device:manage", "notice:read", "notice:manage", "alert:read", "alert:manage", "family:read", "family: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 device: DeviceAsset = {
id: "device-1",
organizationId: "org-1",
name: "床头呼叫器",
code: "DEV-1",
category: "呼叫系统",
location: "A102",
status: "active",
lastInspectedAt: undefined,
notes: "",
createdAt: "2026-07-02T00:00:00.000Z",
updatedAt: "2026-07-02T00:00:00.000Z",
};
const deviceData: DeviceOperationsData = {
metrics: { activeDevices: 1, maintenanceDevices: 0, openTickets: 0, urgentTickets: 0 },
devices: [device],
tickets: [],
};
const notice: Notice = {
id: "notice-1",
organizationId: "org-1",
title: "探访安排",
content: "周六探访安排",
audience: "全体员工",
status: "published",
publishedAt: "2026-07-02T00:00:00.000Z",
createdByAccountId: "account-1",
createdByName: "Admin",
updatedByAccountId: "account-1",
updatedByName: "Admin",
readCount: 0,
hasRead: false,
createdAt: "2026-07-02T00:00:00.000Z",
updatedAt: "2026-07-02T00:00:00.000Z",
};
const alertRule: AlertRule = {
id: "rule-1",
organizationId: "org-1",
name: "血氧低值连续告警",
ruleType: "health",
severity: "critical",
status: "enabled",
conditionSummary: "血氧低于阈值",
suggestion: "通知医生",
createdAt: "2026-07-02T00:00:00.000Z",
updatedAt: "2026-07-02T00:00:00.000Z",
};
const alertData: AlertCenterData = {
metrics: { enabledRules: 1, openTriggers: 0, criticalTriggers: 0, resolvedTriggers: 0 },
rules: [alertRule],
triggers: [],
};
const familyContact: FamilyContact = {
id: "contact-1",
organizationId: "org-1",
elderId: "elder-1",
elderName: "王桂兰",
name: "张敏",
relationship: "女儿",
phone: "13800000001",
status: "active",
notes: "",
createdAt: "2026-07-02T00:00:00.000Z",
updatedAt: "2026-07-02T00:00:00.000Z",
};
const familyData: FamilyServiceData = {
metrics: { activeContacts: 1, pendingVisits: 0, openFeedback: 0, resolvedFeedback: 0 },
elderOptions: [{ id: "elder-1", name: "王桂兰" }],
contacts: [familyContact],
visits: [],
feedback: [],
};
function allowAuth(overrides: Partial<AuthContext> = {}): void {
vi.mocked(requirePermission).mockResolvedValue({ success: true, context: createAuthContext(overrides) });
}
function jsonRequest(body: Record<string, unknown>): Request {
return new Request("http://localhost/api/collaboration", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
async function readJson(response: Response): Promise<Record<string, unknown>> {
return (await response.json()) as Record<string, unknown>;
}
beforeEach(() => {
vi.clearAllMocks();
allowAuth();
});
describe("collaboration API routes", () => {
it("loads device operations for the active organization", async () => {
vi.mocked(listDeviceOperationsData).mockResolvedValue(deviceData);
const { GET } = await import("./devices/assets/route");
const response = await GET();
const body = await readJson(response);
expect(response.status).toBe(200);
expect(body.data).toEqual(deviceData);
expect(listDeviceOperationsData).toHaveBeenCalledWith("org-1");
});
it("requires active organization before loading notices", async () => {
allowAuth({ organization: undefined });
const { GET } = await import("./notices/route");
const response = await GET();
const body = await readJson(response);
expect(response.status).toBe(400);
expect(body.reason).toBe("请选择机构后查看公告");
expect(listNoticeCenterData).not.toHaveBeenCalled();
});
it("creates a device asset and records audit", async () => {
vi.mocked(createDeviceAsset).mockResolvedValue(device);
const { POST } = await import("./devices/assets/route");
const response = await POST(jsonRequest({ name: "床头呼叫器", code: "DEV-1", status: "active" }));
const body = await readJson(response);
expect(response.status).toBe(201);
expect(body.success).toBe(true);
expect(createDeviceAsset).toHaveBeenCalledWith(expect.objectContaining({ organizationId: "org-1" }));
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "device.asset.create" }));
});
it("updates a maintenance ticket and propagates cross-organization failures", async () => {
vi.mocked(updateMaintenanceTicket).mockResolvedValue({ success: false, reason: "维修工单不存在", status: 404 });
const { PATCH } = await import("./devices/tickets/[id]/route");
const response = await PATCH(jsonRequest({ title: "保养", priority: "normal", status: "open" }), {
params: Promise.resolve({ id: "ticket-from-other-org" }),
});
const body = await readJson(response);
expect(response.status).toBe(404);
expect(body.reason).toBe("维修工单不存在");
expect(recordAuditLog).not.toHaveBeenCalled();
});
it("creates and marks notices read", async () => {
vi.mocked(createNotice).mockResolvedValue(notice);
vi.mocked(markNoticeRead).mockResolvedValue({ ...notice, hasRead: true, readCount: 1 });
const { POST } = await import("./notices/route");
const createResponse = await POST(jsonRequest({ title: "探访安排", content: "周六探访安排", status: "published" }));
expect(createResponse.status).toBe(201);
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "notice.create" }));
vi.clearAllMocks();
allowAuth();
const { POST: markReadPost } = await import("./notices/[id]/route");
const readResponse = await markReadPost(jsonRequest({}), { params: Promise.resolve({ id: "notice-1" }) });
expect(readResponse.status).toBe(200);
expect(markNoticeRead).toHaveBeenCalledWith({ accountId: "account-1", id: "notice-1", organizationId: "org-1" });
});
it("loads and creates alert rules", async () => {
vi.mocked(listAlertCenterData).mockResolvedValue(alertData);
vi.mocked(createAlertRule).mockResolvedValue(alertRule);
const { GET, POST } = await import("./alerts/rules/route");
const listResponse = await GET();
expect(listResponse.status).toBe(200);
expect(listAlertCenterData).toHaveBeenCalledWith("org-1");
const createResponse = await POST(jsonRequest({ name: "血氧低值连续告警", ruleType: "health", severity: "critical", status: "enabled" }));
expect(createResponse.status).toBe(201);
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "alert.rule.create" }));
});
it("updates alert trigger failures without audit", async () => {
vi.mocked(updateAlertTrigger).mockResolvedValue({ success: false, reason: "预警触发记录不存在", status: 404 });
const { PATCH } = await import("./alerts/triggers/[id]/route");
const response = await PATCH(jsonRequest({ title: "触发", status: "resolved" }), { params: Promise.resolve({ id: "trigger-other-org" }) });
const body = await readJson(response);
expect(response.status).toBe(404);
expect(body.reason).toBe("预警触发记录不存在");
expect(recordAuditLog).not.toHaveBeenCalled();
});
it("loads family services and creates contacts", async () => {
vi.mocked(listFamilyServiceData).mockResolvedValue(familyData);
vi.mocked(createFamilyContact).mockResolvedValue(familyContact);
const { GET, POST } = await import("./family/contacts/route");
const listResponse = await GET();
expect(listResponse.status).toBe(200);
expect(listFamilyServiceData).toHaveBeenCalledWith("org-1");
const createResponse = await POST(
jsonRequest({ elderId: "elder-1", name: "张敏", relationship: "女儿", phone: "13800000001", status: "active" }),
);
expect(createResponse.status).toBe(201);
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "family.contact.create" }));
});
it("updates family visit failures without audit", async () => {
vi.mocked(updateFamilyVisit).mockResolvedValue({ success: false, reason: "探访预约不存在", status: 404 });
const { PATCH } = await import("./family/visits/[id]/route");
const response = await PATCH(
jsonRequest({ elderId: "elder-1", scheduledAt: "2026-07-02T08:00:00.000Z", status: "approved" }),
{ params: Promise.resolve({ id: "visit-other-org" }) },
);
const body = await readJson(response);
expect(response.status).toBe(404);
expect(body.reason).toBe("探访预约不存在");
expect(recordAuditLog).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,74 @@
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
import { deleteDeviceAsset, isDeviceMutationFailure, updateDeviceAsset } from "@/modules/devices/server/operations";
import { validateDeviceAssetInput } from "@/modules/devices/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("device:manage", { action: "device.asset.update", targetType: "device_asset", targetId: id });
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后维护设备", 400);
}
const input = validateDeviceAssetInput(await readJsonBody(request));
if (!input.success) {
return jsonFailure(input.reason);
}
const device = await updateDeviceAsset({ ...input.data, id, organizationId });
if (isDeviceMutationFailure(device)) {
return jsonFailure(device.reason, device.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId,
action: "device.asset.update",
targetType: "device_asset",
targetId: device.id,
result: "success",
reason: `更新设备:${device.name}`,
});
return jsonSuccess("设备已更新", { device });
}
export async function DELETE(_request: Request, context: RouteContext): Promise<Response> {
const { id } = await context.params;
const auth = await requirePermission("device:manage", { action: "device.asset.delete", targetType: "device_asset", targetId: id });
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后维护设备", 400);
}
const device = await deleteDeviceAsset({ id, organizationId });
if (isDeviceMutationFailure(device)) {
return jsonFailure(device.reason, device.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId,
action: "device.asset.delete",
targetType: "device_asset",
targetId: device.id,
result: "success",
reason: `删除设备:${device.name}`,
});
return jsonSuccess("设备已删除", { device });
}

View File

@@ -0,0 +1,54 @@
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
import { createDeviceAsset, isDeviceMutationFailure, listDeviceOperationsData } from "@/modules/devices/server/operations";
import { validateDeviceAssetInput } from "@/modules/devices/types";
export async function GET(): Promise<Response> {
const auth = await requirePermission("device:read", { action: "device.asset.list", targetType: "device_asset" });
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后查看设备运维", 400);
}
const data = await listDeviceOperationsData(organizationId);
return jsonSuccess("设备运维数据已加载", { data });
}
export async function POST(request: Request): Promise<Response> {
const auth = await requirePermission("device:manage", { action: "device.asset.create", targetType: "device_asset" });
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后维护设备", 400);
}
const input = validateDeviceAssetInput(await readJsonBody(request));
if (!input.success) {
return jsonFailure(input.reason);
}
const device = await createDeviceAsset({ ...input.data, organizationId });
if (isDeviceMutationFailure(device)) {
return jsonFailure(device.reason, device.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId,
action: "device.asset.create",
targetType: "device_asset",
targetId: device.id,
result: "success",
reason: `创建设备:${device.name}`,
});
return jsonSuccess("设备已创建", { device }, 201);
}

View File

@@ -0,0 +1,74 @@
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
import { deleteMaintenanceTicket, isDeviceMutationFailure, updateMaintenanceTicket } from "@/modules/devices/server/operations";
import { validateMaintenanceTicketInput } from "@/modules/devices/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("device:manage", { action: "device.ticket.update", targetType: "maintenance_ticket", targetId: id });
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后维护工单", 400);
}
const input = validateMaintenanceTicketInput(await readJsonBody(request));
if (!input.success) {
return jsonFailure(input.reason);
}
const ticket = await updateMaintenanceTicket({ ...input.data, id, organizationId });
if (isDeviceMutationFailure(ticket)) {
return jsonFailure(ticket.reason, ticket.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId,
action: "device.ticket.update",
targetType: "maintenance_ticket",
targetId: ticket.id,
result: "success",
reason: `更新维修工单:${ticket.title}`,
});
return jsonSuccess("维修工单已更新", { ticket });
}
export async function DELETE(_request: Request, context: RouteContext): Promise<Response> {
const { id } = await context.params;
const auth = await requirePermission("device:manage", { action: "device.ticket.delete", targetType: "maintenance_ticket", targetId: id });
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后维护工单", 400);
}
const ticket = await deleteMaintenanceTicket({ id, organizationId });
if (isDeviceMutationFailure(ticket)) {
return jsonFailure(ticket.reason, ticket.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId,
action: "device.ticket.delete",
targetType: "maintenance_ticket",
targetId: ticket.id,
result: "success",
reason: `删除维修工单:${ticket.title}`,
});
return jsonSuccess("维修工单已删除", { ticket });
}

View File

@@ -0,0 +1,39 @@
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
import { createMaintenanceTicket, isDeviceMutationFailure } from "@/modules/devices/server/operations";
import { validateMaintenanceTicketInput } from "@/modules/devices/types";
export async function POST(request: Request): Promise<Response> {
const auth = await requirePermission("device:manage", { action: "device.ticket.create", targetType: "maintenance_ticket" });
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后维护工单", 400);
}
const input = validateMaintenanceTicketInput(await readJsonBody(request));
if (!input.success) {
return jsonFailure(input.reason);
}
const ticket = await createMaintenanceTicket({ ...input.data, organizationId });
if (isDeviceMutationFailure(ticket)) {
return jsonFailure(ticket.reason, ticket.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId,
action: "device.ticket.create",
targetType: "maintenance_ticket",
targetId: ticket.id,
result: "success",
reason: `创建维修工单:${ticket.title}`,
});
return jsonSuccess("维修工单已创建", { ticket }, 201);
}

View File

@@ -0,0 +1,74 @@
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
import { deleteFamilyContact, isFamilyMutationFailure, updateFamilyContact } from "@/modules/family/server/operations";
import { validateFamilyContactInput } from "@/modules/family/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("family:manage", { action: "family.contact.update", targetType: "family_contact", targetId: id });
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后维护家属联系人", 400);
}
const input = validateFamilyContactInput(await readJsonBody(request));
if (!input.success) {
return jsonFailure(input.reason);
}
const contact = await updateFamilyContact({ ...input.data, id, organizationId });
if (isFamilyMutationFailure(contact)) {
return jsonFailure(contact.reason, contact.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId,
action: "family.contact.update",
targetType: "family_contact",
targetId: contact.id,
result: "success",
reason: `更新家属联系人:${contact.name}`,
});
return jsonSuccess("家属联系人已更新", { contact });
}
export async function DELETE(_request: Request, context: RouteContext): Promise<Response> {
const { id } = await context.params;
const auth = await requirePermission("family:manage", { action: "family.contact.delete", targetType: "family_contact", targetId: id });
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后维护家属联系人", 400);
}
const contact = await deleteFamilyContact({ id, organizationId });
if (isFamilyMutationFailure(contact)) {
return jsonFailure(contact.reason, contact.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId,
action: "family.contact.delete",
targetType: "family_contact",
targetId: contact.id,
result: "success",
reason: `删除家属联系人:${contact.name}`,
});
return jsonSuccess("家属联系人已删除", { contact });
}

View File

@@ -0,0 +1,54 @@
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
import { createFamilyContact, isFamilyMutationFailure, listFamilyServiceData } from "@/modules/family/server/operations";
import { validateFamilyContactInput } from "@/modules/family/types";
export async function GET(): Promise<Response> {
const auth = await requirePermission("family:read", { action: "family.contact.list", targetType: "family_contact" });
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后查看家属服务", 400);
}
const data = await listFamilyServiceData(organizationId);
return jsonSuccess("家属服务已加载", { data });
}
export async function POST(request: Request): Promise<Response> {
const auth = await requirePermission("family:manage", { action: "family.contact.create", targetType: "family_contact" });
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后维护家属联系人", 400);
}
const input = validateFamilyContactInput(await readJsonBody(request));
if (!input.success) {
return jsonFailure(input.reason);
}
const contact = await createFamilyContact({ ...input.data, organizationId });
if (isFamilyMutationFailure(contact)) {
return jsonFailure(contact.reason, contact.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId,
action: "family.contact.create",
targetType: "family_contact",
targetId: contact.id,
result: "success",
reason: `创建家属联系人:${contact.name}`,
});
return jsonSuccess("家属联系人已创建", { contact }, 201);
}

View File

@@ -0,0 +1,74 @@
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
import { deleteFamilyFeedback, isFamilyMutationFailure, updateFamilyFeedback } from "@/modules/family/server/operations";
import { validateFamilyFeedbackInput } from "@/modules/family/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("family:manage", { action: "family.feedback.update", targetType: "family_feedback", targetId: id });
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后维护家属反馈", 400);
}
const input = validateFamilyFeedbackInput(await readJsonBody(request));
if (!input.success) {
return jsonFailure(input.reason);
}
const feedback = await updateFamilyFeedback({ ...input.data, id, organizationId, accountId: auth.context.account.id });
if (isFamilyMutationFailure(feedback)) {
return jsonFailure(feedback.reason, feedback.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId,
action: "family.feedback.update",
targetType: "family_feedback",
targetId: feedback.id,
result: "success",
reason: `更新家属反馈:${feedback.elderName}`,
});
return jsonSuccess("家属反馈已更新", { feedback });
}
export async function DELETE(_request: Request, context: RouteContext): Promise<Response> {
const { id } = await context.params;
const auth = await requirePermission("family:manage", { action: "family.feedback.delete", targetType: "family_feedback", targetId: id });
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后维护家属反馈", 400);
}
const feedback = await deleteFamilyFeedback({ id, organizationId });
if (isFamilyMutationFailure(feedback)) {
return jsonFailure(feedback.reason, feedback.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId,
action: "family.feedback.delete",
targetType: "family_feedback",
targetId: feedback.id,
result: "success",
reason: `删除家属反馈:${feedback.elderName}`,
});
return jsonSuccess("家属反馈已删除", { feedback });
}

View File

@@ -0,0 +1,39 @@
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
import { createFamilyFeedback, isFamilyMutationFailure } from "@/modules/family/server/operations";
import { validateFamilyFeedbackInput } from "@/modules/family/types";
export async function POST(request: Request): Promise<Response> {
const auth = await requirePermission("family:manage", { action: "family.feedback.create", targetType: "family_feedback" });
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后维护家属反馈", 400);
}
const input = validateFamilyFeedbackInput(await readJsonBody(request));
if (!input.success) {
return jsonFailure(input.reason);
}
const feedback = await createFamilyFeedback({ ...input.data, organizationId, accountId: auth.context.account.id });
if (isFamilyMutationFailure(feedback)) {
return jsonFailure(feedback.reason, feedback.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId,
action: "family.feedback.create",
targetType: "family_feedback",
targetId: feedback.id,
result: "success",
reason: `创建家属反馈:${feedback.elderName}`,
});
return jsonSuccess("家属反馈已创建", { feedback }, 201);
}

View File

@@ -0,0 +1,74 @@
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
import { deleteFamilyVisit, isFamilyMutationFailure, updateFamilyVisit } from "@/modules/family/server/operations";
import { validateFamilyVisitInput } from "@/modules/family/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("family:manage", { action: "family.visit.update", targetType: "family_visit", targetId: id });
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后维护探访预约", 400);
}
const input = validateFamilyVisitInput(await readJsonBody(request));
if (!input.success) {
return jsonFailure(input.reason);
}
const visit = await updateFamilyVisit({ ...input.data, id, organizationId, accountId: auth.context.account.id });
if (isFamilyMutationFailure(visit)) {
return jsonFailure(visit.reason, visit.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId,
action: "family.visit.update",
targetType: "family_visit",
targetId: visit.id,
result: "success",
reason: `更新探访预约:${visit.elderName}`,
});
return jsonSuccess("探访预约已更新", { visit });
}
export async function DELETE(_request: Request, context: RouteContext): Promise<Response> {
const { id } = await context.params;
const auth = await requirePermission("family:manage", { action: "family.visit.delete", targetType: "family_visit", targetId: id });
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后维护探访预约", 400);
}
const visit = await deleteFamilyVisit({ id, organizationId });
if (isFamilyMutationFailure(visit)) {
return jsonFailure(visit.reason, visit.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId,
action: "family.visit.delete",
targetType: "family_visit",
targetId: visit.id,
result: "success",
reason: `删除探访预约:${visit.elderName}`,
});
return jsonSuccess("探访预约已删除", { visit });
}

View File

@@ -0,0 +1,39 @@
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
import { createFamilyVisit, isFamilyMutationFailure } from "@/modules/family/server/operations";
import { validateFamilyVisitInput } from "@/modules/family/types";
export async function POST(request: Request): Promise<Response> {
const auth = await requirePermission("family:manage", { action: "family.visit.create", targetType: "family_visit" });
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后维护探访预约", 400);
}
const input = validateFamilyVisitInput(await readJsonBody(request));
if (!input.success) {
return jsonFailure(input.reason);
}
const visit = await createFamilyVisit({ ...input.data, organizationId, accountId: auth.context.account.id });
if (isFamilyMutationFailure(visit)) {
return jsonFailure(visit.reason, visit.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId,
action: "family.visit.create",
targetType: "family_visit",
targetId: visit.id,
result: "success",
reason: `创建探访预约:${visit.elderName}`,
});
return jsonSuccess("探访预约已创建", { visit }, 201);
}

View File

@@ -0,0 +1,94 @@
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
import { deleteNotice, isNoticeMutationFailure, markNoticeRead, updateNotice } from "@/modules/notices/server/operations";
import { validateNoticeInput } from "@/modules/notices/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("notice:manage", { action: "notice.update", targetType: "notice", targetId: id });
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后维护公告", 400);
}
const input = validateNoticeInput(await readJsonBody(request));
if (!input.success) {
return jsonFailure(input.reason);
}
const notice = await updateNotice({ ...input.data, id, organizationId, accountId: auth.context.account.id });
if (isNoticeMutationFailure(notice)) {
return jsonFailure(notice.reason, notice.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId,
action: "notice.update",
targetType: "notice",
targetId: notice.id,
result: "success",
reason: `更新公告:${notice.title}`,
});
return jsonSuccess("公告已更新", { notice });
}
export async function POST(_request: Request, context: RouteContext): Promise<Response> {
const { id } = await context.params;
const auth = await requirePermission("notice:read", { action: "notice.read", targetType: "notice", targetId: id });
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后查看公告", 400);
}
const notice = await markNoticeRead({ id, organizationId, accountId: auth.context.account.id });
if (isNoticeMutationFailure(notice)) {
return jsonFailure(notice.reason, notice.status);
}
return jsonSuccess("公告已标记已读", { notice });
}
export async function DELETE(_request: Request, context: RouteContext): Promise<Response> {
const { id } = await context.params;
const auth = await requirePermission("notice:manage", { action: "notice.delete", targetType: "notice", targetId: id });
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后维护公告", 400);
}
const notice = await deleteNotice({ id, organizationId });
if (isNoticeMutationFailure(notice)) {
return jsonFailure(notice.reason, notice.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId,
action: "notice.delete",
targetType: "notice",
targetId: notice.id,
result: "success",
reason: `删除公告:${notice.title}`,
});
return jsonSuccess("公告已删除", { notice });
}

54
app/api/notices/route.ts Normal file
View File

@@ -0,0 +1,54 @@
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
import { createNotice, isNoticeMutationFailure, listNoticeCenterData } from "@/modules/notices/server/operations";
import { validateNoticeInput } from "@/modules/notices/types";
export async function GET(): Promise<Response> {
const auth = await requirePermission("notice:read", { action: "notice.list", targetType: "notice" });
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后查看公告", 400);
}
const data = await listNoticeCenterData(organizationId, auth.context.account.id);
return jsonSuccess("公告通知已加载", { data });
}
export async function POST(request: Request): Promise<Response> {
const auth = await requirePermission("notice:manage", { action: "notice.create", targetType: "notice" });
if (!auth.success) {
return auth.response;
}
const organizationId = auth.context.organization?.id;
if (!organizationId) {
return jsonFailure("请选择机构后维护公告", 400);
}
const input = validateNoticeInput(await readJsonBody(request));
if (!input.success) {
return jsonFailure(input.reason);
}
const notice = await createNotice({ ...input.data, organizationId, accountId: auth.context.account.id });
if (isNoticeMutationFailure(notice)) {
return jsonFailure(notice.reason, notice.status);
}
await recordAuditLog({
actor: auth.context.account,
organizationId,
action: "notice.create",
targetType: "notice",
targetId: notice.id,
result: "success",
reason: `创建公告:${notice.title}`,
});
return jsonSuccess("公告已创建", { notice }, 201);
}

View File

@@ -437,6 +437,8 @@ a,
inset: 0 !important;
height: fit-content;
margin: auto !important;
width: min(calc(100vw - 1.5rem), var(--dialog-width, 36rem)) !important;
max-width: calc(100vw - 1.5rem) !important;
min-width: 0 !important;
transform: none !important;
translate: 0 0 !important;

View File

@@ -7,6 +7,14 @@ import { Dialog as KumoDialog } from "@cloudflare/kumo/components/dialog";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const dialogWidthValues = {
sm: "28rem",
md: "36rem",
lg: "42rem",
xl: "48rem",
wide: "64rem",
} as const;
type DialogProps = {
open: boolean;
title: string;
@@ -15,11 +23,16 @@ type DialogProps = {
footer?: React.ReactNode;
onClose: () => void;
className?: string;
width?: keyof typeof dialogWidthValues;
};
const kumoBackdropClassNames =
"fixed inset-0 bg-kumo-recessed opacity-80 transition-all duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0";
type DialogStyle = React.CSSProperties & {
"--dialog-width": string;
};
export function Dialog({
open,
title,
@@ -28,7 +41,14 @@ export function Dialog({
footer,
onClose,
className,
width = "md",
}: DialogProps): React.ReactElement {
const dialogStyle: DialogStyle = {
"--dialog-width": dialogWidthValues[width],
transform: "none",
translate: "0 0",
};
return (
<KumoDialog.Root
onOpenChange={(nextOpen) => {
@@ -40,13 +60,13 @@ export function Dialog({
>
<KumoDialog
className={cn(
"teatea-dialog fixed inset-0 z-50 m-auto flex h-fit max-h-[min(calc(100svh-1.5rem),52rem)] w-[min(calc(100vw-1.5rem),42rem)] min-w-0 flex-col overflow-hidden rounded-lg border border-kumo-line bg-kumo-base p-0 shadow-xl outline-none ring-1 ring-kumo-line",
"teatea-dialog fixed inset-0 z-50 m-auto flex h-fit max-h-[min(calc(100svh-1.5rem),52rem)] w-[min(calc(100vw-1.5rem),var(--dialog-width))] min-w-0 flex-col overflow-hidden rounded-lg border border-kumo-line bg-kumo-base p-0 shadow-xl outline-none ring-1 ring-kumo-line",
"data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0",
className,
)}
data-kumo-backdrop-classes={kumoBackdropClassNames}
size="xl"
style={{ transform: "none", translate: "0 0" }}
style={dialogStyle}
>
<header className="flex shrink-0 items-start justify-between gap-4 border-b border-kumo-line bg-kumo-base p-5">
<div className="min-w-0">

View File

@@ -0,0 +1,170 @@
CREATE TYPE "public"."alert_rule_status" AS ENUM('enabled', 'disabled');--> statement-breakpoint
CREATE TYPE "public"."alert_rule_type" AS ENUM('health', 'care', 'device', 'incident', 'admission', 'other');--> statement-breakpoint
CREATE TYPE "public"."alert_trigger_status" AS ENUM('open', 'acknowledged', 'resolved', 'closed');--> statement-breakpoint
CREATE TYPE "public"."device_asset_status" AS ENUM('active', 'maintenance', 'disabled', 'retired');--> statement-breakpoint
CREATE TYPE "public"."family_contact_status" AS ENUM('active', 'suspended', 'archived');--> statement-breakpoint
CREATE TYPE "public"."family_feedback_status" AS ENUM('open', 'in_progress', 'resolved', 'closed');--> statement-breakpoint
CREATE TYPE "public"."family_feedback_type" AS ENUM('service', 'care', 'meal', 'environment', 'other');--> statement-breakpoint
CREATE TYPE "public"."family_visit_status" AS ENUM('requested', 'approved', 'completed', 'cancelled');--> statement-breakpoint
CREATE TYPE "public"."maintenance_ticket_priority" AS ENUM('low', 'normal', 'high', 'urgent');--> statement-breakpoint
CREATE TYPE "public"."maintenance_ticket_status" AS ENUM('open', 'assigned', 'resolved', 'closed', 'cancelled');--> statement-breakpoint
CREATE TYPE "public"."notice_status" AS ENUM('draft', 'published', 'retracted');--> statement-breakpoint
CREATE TABLE "alert_rules" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"name" text NOT NULL,
"rule_type" "alert_rule_type" DEFAULT 'other' NOT NULL,
"severity" "incident_severity" DEFAULT 'warning' NOT NULL,
"status" "alert_rule_status" DEFAULT 'enabled' NOT NULL,
"condition_summary" text DEFAULT '' NOT NULL,
"suggestion" text DEFAULT '' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "alert_triggers" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"rule_id" uuid,
"elder_id" uuid,
"title" text NOT NULL,
"description" text DEFAULT '' NOT NULL,
"status" "alert_trigger_status" DEFAULT 'open' NOT NULL,
"source" text DEFAULT '' NOT NULL,
"handled_by_account_id" uuid,
"handled_at" timestamp with time zone,
"handling_notes" text DEFAULT '' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "device_assets" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"name" text NOT NULL,
"code" text NOT NULL,
"category" text DEFAULT '' NOT NULL,
"location" text DEFAULT '' NOT NULL,
"status" "device_asset_status" DEFAULT 'active' NOT NULL,
"last_inspected_at" timestamp with time zone,
"notes" text DEFAULT '' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "family_contacts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"elder_id" uuid NOT NULL,
"name" text NOT NULL,
"relationship" text NOT NULL,
"phone" text NOT NULL,
"status" "family_contact_status" DEFAULT 'active' NOT NULL,
"notes" text DEFAULT '' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "family_feedback" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"elder_id" uuid NOT NULL,
"contact_id" uuid,
"feedback_type" "family_feedback_type" DEFAULT 'other' NOT NULL,
"content" text NOT NULL,
"status" "family_feedback_status" DEFAULT 'open' NOT NULL,
"response_notes" text DEFAULT '' NOT NULL,
"handled_by_account_id" uuid,
"handled_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "family_visit_appointments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"elder_id" uuid NOT NULL,
"contact_id" uuid,
"scheduled_at" timestamp with time zone NOT NULL,
"status" "family_visit_status" DEFAULT 'requested' NOT NULL,
"notes" text DEFAULT '' NOT NULL,
"handled_by_account_id" uuid,
"handled_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "maintenance_tickets" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"device_id" uuid,
"title" text NOT NULL,
"description" text DEFAULT '' NOT NULL,
"priority" "maintenance_ticket_priority" DEFAULT 'normal' NOT NULL,
"status" "maintenance_ticket_status" DEFAULT 'open' NOT NULL,
"assignee_label" text DEFAULT '' NOT NULL,
"resolution_notes" text DEFAULT '' NOT NULL,
"resolved_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "notice_read_receipts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"notice_id" uuid NOT NULL,
"account_id" uuid NOT NULL,
"read_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "notices" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"title" text NOT NULL,
"content" text DEFAULT '' NOT NULL,
"audience" text DEFAULT '全体员工' NOT NULL,
"status" "notice_status" DEFAULT 'draft' NOT NULL,
"published_at" timestamp with time zone,
"created_by_account_id" uuid,
"updated_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 "alert_rules" ADD CONSTRAINT "alert_rules_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "alert_triggers" ADD CONSTRAINT "alert_triggers_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "alert_triggers" ADD CONSTRAINT "alert_triggers_rule_id_alert_rules_id_fk" FOREIGN KEY ("rule_id") REFERENCES "public"."alert_rules"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "alert_triggers" ADD CONSTRAINT "alert_triggers_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 "alert_triggers" ADD CONSTRAINT "alert_triggers_handled_by_account_id_accounts_id_fk" FOREIGN KEY ("handled_by_account_id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "device_assets" ADD CONSTRAINT "device_assets_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "family_contacts" ADD CONSTRAINT "family_contacts_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "family_contacts" ADD CONSTRAINT "family_contacts_elder_id_elders_id_fk" FOREIGN KEY ("elder_id") REFERENCES "public"."elders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "family_feedback" ADD CONSTRAINT "family_feedback_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "family_feedback" ADD CONSTRAINT "family_feedback_elder_id_elders_id_fk" FOREIGN KEY ("elder_id") REFERENCES "public"."elders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "family_feedback" ADD CONSTRAINT "family_feedback_contact_id_family_contacts_id_fk" FOREIGN KEY ("contact_id") REFERENCES "public"."family_contacts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "family_feedback" ADD CONSTRAINT "family_feedback_handled_by_account_id_accounts_id_fk" FOREIGN KEY ("handled_by_account_id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "family_visit_appointments" ADD CONSTRAINT "family_visit_appointments_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "family_visit_appointments" ADD CONSTRAINT "family_visit_appointments_elder_id_elders_id_fk" FOREIGN KEY ("elder_id") REFERENCES "public"."elders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "family_visit_appointments" ADD CONSTRAINT "family_visit_appointments_contact_id_family_contacts_id_fk" FOREIGN KEY ("contact_id") REFERENCES "public"."family_contacts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "family_visit_appointments" ADD CONSTRAINT "family_visit_appointments_handled_by_account_id_accounts_id_fk" FOREIGN KEY ("handled_by_account_id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "maintenance_tickets" ADD CONSTRAINT "maintenance_tickets_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "maintenance_tickets" ADD CONSTRAINT "maintenance_tickets_device_id_device_assets_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."device_assets"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "notice_read_receipts" ADD CONSTRAINT "notice_read_receipts_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "notice_read_receipts" ADD CONSTRAINT "notice_read_receipts_notice_id_notices_id_fk" FOREIGN KEY ("notice_id") REFERENCES "public"."notices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "notice_read_receipts" ADD CONSTRAINT "notice_read_receipts_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "notices" ADD CONSTRAINT "notices_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "notices" ADD CONSTRAINT "notices_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 "notices" ADD CONSTRAINT "notices_updated_by_account_id_accounts_id_fk" FOREIGN KEY ("updated_by_account_id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "alert_rules_status_lookup" ON "alert_rules" USING btree ("organization_id","status","rule_type");--> statement-breakpoint
CREATE INDEX "alert_triggers_queue_lookup" ON "alert_triggers" USING btree ("organization_id","status");--> statement-breakpoint
CREATE INDEX "alert_triggers_rule_lookup" ON "alert_triggers" USING btree ("organization_id","rule_id");--> statement-breakpoint
CREATE UNIQUE INDEX "device_assets_code_organization_unique" ON "device_assets" USING btree ("organization_id","code");--> statement-breakpoint
CREATE INDEX "device_assets_status_lookup" ON "device_assets" USING btree ("organization_id","status");--> statement-breakpoint
CREATE INDEX "family_contacts_elder_lookup" ON "family_contacts" USING btree ("organization_id","elder_id","status");--> statement-breakpoint
CREATE INDEX "family_feedback_queue_lookup" ON "family_feedback" USING btree ("organization_id","status","feedback_type");--> statement-breakpoint
CREATE INDEX "family_visit_appointments_schedule_lookup" ON "family_visit_appointments" USING btree ("organization_id","status","scheduled_at");--> statement-breakpoint
CREATE INDEX "maintenance_tickets_queue_lookup" ON "maintenance_tickets" USING btree ("organization_id","status","priority");--> statement-breakpoint
CREATE INDEX "maintenance_tickets_device_lookup" ON "maintenance_tickets" USING btree ("organization_id","device_id");--> statement-breakpoint
CREATE UNIQUE INDEX "notice_read_receipts_account_notice_unique" ON "notice_read_receipts" USING btree ("notice_id","account_id");--> statement-breakpoint
CREATE INDEX "notice_read_receipts_notice_lookup" ON "notice_read_receipts" USING btree ("organization_id","notice_id");--> statement-breakpoint
CREATE INDEX "notices_status_lookup" ON "notices" USING btree ("organization_id","status");

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,13 @@
"when": 1783064001398,
"tag": "0004_silly_carnage",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1783071837853,
"tag": "0005_quiet_sandman",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,183 @@
"use client";
import { FormEvent, useMemo, useState } from "react";
import { Radio, Search, ShieldAlert, Siren } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, 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 { IncidentSeverity } from "@/modules/core/types";
import type { ApiResult } from "@/modules/core/server/api";
import { INCIDENT_SEVERITY_LABELS } from "@/modules/emergency/types";
import type { AlertCenterData, AlertRule, AlertRuleInput, AlertRuleStatus, AlertRuleType, AlertTrigger, AlertTriggerInput, AlertTriggerStatus } from "@/modules/alerts/types";
import { ALERT_RULE_STATUS_LABELS, ALERT_RULE_STATUS_VALUES, ALERT_RULE_TYPE_LABELS, ALERT_RULE_TYPE_VALUES, ALERT_TRIGGER_STATUS_LABELS, ALERT_TRIGGER_STATUS_VALUES } from "@/modules/alerts/types";
type Props = { canManage: boolean; initialData: AlertCenterData };
type DialogState =
| { kind: "rule"; mode: "create"; value: AlertRuleInput }
| { kind: "rule"; mode: "edit"; id: string; value: AlertRuleInput }
| { kind: "trigger"; mode: "create"; value: AlertTriggerInput }
| { kind: "trigger"; mode: "edit"; id: string; value: AlertTriggerInput };
const severityValues = ["info", "warning", "critical"] as const satisfies readonly IncidentSeverity[];
const emptyRule: AlertRuleInput = { name: "", ruleType: "health", severity: "warning", status: "enabled", conditionSummary: "", suggestion: "" };
const emptyTrigger: AlertTriggerInput = { ruleId: undefined, elderId: undefined, title: "", description: "", status: "open", source: "", handlingNotes: "" };
function formatDateTime(value: string | undefined): string {
return value ? new Date(value).toLocaleString("zh-CN") : "-";
}
function ruleToInput(rule: AlertRule): AlertRuleInput {
return { name: rule.name, ruleType: rule.ruleType, severity: rule.severity, status: rule.status, conditionSummary: rule.conditionSummary, suggestion: rule.suggestion };
}
function triggerToInput(trigger: AlertTrigger): AlertTriggerInput {
return { ruleId: trigger.ruleId, elderId: trigger.elderId, title: trigger.title, description: trigger.description, status: trigger.status, source: trigger.source, handlingNotes: trigger.handlingNotes };
}
function badgeVariant(value: string): "danger" | "secondary" | "success" | "warning" {
if (value === "critical") return "danger";
if (value === "enabled" || value === "resolved" || value === "closed") return "success";
if (value === "open" || value === "acknowledged" || value === "warning") return "warning";
return "secondary";
}
export function AlertsWorkspaceClient({ canManage, initialData }: Props): React.ReactElement {
const [data, setData] = useState(initialData);
const [query, setQuery] = useState("");
const [triggerStatus, setTriggerStatus] = useState<"all" | AlertTriggerStatus>("all");
const [dialog, setDialog] = useState<DialogState | undefined>();
const [message, setMessage] = useState("");
const [isPending, setIsPending] = useState(false);
const normalizedQuery = query.trim().toLowerCase();
const filteredRules = useMemo(() => data.rules.filter((rule) => !normalizedQuery || [rule.name, rule.conditionSummary, rule.suggestion].join(" ").toLowerCase().includes(normalizedQuery)), [data.rules, normalizedQuery]);
const filteredTriggers = useMemo(
() =>
data.triggers.filter((trigger) => {
const matchesQuery = !normalizedQuery || [trigger.title, trigger.description, trigger.ruleName, trigger.elderName, trigger.source].join(" ").toLowerCase().includes(normalizedQuery);
return matchesQuery && (triggerStatus === "all" || trigger.status === triggerStatus);
}),
[data.triggers, normalizedQuery, triggerStatus],
);
async function refreshData(): Promise<void> {
const response = await fetch("/api/alerts/rules");
const result = (await response.json()) as ApiResult<{ data: AlertCenterData }>;
if (result.success) setData(result.data);
}
async function submitDialog(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
if (!dialog || !canManage) {
setMessage("当前角色无权维护规则预警");
return;
}
const isCreate = dialog.mode === "create";
const path = dialog.kind === "rule" ? (isCreate ? "/api/alerts/rules" : `/api/alerts/rules/${dialog.id}`) : isCreate ? "/api/alerts/triggers" : `/api/alerts/triggers/${dialog.id}`;
setIsPending(true);
const response = await fetch(path, { method: isCreate ? "POST" : "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(dialog.value) });
const result = (await response.json()) as ApiResult<Record<string, unknown>>;
setIsPending(false);
setMessage(result.reason);
if (result.success) {
setDialog(undefined);
await refreshData();
}
}
async function remove(kind: "rule" | "trigger", id: string): Promise<void> {
if (!canManage) {
setMessage("当前角色无权删除规则预警记录");
return;
}
setIsPending(true);
const response = await fetch(kind === "rule" ? `/api/alerts/rules/${id}` : `/api/alerts/triggers/${id}`, { method: "DELETE" });
const result = (await response.json()) as ApiResult<Record<string, unknown>>;
setIsPending(false);
setMessage(result.reason);
if (result.success) await refreshData();
}
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard icon={Radio} label="启用规则" value={data.metrics.enabledRules} />
<MetricCard icon={ShieldAlert} label="待处理触发" value={data.metrics.openTriggers} />
<MetricCard icon={Siren} label="紧急触发" value={data.metrics.criticalTriggers} />
<MetricCard icon={Radio} label="已处理触发" value={data.metrics.resolvedTriggers} />
</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) => setTriggerStatus(value as "all" | AlertTriggerStatus)} options={[{ value: "all", label: "全部触发状态" }, ...ALERT_TRIGGER_STATUS_VALUES.map((value) => ({ value, label: ALERT_TRIGGER_STATUS_LABELS[value] }))]} value={triggerStatus} />
<Button disabled={!canManage} onClick={() => setDialog({ kind: "rule", mode: "create", value: emptyRule })} type="button"></Button>
<Button disabled={!canManage} onClick={() => setDialog({ kind: "trigger", mode: "create", value: emptyTrigger })} type="button" variant="outline"></Button>
</div>
</section>
{message ? <p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">{message}</p> : null}
<DataTable title="预警规则" empty="暂无预警规则" colSpan={6} heads={["规则", "类型", "级别", "状态", "更新时间", "操作"]}>
{filteredRules.map((rule) => (
<Table.Row key={rule.id}>
<Table.Cell className="px-4 py-3"><p className="font-medium">{rule.name}</p><p className="text-xs text-muted-foreground">{rule.conditionSummary || "-"}</p></Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{ALERT_RULE_TYPE_LABELS[rule.ruleType]}</Table.Cell>
<Table.Cell className="px-4 py-3"><Badge variant={badgeVariant(rule.severity)}>{INCIDENT_SEVERITY_LABELS[rule.severity]}</Badge></Table.Cell>
<Table.Cell className="px-4 py-3"><Badge variant={badgeVariant(rule.status)}>{ALERT_RULE_STATUS_LABELS[rule.status]}</Badge></Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDateTime(rule.updatedAt)}</Table.Cell>
<Table.Cell className="px-4 py-3"><RowActions disabled={!canManage || isPending} onDelete={() => remove("rule", rule.id)} onEdit={() => setDialog({ kind: "rule", mode: "edit", id: rule.id, value: ruleToInput(rule) })} /></Table.Cell>
</Table.Row>
))}
{filteredRules.length === 0 ? <EmptyRow colSpan={6} text="暂无预警规则" /> : null}
</DataTable>
<DataTable title="触发记录" empty="暂无触发记录" colSpan={7} heads={["记录", "规则", "老人", "状态", "来源", "更新时间", "操作"]}>
{filteredTriggers.map((trigger) => (
<Table.Row key={trigger.id}>
<Table.Cell className="px-4 py-3"><p className="font-medium">{trigger.title}</p><p className="text-xs text-muted-foreground">{trigger.description || trigger.handlingNotes || "-"}</p></Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{trigger.ruleName || "-"}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{trigger.elderName || "-"}</Table.Cell>
<Table.Cell className="px-4 py-3"><Badge variant={badgeVariant(trigger.status)}>{ALERT_TRIGGER_STATUS_LABELS[trigger.status]}</Badge></Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{trigger.source || "-"}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDateTime(trigger.updatedAt)}</Table.Cell>
<Table.Cell className="px-4 py-3"><RowActions disabled={!canManage || isPending} onDelete={() => remove("trigger", trigger.id)} onEdit={() => setDialog({ kind: "trigger", mode: "edit", id: trigger.id, value: triggerToInput(trigger) })} /></Table.Cell>
</Table.Row>
))}
{filteredTriggers.length === 0 ? <EmptyRow colSpan={7} text="暂无触发记录" /> : null}
</DataTable>
<Dialog onClose={() => setDialog(undefined)} open={dialog !== undefined} title={dialog?.kind === "rule" ? "预警规则" : "触发记录"} width="lg">
{dialog ? <form className="grid gap-3" onSubmit={submitDialog}>{dialog.kind === "rule" ? <RuleForm dialog={dialog} setDialog={setDialog} /> : <TriggerForm dialog={dialog} rules={data.rules} setDialog={setDialog} />}<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"><Button disabled={isPending} onClick={() => setDialog(undefined)} type="button" variant="outline"></Button><Button disabled={isPending || !canManage} type="submit"></Button></div></form> : null}
</Dialog>
</div>
);
}
function RuleForm({ dialog, setDialog }: { dialog: Extract<DialogState, { kind: "rule" }>; setDialog: (dialog: DialogState) => void }): React.ReactElement {
const value = dialog.value;
const update = (next: Partial<AlertRuleInput>) => setDialog({ ...dialog, value: { ...value, ...next } });
return <><Input label="规则名称" onChange={(event) => update({ name: event.target.value })} required value={value.name} /><Select aria-label="规则类型" onValueChange={(ruleType) => update({ ruleType: ruleType as AlertRuleType })} options={ALERT_RULE_TYPE_VALUES.map((item) => ({ value: item, label: ALERT_RULE_TYPE_LABELS[item] }))} value={value.ruleType} /><Select aria-label="级别" onValueChange={(severity) => update({ severity: severity as IncidentSeverity })} options={severityValues.map((item) => ({ value: item, label: INCIDENT_SEVERITY_LABELS[item] }))} value={value.severity} /><Select aria-label="状态" onValueChange={(status) => update({ status: status as AlertRuleStatus })} options={ALERT_RULE_STATUS_VALUES.map((item) => ({ value: item, label: ALERT_RULE_STATUS_LABELS[item] }))} value={value.status} /><Textarea label="条件摘要" onChange={(event) => update({ conditionSummary: event.target.value })} value={value.conditionSummary} /><Textarea label="处理建议" onChange={(event) => update({ suggestion: event.target.value })} value={value.suggestion} /></>;
}
function TriggerForm({ dialog, rules, setDialog }: { dialog: Extract<DialogState, { kind: "trigger" }>; rules: AlertRule[]; setDialog: (dialog: DialogState) => void }): React.ReactElement {
const value = dialog.value;
const update = (next: Partial<AlertTriggerInput>) => setDialog({ ...dialog, value: { ...value, ...next } });
return <><Select aria-label="关联规则" onValueChange={(ruleId) => update({ ruleId: ruleId || undefined })} options={[{ value: "", label: "不关联规则" }, ...rules.map((rule) => ({ value: rule.id, label: rule.name }))]} value={value.ruleId ?? ""} /><Input label="标题" onChange={(event) => update({ title: event.target.value })} required value={value.title} /><Textarea label="描述" onChange={(event) => update({ description: event.target.value })} value={value.description} /><Select aria-label="状态" onValueChange={(status) => update({ status: status as AlertTriggerStatus })} options={ALERT_TRIGGER_STATUS_VALUES.map((item) => ({ value: item, label: ALERT_TRIGGER_STATUS_LABELS[item] }))} value={value.status} /><Input label="来源" onChange={(event) => update({ source: event.target.value })} value={value.source} /><Textarea label="处理记录" onChange={(event) => update({ handlingNotes: event.target.value })} value={value.handlingNotes} /></>;
}
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><p className="text-sm text-muted-foreground">{label}</p><CardTitle className="mt-2 text-3xl">{value}</CardTitle></div><Icon className="size-5 text-primary" aria-hidden={true} /></CardHeader></Card>;
}
function DataTable({ children, heads, title }: { children: React.ReactNode; colSpan: number; empty: string; heads: string[]; title: string }): React.ReactElement {
return <Card><CardHeader><CardTitle>{title}</CardTitle></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>{heads.map((head) => <Table.Head className={head === "操作" ? "px-4 py-3 text-right" : "px-4 py-3"} key={head}>{head}</Table.Head>)}</Table.Row></Table.Header><Table.Body className="divide-y bg-card">{children}</Table.Body></Table></CardContent></Card>;
}
function EmptyRow({ colSpan, text }: { colSpan: number; text: string }): React.ReactElement {
return <Table.Row><Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={colSpan}>{text}</Table.Cell></Table.Row>;
}
function RowActions({ disabled, onDelete, onEdit }: { disabled: boolean; onDelete: () => void; onEdit: () => void }): React.ReactElement {
return <div className="flex justify-end gap-2"><Button disabled={disabled} onClick={onEdit} size="sm" type="button" variant="outline"></Button><Button disabled={disabled} onClick={onDelete} size="sm" type="button" variant="outline"></Button></div>;
}

View File

@@ -0,0 +1,242 @@
import { and, desc, eq, inArray } from "drizzle-orm";
import { getDatabase } from "@/modules/core/server/db";
import { accounts, alertRules, alertTriggers, elders } from "@/modules/core/server/schema";
import type { AlertCenterData, AlertRule, AlertRuleInput, AlertTrigger, AlertTriggerInput } from "@/modules/alerts/types";
export type AlertMutationFailure = {
success: false;
reason: string;
status: number;
};
export function isAlertMutationFailure(value: unknown): value is AlertMutationFailure {
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 toAlertRule(row: typeof alertRules.$inferSelect): AlertRule {
return {
id: row.id,
organizationId: row.organizationId,
name: row.name,
ruleType: row.ruleType,
severity: row.severity,
status: row.status,
conditionSummary: row.conditionSummary,
suggestion: row.suggestion,
createdAt: iso(row.createdAt),
updatedAt: iso(row.updatedAt),
};
}
function toAlertTrigger(
row: typeof alertTriggers.$inferSelect,
ruleNameById: Map<string, string>,
elderNameById: Map<string, string>,
accountNameById: Map<string, string>,
): AlertTrigger {
const ruleId = row.ruleId ?? undefined;
const elderId = row.elderId ?? undefined;
const handledByAccountId = row.handledByAccountId ?? undefined;
return {
id: row.id,
organizationId: row.organizationId,
ruleId,
ruleName: ruleId ? ruleNameById.get(ruleId) ?? "" : "",
elderId,
elderName: elderId ? elderNameById.get(elderId) ?? "" : "",
title: row.title,
description: row.description,
status: row.status,
source: row.source,
handledByAccountId,
handledByName: handledByAccountId ? accountNameById.get(handledByAccountId) : undefined,
handledAt: optionalIso(row.handledAt),
handlingNotes: row.handlingNotes,
createdAt: iso(row.createdAt),
updatedAt: iso(row.updatedAt),
};
}
async function validateRuleAndElder(input: {
elderId?: string;
organizationId: string;
ruleId?: string;
}): Promise<{
elderNameById: Map<string, string>;
ruleNameById: Map<string, string>;
} | AlertMutationFailure> {
const database = getDatabase();
const [ruleRows, elderRows] = await Promise.all([
input.ruleId
? database.select({ id: alertRules.id, name: alertRules.name }).from(alertRules).where(and(eq(alertRules.id, input.ruleId), eq(alertRules.organizationId, input.organizationId)))
: [],
input.elderId
? database.select({ id: elders.id, name: elders.name }).from(elders).where(and(eq(elders.id, input.elderId), eq(elders.organizationId, input.organizationId)))
: [],
]);
if (input.ruleId && !ruleRows[0]) {
return { success: false, reason: "预警规则不存在", status: 404 };
}
if (input.elderId && !elderRows[0]) {
return { success: false, reason: "老人档案不存在", status: 404 };
}
return {
ruleNameById: new Map(ruleRows.map((rule) => [rule.id, rule.name])),
elderNameById: new Map(elderRows.map((elder) => [elder.id, elder.name])),
};
}
export async function listAlertCenterData(organizationId: string): Promise<AlertCenterData> {
const database = getDatabase();
const [ruleRows, triggerRows] = await Promise.all([
database.select().from(alertRules).where(eq(alertRules.organizationId, organizationId)).orderBy(desc(alertRules.updatedAt)),
database.select().from(alertTriggers).where(eq(alertTriggers.organizationId, organizationId)).orderBy(desc(alertTriggers.updatedAt)),
]);
const ruleIds = Array.from(new Set(triggerRows.map((trigger) => trigger.ruleId).filter((id): id is string => typeof id === "string")));
const elderIds = Array.from(new Set(triggerRows.map((trigger) => trigger.elderId).filter((id): id is string => typeof id === "string")));
const accountIds = Array.from(new Set(triggerRows.map((trigger) => trigger.handledByAccountId).filter((id): id is string => typeof id === "string")));
const [triggerRuleRows, elderRows, accountRows] = await Promise.all([
ruleIds.length > 0 ? database.select({ id: alertRules.id, name: alertRules.name }).from(alertRules).where(inArray(alertRules.id, ruleIds)) : [],
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 ruleNameById = new Map([...ruleRows.map((rule) => [rule.id, rule.name] as const), ...triggerRuleRows.map((rule) => [rule.id, rule.name] as const)]);
return {
metrics: {
enabledRules: ruleRows.filter((rule) => rule.status === "enabled").length,
openTriggers: triggerRows.filter((trigger) => trigger.status === "open" || trigger.status === "acknowledged").length,
criticalTriggers: triggerRows.filter((trigger) => {
const ruleId = trigger.ruleId;
const rule = ruleId ? ruleRows.find((item) => item.id === ruleId) : undefined;
return rule?.severity === "critical";
}).length,
resolvedTriggers: triggerRows.filter((trigger) => trigger.status === "resolved" || trigger.status === "closed").length,
},
rules: ruleRows.map(toAlertRule),
triggers: triggerRows.map((trigger) =>
toAlertTrigger(
trigger,
ruleNameById,
new Map(elderRows.map((elder) => [elder.id, elder.name])),
new Map(accountRows.map((account) => [account.id, account.name])),
),
),
};
}
export async function createAlertRule(input: AlertRuleInput & { organizationId: string }): Promise<AlertRule | AlertMutationFailure> {
const database = getDatabase();
const rows = await database.insert(alertRules).values({ ...input }).returning();
const rule = rows[0];
return rule ? toAlertRule(rule) : { success: false, reason: "预警规则创建失败", status: 500 };
}
export async function updateAlertRule(input: AlertRuleInput & { id: string; organizationId: string }): Promise<AlertRule | AlertMutationFailure> {
const database = getDatabase();
const rows = await database
.update(alertRules)
.set({
name: input.name,
ruleType: input.ruleType,
severity: input.severity,
status: input.status,
conditionSummary: input.conditionSummary,
suggestion: input.suggestion,
updatedAt: new Date(),
})
.where(and(eq(alertRules.id, input.id), eq(alertRules.organizationId, input.organizationId)))
.returning();
const rule = rows[0];
return rule ? toAlertRule(rule) : { success: false, reason: "预警规则不存在", status: 404 };
}
export async function deleteAlertRule(input: { id: string; organizationId: string }): Promise<AlertRule | AlertMutationFailure> {
const database = getDatabase();
const rows = await database.delete(alertRules).where(and(eq(alertRules.id, input.id), eq(alertRules.organizationId, input.organizationId))).returning();
const rule = rows[0];
return rule ? toAlertRule(rule) : { success: false, reason: "预警规则不存在", status: 404 };
}
export async function createAlertTrigger(input: AlertTriggerInput & { accountId: string; organizationId: string }): Promise<AlertTrigger | AlertMutationFailure> {
const maps = await validateRuleAndElder(input);
if (isAlertMutationFailure(maps)) {
return maps;
}
const database = getDatabase();
const now = new Date();
const handledAt = input.status === "resolved" || input.status === "closed" || input.status === "acknowledged" ? now : undefined;
const rows = await database
.insert(alertTriggers)
.values({
organizationId: input.organizationId,
ruleId: input.ruleId,
elderId: input.elderId,
title: input.title,
description: input.description,
status: input.status,
source: input.source,
handledByAccountId: handledAt ? input.accountId : undefined,
handledAt,
handlingNotes: input.handlingNotes,
})
.returning();
const trigger = rows[0];
return trigger
? toAlertTrigger(trigger, maps.ruleNameById, maps.elderNameById, new Map())
: { success: false, reason: "预警触发记录创建失败", status: 500 };
}
export async function updateAlertTrigger(input: AlertTriggerInput & { accountId: string; id: string; organizationId: string }): Promise<AlertTrigger | AlertMutationFailure> {
const maps = await validateRuleAndElder(input);
if (isAlertMutationFailure(maps)) {
return maps;
}
const database = getDatabase();
const now = new Date();
const handledAt = input.status === "resolved" || input.status === "closed" || input.status === "acknowledged" ? now : null;
const rows = await database
.update(alertTriggers)
.set({
ruleId: input.ruleId ?? null,
elderId: input.elderId ?? null,
title: input.title,
description: input.description,
status: input.status,
source: input.source,
handledByAccountId: handledAt ? input.accountId : null,
handledAt,
handlingNotes: input.handlingNotes,
updatedAt: now,
})
.where(and(eq(alertTriggers.id, input.id), eq(alertTriggers.organizationId, input.organizationId)))
.returning();
const trigger = rows[0];
return trigger
? toAlertTrigger(trigger, maps.ruleNameById, maps.elderNameById, new Map([[input.accountId, ""]]))
: { success: false, reason: "预警触发记录不存在", status: 404 };
}
export async function deleteAlertTrigger(input: { id: string; organizationId: string }): Promise<AlertTrigger | AlertMutationFailure> {
const database = getDatabase();
const rows = await database.delete(alertTriggers).where(and(eq(alertTriggers.id, input.id), eq(alertTriggers.organizationId, input.organizationId))).returning();
const trigger = rows[0];
return trigger ? toAlertTrigger(trigger, new Map(), new Map(), new Map()) : { success: false, reason: "预警触发记录不存在", status: 404 };
}

184
modules/alerts/types.ts Normal file
View File

@@ -0,0 +1,184 @@
import type { IncidentSeverity } from "@/modules/core/types";
export const ALERT_RULE_TYPE_VALUES = ["health", "care", "device", "incident", "admission", "other"] as const;
export type AlertRuleType = (typeof ALERT_RULE_TYPE_VALUES)[number];
export const ALERT_RULE_STATUS_VALUES = ["enabled", "disabled"] as const;
export type AlertRuleStatus = (typeof ALERT_RULE_STATUS_VALUES)[number];
export const ALERT_TRIGGER_STATUS_VALUES = ["open", "acknowledged", "resolved", "closed"] as const;
export type AlertTriggerStatus = (typeof ALERT_TRIGGER_STATUS_VALUES)[number];
export const ALERT_RULE_TYPE_LABELS: Record<AlertRuleType, string> = {
health: "健康",
care: "护理",
device: "设备",
incident: "应急",
admission: "入住",
other: "其他",
};
export const ALERT_RULE_STATUS_LABELS: Record<AlertRuleStatus, string> = {
enabled: "启用",
disabled: "停用",
};
export const ALERT_TRIGGER_STATUS_LABELS: Record<AlertTriggerStatus, string> = {
open: "待处理",
acknowledged: "已确认",
resolved: "已解决",
closed: "已关闭",
};
export type AlertRule = {
id: string;
organizationId: string;
name: string;
ruleType: AlertRuleType;
severity: IncidentSeverity;
status: AlertRuleStatus;
conditionSummary: string;
suggestion: string;
createdAt: string;
updatedAt: string;
};
export type AlertTrigger = {
id: string;
organizationId: string;
ruleId?: string;
ruleName: string;
elderId?: string;
elderName: string;
title: string;
description: string;
status: AlertTriggerStatus;
source: string;
handledByAccountId?: string;
handledByName?: string;
handledAt?: string;
handlingNotes: string;
createdAt: string;
updatedAt: string;
};
export type AlertCenterData = {
metrics: {
enabledRules: number;
openTriggers: number;
criticalTriggers: number;
resolvedTriggers: number;
};
rules: AlertRule[];
triggers: AlertTrigger[];
};
export type AlertRuleInput = {
name: string;
ruleType: AlertRuleType;
severity: IncidentSeverity;
status: AlertRuleStatus;
conditionSummary: string;
suggestion: string;
};
export type AlertTriggerInput = {
ruleId?: string;
elderId?: string;
title: string;
description: string;
status: AlertTriggerStatus;
source: string;
handlingNotes: string;
};
type ValidationSuccess<T> = {
success: true;
data: T;
};
type ValidationFailure = {
success: false;
reason: string;
};
export type ValidationResult<T> = ValidationSuccess<T> | ValidationFailure;
const INCIDENT_SEVERITY_VALUES = ["info", "warning", "critical"] as const satisfies readonly IncidentSeverity[];
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 readOptionalString(source: Record<string, unknown>, key: string): string | undefined {
const value = readString(source, key);
return value || undefined;
}
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 validateAlertRuleInput(value: unknown): ValidationResult<AlertRuleInput> {
if (!isRecord(value)) {
return { success: false, reason: "请求数据格式无效" };
}
const name = readString(value, "name");
if (!name) {
return { success: false, reason: "规则名称不能为空" };
}
const ruleType = readEnum(value.ruleType, ALERT_RULE_TYPE_VALUES);
const severity = readEnum(value.severity, INCIDENT_SEVERITY_VALUES);
const status = readEnum(value.status, ALERT_RULE_STATUS_VALUES);
if (!ruleType || !severity || !status) {
return { success: false, reason: "规则类型、级别或状态无效" };
}
return {
success: true,
data: {
name,
ruleType,
severity,
status,
conditionSummary: readString(value, "conditionSummary"),
suggestion: readString(value, "suggestion"),
},
};
}
export function validateAlertTriggerInput(value: unknown): ValidationResult<AlertTriggerInput> {
if (!isRecord(value)) {
return { success: false, reason: "请求数据格式无效" };
}
const title = readString(value, "title");
if (!title) {
return { success: false, reason: "触发记录标题不能为空" };
}
const status = readEnum(value.status, ALERT_TRIGGER_STATUS_VALUES);
if (!status) {
return { success: false, reason: "触发记录状态无效" };
}
return {
success: true,
data: {
ruleId: readOptionalString(value, "ruleId"),
elderId: readOptionalString(value, "elderId"),
title,
description: readString(value, "description"),
status,
source: readString(value, "source"),
handlingNotes: readString(value, "handlingNotes"),
},
};
}

View File

@@ -247,7 +247,7 @@ export function CareWorkspaceClient({ canManage, initialData }: CareWorkspaceCli
</CardContent>
</Card>
<Dialog className="max-w-lg" description={completeTarget?.title} onClose={closeCompleteDialog} open={completeTarget !== undefined} title="完成护理任务">
<Dialog 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">

View File

@@ -3,15 +3,23 @@ import { eq } from "drizzle-orm";
import { getDatabase } from "@/modules/core/server/db";
import {
admissions,
alertRules,
alertTriggers,
beds,
buildings,
campuses,
careTasks,
chronicConditions,
deviceAssets,
elders,
familyContacts,
familyFeedback,
familyVisitAppointments,
floors,
healthAnomalyReviews,
healthProfiles,
maintenanceTickets,
notices,
rooms,
systemIncidents,
vitalRecords,
@@ -105,15 +113,107 @@ type SeedCareTask = {
title: string;
};
type SeedSystemIncident = {
acknowledgedHoursAgo?: number;
createdHoursAgo: number;
description: string;
resolvedHoursAgo?: number;
severity: "info" | "warning" | "critical";
source: string;
status: "open" | "acknowledged" | "resolved" | "closed";
title: string;
};
type SeedDeviceAsset = {
category: string;
code: string;
lastInspectedHoursAgo?: number;
location: string;
name: string;
notes: string;
status: "active" | "maintenance" | "disabled" | "retired";
};
type SeedMaintenanceTicket = {
assigneeLabel: string;
description: string;
deviceCode: string;
priority: "low" | "normal" | "high" | "urgent";
resolutionNotes: string;
status: "open" | "assigned" | "resolved" | "closed" | "cancelled";
title: string;
};
type SeedNotice = {
audience: string;
content: string;
publishedHoursAgo?: number;
status: "draft" | "published" | "retracted";
title: string;
};
type SeedAlertRule = {
conditionSummary: string;
name: string;
ruleType: "health" | "care" | "device" | "incident" | "admission" | "other";
severity: "info" | "warning" | "critical";
status: "enabled" | "disabled";
suggestion: string;
};
type SeedAlertTrigger = {
createdHoursAgo: number;
description: string;
elderName?: string;
handlingNotes: string;
ruleName: string;
source: string;
status: "open" | "acknowledged" | "resolved" | "closed";
title: string;
};
type SeedFamilyContact = {
elderName: string;
name: string;
notes: string;
phone: string;
relationship: string;
status: "active" | "suspended" | "archived";
};
type SeedFamilyVisit = {
contactName: string;
elderName: string;
notes: string;
scheduledHoursOffset: number;
status: "requested" | "approved" | "completed" | "cancelled";
};
type SeedFamilyFeedback = {
contactName: string;
content: string;
elderName: string;
feedbackType: "service" | "care" | "meal" | "environment" | "other";
responseNotes: string;
status: "open" | "in_progress" | "resolved" | "closed";
};
const DEFAULT_ROOMS: SeedRoom[] = [
{ campusName: "主院区", buildingName: "护理楼", floorName: "一层", floorLevel: 1, code: "A101", name: "阳光房", capacity: 2 },
{ campusName: "主院区", buildingName: "护理楼", floorName: "一层", floorLevel: 1, code: "A102", name: "康养房", capacity: 2 },
{ campusName: "主院区", buildingName: "护理楼", floorName: "二层", floorLevel: 2, code: "A201", name: "记忆照护房", capacity: 2 },
{ campusName: "主院区", buildingName: "护理楼", floorName: "二层", floorLevel: 2, code: "A202", name: "重点照护房", capacity: 2 },
{ campusName: "主院区", buildingName: "护理楼", floorName: "三层", floorLevel: 3, code: "A301", name: "失能照护房", capacity: 2 },
{ campusName: "主院区", buildingName: "护理楼", floorName: "三层", floorLevel: 3, code: "A302", name: "安宁照护房", capacity: 2 },
{ campusName: "主院区", buildingName: "康复楼", floorName: "一层", floorLevel: 1, code: "R101", name: "康复观察房", capacity: 2 },
{ campusName: "主院区", buildingName: "康复楼", floorName: "一层", floorLevel: 1, code: "R102", name: "短住评估房", capacity: 2 },
{ campusName: "主院区", buildingName: "康复楼", floorName: "二层", floorLevel: 2, code: "R201", name: "术后康复房", capacity: 2 },
{ campusName: "主院区", buildingName: "康复楼", floorName: "二层", floorLevel: 2, code: "R202", name: "认知训练房", capacity: 2 },
{ campusName: "北苑院区", buildingName: "颐养楼", floorName: "一层", floorLevel: 1, code: "B101", name: "自理房", capacity: 2 },
{ campusName: "北苑院区", buildingName: "颐养楼", floorName: "二层", floorLevel: 2, code: "B201", name: "亲情套房", capacity: 2 },
{ campusName: "北苑院区", buildingName: "颐养楼", floorName: "二层", floorLevel: 2, code: "B202", name: "陪护套房", capacity: 2 },
{ campusName: "南苑院区", buildingName: "医养楼", floorName: "一层", floorLevel: 1, code: "S101", name: "医养观察房", capacity: 2 },
{ campusName: "南苑院区", buildingName: "医养楼", floorName: "一层", floorLevel: 1, code: "S102", name: "短期托养房", capacity: 2 },
];
const DEFAULT_BEDS: SeedBed[] = [
@@ -125,14 +225,28 @@ const DEFAULT_BEDS: SeedBed[] = [
{ code: "A201-2", roomCode: "A201", status: "available", notes: "靠近护理站" },
{ code: "A202-1", roomCode: "A202", status: "occupied", notes: "压疮风险重点巡查" },
{ code: "A202-2", roomCode: "A202", status: "disabled", notes: "待更换护栏" },
{ code: "A301-1", roomCode: "A301", status: "occupied", notes: "失能照护,配置防跌倒垫" },
{ code: "A301-2", roomCode: "A301", status: "available", notes: "鼻饲护理设备已消毒待用" },
{ code: "A302-1", roomCode: "A302", status: "available", notes: "家属陪伴预约优先" },
{ code: "A302-2", roomCode: "A302", status: "maintenance", notes: "氧气接口保养" },
{ code: "R101-1", roomCode: "R101", status: "occupied", notes: "康复训练后休息床位" },
{ code: "R101-2", roomCode: "R101", status: "available", notes: "临时观察床位" },
{ code: "R102-1", roomCode: "R102", status: "available", notes: "短住评估床位" },
{ code: "R102-2", roomCode: "R102", status: "available", notes: "短住评估床位" },
{ code: "R201-1", roomCode: "R201", status: "occupied", notes: "术后康复第 2 周" },
{ code: "R201-2", roomCode: "R201", status: "available", notes: "康复床位候补" },
{ code: "R202-1", roomCode: "R202", status: "occupied", notes: "认知训练老人长期床位" },
{ code: "R202-2", roomCode: "R202", status: "available", notes: "靠近活动室" },
{ code: "B101-1", roomCode: "B101", status: "occupied", notes: "自理老人长期床位" },
{ code: "B101-2", roomCode: "B101", status: "available", notes: "低照护需求床位" },
{ code: "B201-1", roomCode: "B201", status: "occupied", notes: "亲属探访便利" },
{ code: "B201-1", roomCode: "B201", status: "available", notes: "亲属探访便利" },
{ code: "B201-2", roomCode: "B201", status: "available", notes: "亲情套房备用床" },
{ code: "B202-1", roomCode: "B202", status: "occupied", notes: "陪护套房,家属每周探访" },
{ code: "B202-2", roomCode: "B202", status: "available", notes: "可接收短住老人" },
{ code: "S101-1", roomCode: "S101", status: "occupied", notes: "医养观察,夜间吸氧" },
{ code: "S101-2", roomCode: "S101", status: "disabled", notes: "待更换床垫" },
{ code: "S102-1", roomCode: "S102", status: "available", notes: "短期托养床位" },
{ code: "S102-2", roomCode: "S102", status: "available", notes: "短期托养床位" },
];
const DEFAULT_ELDERS: SeedElder[] = [
@@ -236,6 +350,86 @@ const DEFAULT_ELDERS: SeedElder[] = [
phone: "13800000010",
medicalNotes: "短住康复结束后退住。",
},
{
name: "马淑芬",
gender: "female",
age: 86,
careLevel: "intensive",
status: "active",
primaryContact: "马晨",
phone: "13800000011",
medicalNotes: "吞咽困难,晚餐需软食并观察呛咳。",
},
{
name: "胡志强",
gender: "male",
age: 80,
careLevel: "assisted",
status: "active",
primaryContact: "胡悦",
phone: "13800000012",
medicalNotes: "术后康复,需记录每日步行距离。",
},
{
name: "高兰英",
gender: "female",
age: 77,
careLevel: "semi-assisted",
status: "active",
primaryContact: "高磊",
phone: "13800000013",
medicalNotes: "轻度认知障碍,活动后需陪同返回房间。",
},
{
name: "钱松柏",
gender: "male",
age: 89,
careLevel: "intensive",
status: "active",
primaryContact: "钱宁",
phone: "13800000014",
medicalNotes: "慢阻肺,夜间吸氧,重点关注血氧。",
},
{
name: "林玉琴",
gender: "female",
age: 71,
careLevel: "self-care",
status: "active",
primaryContact: "林涛",
phone: "13800000015",
medicalNotes: "短住托养,需协助完成初次健康档案。",
},
{
name: "曹文海",
gender: "male",
age: 83,
careLevel: "assisted",
status: "pending",
primaryContact: "曹颖",
phone: "13800000016",
medicalNotes: "已预约下周入住,等待家属确认床位。",
},
{
name: "罗海燕",
gender: "female",
age: 75,
careLevel: "semi-assisted",
status: "pending",
primaryContact: "罗杰",
phone: "13800000017",
medicalNotes: "需补充近期体检报告。",
},
{
name: "邓启明",
gender: "male",
age: 70,
careLevel: "self-care",
status: "discharged",
primaryContact: "邓可",
phone: "13800000018",
medicalNotes: "试住结束后转家庭照护。",
},
];
const DEFAULT_ADMISSIONS: SeedAdmission[] = [
@@ -245,7 +439,13 @@ const DEFAULT_ADMISSIONS: SeedAdmission[] = [
{ elderName: "赵国华", bedCode: "A202-1", status: "active", notes: "重点照护入住" },
{ elderName: "孙秀梅", bedCode: "R101-1", status: "active", notes: "康复观察入住" },
{ elderName: "刘长青", bedCode: "B101-1", status: "active", notes: "自理区入住" },
{ elderName: "马淑芬", bedCode: "A301-1", status: "active", notes: "失能照护入住" },
{ elderName: "胡志强", bedCode: "R201-1", status: "active", notes: "术后康复入住" },
{ elderName: "高兰英", bedCode: "R202-1", status: "active", notes: "认知训练入住" },
{ elderName: "钱松柏", bedCode: "S101-1", status: "active", notes: "医养观察入住" },
{ elderName: "林玉琴", bedCode: "B202-1", status: "active", notes: "短住托养入住" },
{ elderName: "吴佩兰", bedCode: "B201-1", status: "discharged", notes: "历史退住记录" },
{ elderName: "邓启明", bedCode: "A301-2", status: "discharged", notes: "试住退住记录" },
];
const DEFAULT_HEALTH_PROFILES: SeedHealthProfile[] = [
@@ -281,6 +481,46 @@ const DEFAULT_HEALTH_PROFILES: SeedHealthProfile[] = [
careRestrictions: "避免爬楼和长时间站立。",
emergencyNotes: "胸痛需立即报告。",
},
{
elderName: "马淑芬",
allergyNotes: "头孢类药物过敏",
medicalHistory: "脑梗后吞咽功能下降,近期体重下降。",
medicationNotes: "晚间营养补充剂,服药后观察吞咽。",
careRestrictions: "软食,进食时保持坐位,餐后半小时内不平卧。",
emergencyNotes: "呛咳、紫绀或呼吸急促时立即通知医生。",
},
{
elderName: "胡志强",
allergyNotes: "",
medicalHistory: "髋关节术后康复中。",
medicationNotes: "按医嘱服用止痛药和抗凝药。",
careRestrictions: "转移位需双人协助,禁止独自上下楼。",
emergencyNotes: "伤口渗血或疼痛加重时联系康复医生。",
},
{
elderName: "高兰英",
allergyNotes: "无明确药物过敏史",
medicalHistory: "轻度认知障碍,夜间偶发迷路。",
medicationNotes: "睡前药物由护理员核对。",
careRestrictions: "外出活动需佩戴定位牌。",
emergencyNotes: "离开活动区超过 10 分钟未返回时启动寻人流程。",
},
{
elderName: "钱松柏",
allergyNotes: "",
medicalHistory: "慢阻肺,长期吸入治疗。",
medicationNotes: "吸入剂每日两次,夜间低流量吸氧。",
careRestrictions: "避免受凉和烟味刺激。",
emergencyNotes: "血氧低于 90 或呼吸困难时立即启动应急流程。",
},
{
elderName: "林玉琴",
allergyNotes: "无",
medicalHistory: "短住托养,既往骨质疏松。",
medicationNotes: "钙剂和维生素 D 每日提醒。",
careRestrictions: "浴室需防滑协助。",
emergencyNotes: "跌倒后即刻评估并通知家属。",
},
];
const DEFAULT_VITAL_RECORDS: SeedVitalRecord[] = [
@@ -331,6 +571,90 @@ const DEFAULT_VITAL_RECORDS: SeedVitalRecord[] = [
spo2: 91,
notes: "体温和血氧异常,需复核。",
},
{
elderName: "马淑芬",
recordedHoursAgo: 3,
source: "manual",
systolicBp: 142,
diastolicBp: 86,
heartRate: 96,
temperatureTenths: 369,
spo2: 94,
bloodGlucoseTenths: 73,
weightTenths: 486,
notes: "晚餐前复测,吞咽情况稳定。",
},
{
elderName: "胡志强",
recordedHoursAgo: 9,
source: "device",
systolicBp: 124,
diastolicBp: 76,
heartRate: 82,
temperatureTenths: 364,
spo2: 98,
weightTenths: 705,
notes: "康复训练后体征平稳。",
},
{
elderName: "高兰英",
recordedHoursAgo: 15,
source: "manual",
systolicBp: 136,
diastolicBp: 84,
heartRate: 78,
temperatureTenths: 366,
spo2: 97,
bloodGlucoseTenths: 65,
notes: "夜间巡护记录,情绪平稳。",
},
{
elderName: "钱松柏",
recordedHoursAgo: 1,
source: "device",
systolicBp: 150,
diastolicBp: 88,
heartRate: 104,
temperatureTenths: 372,
spo2: 89,
notes: "夜间血氧低,已安排吸氧复核。",
},
{
elderName: "林玉琴",
recordedHoursAgo: 22,
source: "import",
systolicBp: 118,
diastolicBp: 72,
heartRate: 70,
temperatureTenths: 363,
spo2: 99,
bloodGlucoseTenths: 58,
weightTenths: 552,
notes: "入院体检导入记录。",
},
{
elderName: "王桂兰",
recordedHoursAgo: 26,
source: "device",
systolicBp: 145,
diastolicBp: 90,
heartRate: 84,
temperatureTenths: 366,
spo2: 97,
notes: "前一日夜间复测,血压较高。",
},
{
elderName: "周玉珍",
recordedHoursAgo: 30,
source: "manual",
systolicBp: 130,
diastolicBp: 80,
heartRate: 74,
temperatureTenths: 365,
spo2: 98,
bloodGlucoseTenths: 92,
notes: "晨间血糖记录。",
},
];
const DEFAULT_CHRONIC_CONDITIONS: SeedChronicCondition[] = [
@@ -355,6 +679,48 @@ const DEFAULT_CHRONIC_CONDITIONS: SeedChronicCondition[] = [
treatmentNotes: "按医嘱服药,避免劳累。",
followUpNotes: "月度复诊提醒。",
},
{
elderName: "马淑芬",
name: "吞咽功能障碍",
status: "active",
treatmentNotes: "软食和吞咽训练,餐中观察呛咳。",
followUpNotes: "每三天评估进食量和体重。",
},
{
elderName: "胡志强",
name: "髋关节术后康复",
status: "active",
treatmentNotes: "每日步态训练,逐步增加步行距离。",
followUpNotes: "康复师每周复评。",
},
{
elderName: "高兰英",
name: "轻度认知障碍",
status: "controlled",
treatmentNotes: "认知训练和日常定向提醒。",
followUpNotes: "观察夜间离床次数。",
},
{
elderName: "钱松柏",
name: "慢阻肺",
status: "active",
treatmentNotes: "吸入治疗和低流量吸氧。",
followUpNotes: "每日汇总夜间血氧趋势。",
},
{
elderName: "林玉琴",
name: "骨质疏松",
status: "controlled",
treatmentNotes: "补钙、维生素 D 和防跌倒宣教。",
followUpNotes: "短住结束前出具照护建议。",
},
{
elderName: "吴佩兰",
name: "膝关节退行性病变",
status: "resolved",
treatmentNotes: "历史康复记录归档。",
followUpNotes: "退住后无需跟进。",
},
];
const DEFAULT_HEALTH_REVIEWS: SeedHealthReview[] = [
@@ -385,6 +751,33 @@ const DEFAULT_HEALTH_REVIEWS: SeedHealthReview[] = [
description: "餐后血糖高于日常水平。",
resolutionNotes: "已调整晚餐主食并安排次日复测。",
},
{
elderName: "钱松柏",
vitalIndex: 7,
severity: "critical",
status: "pending",
title: "夜间血氧低",
description: "设备记录血氧低于 90需要确认吸氧和呼吸状态。",
resolutionNotes: "",
},
{
elderName: "马淑芬",
vitalIndex: 4,
severity: "warning",
status: "reviewed",
title: "体重下降观察",
description: "近期体重偏低,需要结合进食量继续观察。",
resolutionNotes: "已通知营养师调整晚间加餐。",
},
{
elderName: "王桂兰",
vitalIndex: 9,
severity: "info",
status: "resolved",
title: "血压复核完成",
description: "前一日夜间血压偏高,今日已复测。",
resolutionNotes: "晨间复测仍偏高,转入本日待处理复核。",
},
];
const DEFAULT_CARE_TASKS: SeedCareTask[] = [
@@ -439,6 +832,308 @@ const DEFAULT_CARE_TASKS: SeedCareTask[] = [
assigneeLabel: "三号护理组",
executionNotes: "",
},
{
elderName: "马淑芬",
title: "晚餐软食进食观察",
careType: "meal",
priority: "urgent",
status: "pending",
scheduledHoursOffset: 2,
assigneeLabel: "重点护理组",
executionNotes: "",
},
{
elderName: "胡志强",
title: "步行训练记录",
careType: "rehab",
priority: "normal",
status: "completed",
scheduledHoursOffset: -6,
completedHoursAgo: 5,
assigneeLabel: "康复护理组",
executionNotes: "扶行 80 米,未诉明显疼痛。",
},
{
elderName: "高兰英",
title: "活动室返回陪同",
careType: "daily_care",
priority: "normal",
status: "in_progress",
scheduledHoursOffset: 0,
assigneeLabel: "二号护理组",
executionNotes: "已到活动室,等待小组活动结束。",
},
{
elderName: "钱松柏",
title: "夜间吸氧设备检查",
careType: "inspection",
priority: "urgent",
status: "pending",
scheduledHoursOffset: -1,
assigneeLabel: "夜班护理组",
executionNotes: "",
},
{
elderName: "林玉琴",
title: "短住入院宣教",
careType: "other",
priority: "low",
status: "completed",
scheduledHoursOffset: -24,
completedHoursAgo: 23,
assigneeLabel: "接待护理组",
executionNotes: "已完成房间设施、防跌倒和呼叫器使用说明。",
},
{
elderName: "赵国华",
title: "床单位清洁消毒",
careType: "cleaning",
priority: "normal",
status: "cancelled",
scheduledHoursOffset: -8,
assigneeLabel: "保洁协同组",
executionNotes: "因临时复查延期,已改派夜班完成。",
},
];
const DEFAULT_SYSTEM_INCIDENTS: SeedSystemIncident[] = [
{
severity: "warning",
status: "open",
title: "床头呼叫器待检修",
description: "A102-2 床位呼叫器处于检修状态,完成后可恢复使用。",
source: "默认工作区初始化",
createdHoursAgo: 2,
},
{
severity: "info",
status: "acknowledged",
title: "本周护理评估待完成",
description: "陈秀英、何德明等待入住评估,可在老人档案和床位工作区继续处理。",
source: "默认工作区初始化",
createdHoursAgo: 6,
acknowledgedHoursAgo: 5,
},
{
severity: "critical",
status: "resolved",
title: "夜间巡房异常已处理",
description: "重点照护房夜间巡房提醒已恢复,保留事件用于状态页验证。",
source: "默认工作区初始化",
createdHoursAgo: 14,
acknowledgedHoursAgo: 13,
resolvedHoursAgo: 11,
},
{
severity: "critical",
status: "open",
title: "S101 医养观察房血氧告警",
description: "钱松柏夜间血氧持续低于阈值,需要护理组和医生复核。",
source: "生命体征设备",
createdHoursAgo: 1,
},
{
severity: "warning",
status: "acknowledged",
title: "A302 氧气接口保养延期",
description: "A302-2 氧气接口保养未按计划完成,床位暂不安排入住。",
source: "设备巡检",
createdHoursAgo: 20,
acknowledgedHoursAgo: 18,
},
{
severity: "info",
status: "closed",
title: "短住托养资料归档完成",
description: "林玉琴短住托养资料已录入并完成家属确认。",
source: "接待护理组",
createdHoursAgo: 28,
acknowledgedHoursAgo: 27,
resolvedHoursAgo: 24,
},
];
const DEFAULT_DEVICE_ASSETS: SeedDeviceAsset[] = [
{
name: "A102 床头呼叫器",
code: "DEV-CALL-A102-2",
category: "呼叫系统",
location: "护理楼 A102-2",
status: "maintenance",
lastInspectedHoursAgo: 6,
notes: "按键回弹异常,已临时更换备用呼叫器。",
},
{
name: "S101 血氧监测仪",
code: "DEV-SPO2-S101",
category: "生命体征设备",
location: "医养楼 S101",
status: "active",
lastInspectedHoursAgo: 3,
notes: "夜间连续监测,数据接入健康复核。",
},
{
name: "A302 氧气接口",
code: "DEV-OXY-A302-2",
category: "供氧设施",
location: "护理楼 A302-2",
status: "maintenance",
lastInspectedHoursAgo: 20,
notes: "保养延期,床位暂不安排吸氧需求老人。",
},
{
name: "活动室康复步行带",
code: "DEV-REHAB-R101",
category: "康复设备",
location: "康复楼一层活动室",
status: "active",
lastInspectedHoursAgo: 30,
notes: "适用于步态训练和扶行评估。",
},
];
const DEFAULT_MAINTENANCE_TICKETS: SeedMaintenanceTicket[] = [
{
deviceCode: "DEV-CALL-A102-2",
title: "床头呼叫器按键失灵",
description: "A102-2 床位呼叫器偶发无响应,需要更换按键模块。",
priority: "high",
status: "assigned",
assigneeLabel: "设备运维组",
resolutionNotes: "已备件,预计今日 17:00 前完成。",
},
{
deviceCode: "DEV-OXY-A302-2",
title: "氧气接口保养延期",
description: "A302-2 氧气接口未完成季度保养。",
priority: "urgent",
status: "open",
assigneeLabel: "后勤维修",
resolutionNotes: "",
},
{
deviceCode: "DEV-REHAB-R101",
title: "康复步行带日常校准",
description: "例行速度校准和安全带检查。",
priority: "normal",
status: "resolved",
assigneeLabel: "康复设备供应商",
resolutionNotes: "已完成速度校准,安全带状态正常。",
},
];
const DEFAULT_NOTICES: SeedNotice[] = [
{
title: "本周家属探访安排",
content: "周六上午开放护理楼一层和康复楼探访,请各护理组提前确认老人状态和陪同人员。",
audience: "护理组、前台接待",
status: "published",
publishedHoursAgo: 4,
},
{
title: "夜间重点巡房提醒",
content: "钱松柏、赵国华、马淑芬列入今晚重点巡房名单,请夜班护理组按时记录。",
audience: "夜班护理组",
status: "published",
publishedHoursAgo: 10,
},
{
title: "设备巡检培训材料",
content: "呼叫器、供氧接口和血氧监测仪巡检 SOP 已更新,待主管确认后发布。",
audience: "设备运维组",
status: "draft",
},
];
const DEFAULT_ALERT_RULES: SeedAlertRule[] = [
{
name: "血氧低值连续告警",
ruleType: "health",
severity: "critical",
status: "enabled",
conditionSummary: "设备上报血氧连续低于 92% 超过 10 分钟。",
suggestion: "通知医生复核,护理组检查吸氧设备和体位。",
},
{
name: "高优先级护理任务逾期",
ruleType: "care",
severity: "warning",
status: "enabled",
conditionSummary: "高或紧急护理任务超过计划时间仍未完成。",
suggestion: "联系责任护理组并记录延误原因。",
},
{
name: "设备维修工单未派单",
ruleType: "device",
severity: "warning",
status: "enabled",
conditionSummary: "高优先级维修工单创建后 2 小时仍处于待处理。",
suggestion: "通知设备运维组完成派单。",
},
];
const DEFAULT_ALERT_TRIGGERS: SeedAlertTrigger[] = [
{
ruleName: "血氧低值连续告警",
elderName: "钱松柏",
title: "S101 血氧低值需复核",
description: "夜间血氧连续低于阈值,已同步到安全应急事件。",
status: "open",
source: "生命体征设备",
handlingNotes: "",
createdHoursAgo: 1,
},
{
ruleName: "高优先级护理任务逾期",
elderName: "赵国华",
title: "重点照护夜间巡检逾期",
description: "夜间巡检补记任务已超过计划时间。",
status: "acknowledged",
source: "护理任务",
handlingNotes: "夜班主管已确认,等待补录。",
createdHoursAgo: 2,
},
{
ruleName: "设备维修工单未派单",
title: "氧气接口保养待派单",
description: "A302 氧气接口保养延期,维修工单仍待处理。",
status: "open",
source: "设备运维",
handlingNotes: "",
createdHoursAgo: 5,
},
];
const DEFAULT_FAMILY_CONTACTS: SeedFamilyContact[] = [
{ elderName: "王桂兰", name: "张敏", relationship: "女儿", phone: "13800000001", status: "active", notes: "主要联系人,每周六探访。" },
{ elderName: "钱松柏", name: "钱宁", relationship: "儿子", phone: "13800000011", status: "active", notes: "关注夜间吸氧情况。" },
{ elderName: "林玉琴", name: "林晓", relationship: "女儿", phone: "13800000014", status: "active", notes: "短住托养资料已确认。" },
{ elderName: "赵国华", name: "赵蕾", relationship: "孙女", phone: "13800000016", status: "active", notes: "希望接收护理反馈。" },
];
const DEFAULT_FAMILY_VISITS: SeedFamilyVisit[] = [
{ elderName: "王桂兰", contactName: "张敏", scheduledHoursOffset: 30, status: "approved", notes: "安排护理楼一层会客区。" },
{ elderName: "钱松柏", contactName: "钱宁", scheduledHoursOffset: 8, status: "requested", notes: "需医生确认夜间血氧情况后再回复。" },
{ elderName: "林玉琴", contactName: "林晓", scheduledHoursOffset: -20, status: "completed", notes: "已完成探访和短住事项确认。" },
];
const DEFAULT_FAMILY_FEEDBACK: SeedFamilyFeedback[] = [
{
elderName: "赵国华",
contactName: "赵蕾",
feedbackType: "care",
content: "希望夜间巡房后能同步重点观察结果。",
status: "in_progress",
responseNotes: "护理主管已跟进,今晚开始补充巡房备注。",
},
{
elderName: "林玉琴",
contactName: "林晓",
feedbackType: "environment",
content: "短住房间清洁和呼叫器说明比较清楚。",
status: "resolved",
responseNotes: "已记录为接待服务反馈。",
},
];
function hasRows(rows: unknown[]): boolean {
@@ -738,31 +1433,179 @@ export async function seedDefaultWorkspaceData(organizationId: string): Promise<
}),
);
await transaction.insert(systemIncidents).values([
{
await transaction.insert(systemIncidents).values(
DEFAULT_SYSTEM_INCIDENTS.map((incident) => ({
organizationId,
severity: "warning",
status: "open",
title: "床头呼叫器待检修",
description: "A102-2 床位呼叫器处于检修状态,完成后可恢复使用。",
source: "默认工作区初始化",
},
{
severity: incident.severity,
status: incident.status,
title: incident.title,
description: incident.description,
source: incident.source,
acknowledgedAt:
incident.acknowledgedHoursAgo === undefined ? undefined : new Date(now.getTime() - incident.acknowledgedHoursAgo * 60 * 60 * 1000),
resolvedAt:
incident.resolvedHoursAgo === undefined ? undefined : new Date(now.getTime() - incident.resolvedHoursAgo * 60 * 60 * 1000),
createdAt: new Date(now.getTime() - incident.createdHoursAgo * 60 * 60 * 1000),
updatedAt: new Date(
now.getTime() -
(incident.resolvedHoursAgo ?? incident.acknowledgedHoursAgo ?? incident.createdHoursAgo) * 60 * 60 * 1000,
),
})),
);
const deviceRows = await transaction
.insert(deviceAssets)
.values(
DEFAULT_DEVICE_ASSETS.map((device) => ({
organizationId,
severity: "info",
status: "acknowledged",
title: "本周护理评估待完成",
description: "陈秀英、何德明等待入住评估,可在老人档案和床位工作区继续处理。",
source: "默认工作区初始化",
},
{
name: device.name,
code: device.code,
category: device.category,
location: device.location,
status: device.status,
lastInspectedAt:
device.lastInspectedHoursAgo === undefined ? undefined : new Date(now.getTime() - device.lastInspectedHoursAgo * 60 * 60 * 1000),
notes: device.notes,
})),
)
.returning();
if (deviceRows.length !== DEFAULT_DEVICE_ASSETS.length) {
throw new Error("默认设备台账初始化失败");
}
const deviceIdByCode = new Map(deviceRows.map((device) => [device.code, device.id]));
await transaction.insert(maintenanceTickets).values(
DEFAULT_MAINTENANCE_TICKETS.map((ticket) => {
const deviceId = deviceIdByCode.get(ticket.deviceCode);
if (!deviceId) {
throw new Error("默认维修工单初始化失败");
}
return {
organizationId,
severity: "critical",
status: "resolved",
title: "夜间巡房异常已处理",
description: "重点照护房夜间巡房提醒已恢复,保留事件用于状态页验证。",
source: "默认工作区初始化",
},
]);
deviceId,
title: ticket.title,
description: ticket.description,
priority: ticket.priority,
status: ticket.status,
assigneeLabel: ticket.assigneeLabel,
resolutionNotes: ticket.resolutionNotes,
resolvedAt: ticket.status === "resolved" || ticket.status === "closed" ? now : undefined,
};
}),
);
await transaction.insert(notices).values(
DEFAULT_NOTICES.map((notice) => ({
organizationId,
title: notice.title,
content: notice.content,
audience: notice.audience,
status: notice.status,
publishedAt: notice.publishedHoursAgo === undefined ? undefined : new Date(now.getTime() - notice.publishedHoursAgo * 60 * 60 * 1000),
})),
);
const alertRuleRows = await transaction
.insert(alertRules)
.values(DEFAULT_ALERT_RULES.map((rule) => ({ ...rule, organizationId })))
.returning();
if (alertRuleRows.length !== DEFAULT_ALERT_RULES.length) {
throw new Error("默认预警规则初始化失败");
}
const alertRuleIdByName = new Map(alertRuleRows.map((rule) => [rule.name, rule.id]));
await transaction.insert(alertTriggers).values(
DEFAULT_ALERT_TRIGGERS.map((trigger) => {
const ruleId = alertRuleIdByName.get(trigger.ruleName);
const elderId = trigger.elderName ? elderIdByName.get(trigger.elderName) : undefined;
if (!ruleId || (trigger.elderName && !elderId)) {
throw new Error("默认预警触发记录初始化失败");
}
return {
organizationId,
ruleId,
elderId,
title: trigger.title,
description: trigger.description,
status: trigger.status,
source: trigger.source,
handlingNotes: trigger.handlingNotes,
handledAt: trigger.status === "open" ? undefined : now,
createdAt: new Date(now.getTime() - trigger.createdHoursAgo * 60 * 60 * 1000),
updatedAt: now,
};
}),
);
const familyContactRows = await transaction
.insert(familyContacts)
.values(
DEFAULT_FAMILY_CONTACTS.map((contact) => {
const elderId = elderIdByName.get(contact.elderName);
if (!elderId) {
throw new Error("默认家属联系人初始化失败");
}
return {
organizationId,
elderId,
name: contact.name,
relationship: contact.relationship,
phone: contact.phone,
status: contact.status,
notes: contact.notes,
};
}),
)
.returning();
if (familyContactRows.length !== DEFAULT_FAMILY_CONTACTS.length) {
throw new Error("默认家属联系人初始化失败");
}
const familyContactIdByKey = new Map(
familyContactRows.map((contact) => [`${contact.elderId}/${contact.name}`, contact.id]),
);
await transaction.insert(familyVisitAppointments).values(
DEFAULT_FAMILY_VISITS.map((visit) => {
const elderId = elderIdByName.get(visit.elderName);
const contactId = elderId ? familyContactIdByKey.get(`${elderId}/${visit.contactName}`) : undefined;
if (!elderId || !contactId) {
throw new Error("默认探访预约初始化失败");
}
return {
organizationId,
elderId,
contactId,
scheduledAt: new Date(now.getTime() + visit.scheduledHoursOffset * 60 * 60 * 1000),
status: visit.status,
notes: visit.notes,
handledAt: visit.status === "requested" ? undefined : now,
};
}),
);
await transaction.insert(familyFeedback).values(
DEFAULT_FAMILY_FEEDBACK.map((feedback) => {
const elderId = elderIdByName.get(feedback.elderName);
const contactId = elderId ? familyContactIdByKey.get(`${elderId}/${feedback.contactName}`) : undefined;
if (!elderId || !contactId) {
throw new Error("默认家属反馈初始化失败");
}
return {
organizationId,
elderId,
contactId,
feedbackType: feedback.feedbackType,
content: feedback.content,
status: feedback.status,
responseNotes: feedback.responseNotes,
handledAt: feedback.status === "open" ? undefined : now,
};
}),
);
});
}

View File

@@ -29,6 +29,14 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
{ id: "care:manage", label: "处理护理任务", category: "护理", description: "启动、完成和维护护理服务执行记录。" },
{ id: "health:read", label: "查看健康数据", category: "健康", description: "查看老人健康档案、生命体征和异常复核。" },
{ id: "health:manage", label: "管理健康数据", category: "健康", description: "维护健康档案、生命体征、慢病和异常复核。" },
{ id: "device:read", label: "查看设备运维", category: "协同", description: "查看设备台账和维修工单。" },
{ id: "device:manage", label: "管理设备运维", category: "协同", description: "维护设备台账、维修工单和处理状态。" },
{ id: "notice:read", label: "查看公告通知", category: "协同", description: "查看公告和阅读状态。" },
{ id: "notice:manage", label: "管理公告通知", category: "协同", description: "创建、发布、撤回和删除公告。" },
{ id: "alert:read", label: "查看规则预警", category: "协同", description: "查看预警规则和触发记录。" },
{ id: "alert:manage", label: "管理规则预警", category: "协同", description: "维护预警规则并处理触发记录。" },
{ id: "family:read", label: "查看家属服务", category: "协同", description: "查看家属联系人、探访预约和服务反馈。" },
{ id: "family:manage", label: "管理家属服务", category: "协同", description: "维护家属联系人、探访预约和反馈处理。" },
{ id: "facility:read", label: "查看房间床位", category: "床位", description: "查看房间、床位和占用状态。" },
{ id: "facility:manage", label: "管理房间床位", category: "床位", description: "维护院区、房间、床位和床位状态。" },
{ id: "admission:read", label: "查看入住", category: "入住", description: "查看入住、换床、退住历史。" },
@@ -97,6 +105,14 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [
"care:manage",
"health:read",
"health:manage",
"device:read",
"device:manage",
"notice:read",
"notice:manage",
"alert:read",
"alert:manage",
"family:read",
"family:manage",
"facility:read",
"facility:manage",
"admission:read",
@@ -119,6 +135,14 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [
"care:manage",
"health:read",
"health:manage",
"device:read",
"device:manage",
"notice:read",
"notice:manage",
"alert:read",
"alert:manage",
"family:read",
"family:manage",
"facility:read",
"facility:manage",
"admission:read",
@@ -133,13 +157,35 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [
key: "caregiver",
scope: "organization",
description: "照护人员,查看床位和维护老人照护信息。",
permissions: ["care:read", "care:manage", "health:read", "health:manage", "facility:read", "admission:read", "elder:read", "elder:update"],
permissions: [
"care:read",
"care:manage",
"health:read",
"health:manage",
"device:read",
"alert:read",
"family:read",
"facility:read",
"admission:read",
"elder:read",
"elder:update",
],
},
{
key: "viewer",
scope: "organization",
description: "普通用户,只读查看老人、床位和入住信息。",
permissions: ["care:read", "health:read", "facility:read", "admission:read", "elder:read"],
permissions: [
"care:read",
"health:read",
"device:read",
"notice:read",
"alert:read",
"family:read",
"facility:read",
"admission:read",
"elder:read",
],
},
{
key: "family",

View File

@@ -30,6 +30,17 @@ export const healthReviewStatusEnum = pgEnum("health_review_status", ["pending",
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 deviceAssetStatusEnum = pgEnum("device_asset_status", ["active", "maintenance", "disabled", "retired"]);
export const maintenanceTicketStatusEnum = pgEnum("maintenance_ticket_status", ["open", "assigned", "resolved", "closed", "cancelled"]);
export const maintenanceTicketPriorityEnum = pgEnum("maintenance_ticket_priority", ["low", "normal", "high", "urgent"]);
export const noticeStatusEnum = pgEnum("notice_status", ["draft", "published", "retracted"]);
export const alertRuleTypeEnum = pgEnum("alert_rule_type", ["health", "care", "device", "incident", "admission", "other"]);
export const alertRuleStatusEnum = pgEnum("alert_rule_status", ["enabled", "disabled"]);
export const alertTriggerStatusEnum = pgEnum("alert_trigger_status", ["open", "acknowledged", "resolved", "closed"]);
export const familyContactStatusEnum = pgEnum("family_contact_status", ["active", "suspended", "archived"]);
export const familyVisitStatusEnum = pgEnum("family_visit_status", ["requested", "approved", "completed", "cancelled"]);
export const familyFeedbackTypeEnum = pgEnum("family_feedback_type", ["service", "care", "meal", "environment", "other"]);
export const familyFeedbackStatusEnum = pgEnum("family_feedback_status", ["open", "in_progress", "resolved", "closed"]);
export const systemSettings = pgTable("system_settings", {
id: text("id").primaryKey().default("global"),
@@ -323,6 +334,150 @@ export const careTasks = pgTable("care_tasks", {
scheduleLookup: index("care_tasks_schedule_lookup").on(table.organizationId, table.scheduledAt),
}));
export const deviceAssets = pgTable("device_assets", {
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
name: text("name").notNull(),
code: text("code").notNull(),
category: text("category").notNull().default(""),
location: text("location").notNull().default(""),
status: deviceAssetStatusEnum("status").notNull().default("active"),
lastInspectedAt: timestamp("last_inspected_at", { withTimezone: true }),
notes: text("notes").notNull().default(""),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
codeUnique: uniqueIndex("device_assets_code_organization_unique").on(table.organizationId, table.code),
statusLookup: index("device_assets_status_lookup").on(table.organizationId, table.status),
}));
export const maintenanceTickets = pgTable("maintenance_tickets", {
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
deviceId: uuid("device_id").references(() => deviceAssets.id, { onDelete: "set null" }),
title: text("title").notNull(),
description: text("description").notNull().default(""),
priority: maintenanceTicketPriorityEnum("priority").notNull().default("normal"),
status: maintenanceTicketStatusEnum("status").notNull().default("open"),
assigneeLabel: text("assignee_label").notNull().default(""),
resolutionNotes: text("resolution_notes").notNull().default(""),
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
queueLookup: index("maintenance_tickets_queue_lookup").on(table.organizationId, table.status, table.priority),
deviceLookup: index("maintenance_tickets_device_lookup").on(table.organizationId, table.deviceId),
}));
export const notices = pgTable("notices", {
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
title: text("title").notNull(),
content: text("content").notNull().default(""),
audience: text("audience").notNull().default("全体员工"),
status: noticeStatusEnum("status").notNull().default("draft"),
publishedAt: timestamp("published_at", { withTimezone: true }),
createdByAccountId: uuid("created_by_account_id").references(() => accounts.id),
updatedByAccountId: uuid("updated_by_account_id").references(() => accounts.id),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
statusLookup: index("notices_status_lookup").on(table.organizationId, table.status),
}));
export const noticeReadReceipts = pgTable("notice_read_receipts", {
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
noticeId: uuid("notice_id").notNull().references(() => notices.id, { onDelete: "cascade" }),
accountId: uuid("account_id").notNull().references(() => accounts.id, { onDelete: "cascade" }),
readAt: timestamp("read_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
accountNoticeUnique: uniqueIndex("notice_read_receipts_account_notice_unique").on(table.noticeId, table.accountId),
noticeLookup: index("notice_read_receipts_notice_lookup").on(table.organizationId, table.noticeId),
}));
export const alertRules = pgTable("alert_rules", {
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
name: text("name").notNull(),
ruleType: alertRuleTypeEnum("rule_type").notNull().default("other"),
severity: incidentSeverityEnum("severity").notNull().default("warning"),
status: alertRuleStatusEnum("status").notNull().default("enabled"),
conditionSummary: text("condition_summary").notNull().default(""),
suggestion: text("suggestion").notNull().default(""),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
statusLookup: index("alert_rules_status_lookup").on(table.organizationId, table.status, table.ruleType),
}));
export const alertTriggers = pgTable("alert_triggers", {
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
ruleId: uuid("rule_id").references(() => alertRules.id, { onDelete: "set null" }),
elderId: uuid("elder_id").references(() => elders.id, { onDelete: "set null" }),
title: text("title").notNull(),
description: text("description").notNull().default(""),
status: alertTriggerStatusEnum("status").notNull().default("open"),
source: text("source").notNull().default(""),
handledByAccountId: uuid("handled_by_account_id").references(() => accounts.id),
handledAt: timestamp("handled_at", { withTimezone: true }),
handlingNotes: text("handling_notes").notNull().default(""),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
queueLookup: index("alert_triggers_queue_lookup").on(table.organizationId, table.status),
ruleLookup: index("alert_triggers_rule_lookup").on(table.organizationId, table.ruleId),
}));
export const familyContacts = pgTable("family_contacts", {
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
elderId: uuid("elder_id").notNull().references(() => elders.id, { onDelete: "cascade" }),
name: text("name").notNull(),
relationship: text("relationship").notNull(),
phone: text("phone").notNull(),
status: familyContactStatusEnum("status").notNull().default("active"),
notes: text("notes").notNull().default(""),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
elderLookup: index("family_contacts_elder_lookup").on(table.organizationId, table.elderId, table.status),
}));
export const familyVisitAppointments = pgTable("family_visit_appointments", {
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
elderId: uuid("elder_id").notNull().references(() => elders.id, { onDelete: "cascade" }),
contactId: uuid("contact_id").references(() => familyContacts.id, { onDelete: "set null" }),
scheduledAt: timestamp("scheduled_at", { withTimezone: true }).notNull(),
status: familyVisitStatusEnum("status").notNull().default("requested"),
notes: text("notes").notNull().default(""),
handledByAccountId: uuid("handled_by_account_id").references(() => accounts.id),
handledAt: timestamp("handled_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
scheduleLookup: index("family_visit_appointments_schedule_lookup").on(table.organizationId, table.status, table.scheduledAt),
}));
export const familyFeedback = pgTable("family_feedback", {
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
elderId: uuid("elder_id").notNull().references(() => elders.id, { onDelete: "cascade" }),
contactId: uuid("contact_id").references(() => familyContacts.id, { onDelete: "set null" }),
feedbackType: familyFeedbackTypeEnum("feedback_type").notNull().default("other"),
content: text("content").notNull(),
status: familyFeedbackStatusEnum("status").notNull().default("open"),
responseNotes: text("response_notes").notNull().default(""),
handledByAccountId: uuid("handled_by_account_id").references(() => accounts.id),
handledAt: timestamp("handled_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
queueLookup: index("family_feedback_queue_lookup").on(table.organizationId, table.status, table.feedbackType),
}));
export const admissions = pgTable("admissions", {
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),

View File

@@ -43,6 +43,14 @@ export const PERMISSIONS = [
"care:manage",
"health:read",
"health:manage",
"device:read",
"device:manage",
"notice:read",
"notice:manage",
"alert:read",
"alert:manage",
"family:read",
"family:manage",
"facility:read",
"facility:manage",
"admission:read",

View File

@@ -0,0 +1,325 @@
"use client";
import { FormEvent, useMemo, useState } from "react";
import { AlertTriangle, CheckCircle2, Search, Wrench, XCircle } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, 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 { DeviceAsset, DeviceAssetInput, DeviceAssetStatus, DeviceOperationsData, MaintenanceTicket, MaintenanceTicketInput, MaintenanceTicketPriority, MaintenanceTicketStatus } from "@/modules/devices/types";
import { DEVICE_ASSET_STATUS_LABELS, DEVICE_ASSET_STATUS_VALUES, MAINTENANCE_TICKET_PRIORITY_LABELS, MAINTENANCE_TICKET_PRIORITY_VALUES, MAINTENANCE_TICKET_STATUS_LABELS, MAINTENANCE_TICKET_STATUS_VALUES } from "@/modules/devices/types";
type DevicesWorkspaceClientProps = {
canManage: boolean;
initialData: DeviceOperationsData;
};
type DialogState =
| { kind: "device"; mode: "create"; value: DeviceAssetInput }
| { kind: "device"; mode: "edit"; id: string; value: DeviceAssetInput }
| { kind: "ticket"; mode: "create"; value: MaintenanceTicketInput }
| { kind: "ticket"; mode: "edit"; id: string; value: MaintenanceTicketInput };
const emptyDevice: DeviceAssetInput = {
name: "",
code: "",
category: "",
location: "",
status: "active",
lastInspectedAt: undefined,
notes: "",
};
const emptyTicket: MaintenanceTicketInput = {
deviceId: undefined,
title: "",
description: "",
priority: "normal",
status: "open",
assigneeLabel: "",
resolutionNotes: "",
};
function formatDateTime(value: string | undefined): string {
return value ? new Date(value).toLocaleString("zh-CN") : "-";
}
function deviceToInput(device: DeviceAsset): DeviceAssetInput {
return {
name: device.name,
code: device.code,
category: device.category,
location: device.location,
status: device.status,
lastInspectedAt: device.lastInspectedAt,
notes: device.notes,
};
}
function ticketToInput(ticket: MaintenanceTicket): MaintenanceTicketInput {
return {
deviceId: ticket.deviceId,
title: ticket.title,
description: ticket.description,
priority: ticket.priority,
status: ticket.status,
assigneeLabel: ticket.assigneeLabel,
resolutionNotes: ticket.resolutionNotes,
};
}
function badgeVariant(status: string): "danger" | "secondary" | "success" | "warning" {
if (status === "active" || status === "resolved" || status === "closed") {
return "success";
}
if (status === "maintenance" || status === "open" || status === "assigned" || status === "urgent") {
return "warning";
}
if (status === "disabled" || status === "retired" || status === "cancelled") {
return "danger";
}
return "secondary";
}
export function DevicesWorkspaceClient({ canManage, initialData }: DevicesWorkspaceClientProps): React.ReactElement {
const [data, setData] = useState(initialData);
const [query, setQuery] = useState("");
const [deviceStatus, setDeviceStatus] = useState<"all" | DeviceAssetStatus>("all");
const [ticketStatus, setTicketStatus] = useState<"all" | MaintenanceTicketStatus>("all");
const [dialog, setDialog] = useState<DialogState | undefined>();
const [message, setMessage] = useState("");
const [isPending, setIsPending] = useState(false);
const normalizedQuery = query.trim().toLowerCase();
const filteredDevices = useMemo(
() =>
data.devices.filter((device) => {
const matchesQuery = !normalizedQuery || [device.name, device.code, device.category, device.location, device.notes].join(" ").toLowerCase().includes(normalizedQuery);
const matchesStatus = deviceStatus === "all" || device.status === deviceStatus;
return matchesQuery && matchesStatus;
}),
[data.devices, deviceStatus, normalizedQuery],
);
const filteredTickets = useMemo(
() =>
data.tickets.filter((ticket) => {
const matchesQuery = !normalizedQuery || [ticket.title, ticket.description, ticket.deviceName, ticket.assigneeLabel].join(" ").toLowerCase().includes(normalizedQuery);
const matchesStatus = ticketStatus === "all" || ticket.status === ticketStatus;
return matchesQuery && matchesStatus;
}),
[data.tickets, normalizedQuery, ticketStatus],
);
async function refreshData(): Promise<void> {
const response = await fetch("/api/devices/assets");
const result = (await response.json()) as ApiResult<{ data: DeviceOperationsData }>;
if (result.success) {
setData(result.data);
}
}
async function submitDialog(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
if (!dialog || !canManage) {
setMessage("当前角色无权维护设备运维");
return;
}
const isCreate = dialog.mode === "create";
const path =
dialog.kind === "device"
? isCreate
? "/api/devices/assets"
: `/api/devices/assets/${dialog.id}`
: isCreate
? "/api/devices/tickets"
: `/api/devices/tickets/${dialog.id}`;
setIsPending(true);
const response = await fetch(path, {
method: isCreate ? "POST" : "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(dialog.value),
});
const result = (await response.json()) as ApiResult<Record<string, unknown>>;
setIsPending(false);
setMessage(result.reason);
if (result.success) {
setDialog(undefined);
await refreshData();
}
}
async function remove(kind: "device" | "ticket", id: string): Promise<void> {
if (!canManage) {
setMessage("当前角色无权删除设备运维记录");
return;
}
const path = kind === "device" ? `/api/devices/assets/${id}` : `/api/devices/tickets/${id}`;
setIsPending(true);
const response = await fetch(path, { method: "DELETE" });
const result = (await response.json()) as ApiResult<Record<string, unknown>>;
setIsPending(false);
setMessage(result.reason);
if (result.success) {
await refreshData();
}
}
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard icon={CheckCircle2} label="运行设备" value={data.metrics.activeDevices} />
<MetricCard icon={Wrench} label="维护中设备" value={data.metrics.maintenanceDevices} />
<MetricCard icon={AlertTriangle} label="待处理工单" value={data.metrics.openTickets} />
<MetricCard icon={XCircle} label="紧急工单" value={data.metrics.urgentTickets} />
</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) => setDeviceStatus(value as "all" | DeviceAssetStatus)} options={[{ value: "all", label: "全部设备状态" }, ...DEVICE_ASSET_STATUS_VALUES.map((value) => ({ value, label: DEVICE_ASSET_STATUS_LABELS[value] }))]} value={deviceStatus} />
<Select aria-label="工单状态" onValueChange={(value) => setTicketStatus(value as "all" | MaintenanceTicketStatus)} options={[{ value: "all", label: "全部工单状态" }, ...MAINTENANCE_TICKET_STATUS_VALUES.map((value) => ({ value, label: MAINTENANCE_TICKET_STATUS_LABELS[value] }))]} value={ticketStatus} />
<Button disabled={!canManage} onClick={() => setDialog({ kind: "device", mode: "create", value: emptyDevice })} type="button"></Button>
<Button disabled={!canManage} onClick={() => setDialog({ kind: "ticket", mode: "create", value: emptyTicket })} type="button" variant="outline"></Button>
</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></CardHeader>
<CardContent className="overflow-x-auto">
<Table className="w-full min-w-[900px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3 text-right"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{filteredDevices.map((device) => (
<Table.Row key={device.id}>
<Table.Cell className="px-4 py-3"><p className="font-medium">{device.name}</p><p className="text-xs text-muted-foreground">{device.code} / {device.notes || "-"}</p></Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{device.category || "-"}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{device.location || "-"}</Table.Cell>
<Table.Cell className="px-4 py-3"><Badge variant={badgeVariant(device.status)}>{DEVICE_ASSET_STATUS_LABELS[device.status]}</Badge></Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDateTime(device.lastInspectedAt)}</Table.Cell>
<Table.Cell className="px-4 py-3"><div className="flex justify-end gap-2"><Button disabled={!canManage || isPending} onClick={() => setDialog({ kind: "device", mode: "edit", id: device.id, value: deviceToInput(device) })} size="sm" type="button" variant="outline"></Button><Button disabled={!canManage || isPending} onClick={() => remove("device", device.id)} size="sm" type="button" variant="outline"></Button></div></Table.Cell>
</Table.Row>
))}
{filteredDevices.length === 0 ? <Table.Row><Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}></Table.Cell></Table.Row> : null}
</Table.Body>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle></CardTitle></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"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3 text-right"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{filteredTickets.map((ticket) => (
<Table.Row key={ticket.id}>
<Table.Cell className="px-4 py-3"><p className="font-medium">{ticket.title}</p><p className="text-xs text-muted-foreground">{ticket.description || ticket.resolutionNotes || "-"}</p></Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{ticket.deviceName || "-"}</Table.Cell>
<Table.Cell className="px-4 py-3"><Badge variant={badgeVariant(ticket.priority)}>{MAINTENANCE_TICKET_PRIORITY_LABELS[ticket.priority]}</Badge></Table.Cell>
<Table.Cell className="px-4 py-3"><Badge variant={badgeVariant(ticket.status)}>{MAINTENANCE_TICKET_STATUS_LABELS[ticket.status]}</Badge></Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{ticket.assigneeLabel || "-"}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDateTime(ticket.updatedAt)}</Table.Cell>
<Table.Cell className="px-4 py-3"><div className="flex justify-end gap-2"><Button disabled={!canManage || isPending} onClick={() => setDialog({ kind: "ticket", mode: "edit", id: ticket.id, value: ticketToInput(ticket) })} size="sm" type="button" variant="outline"></Button><Button disabled={!canManage || isPending} onClick={() => remove("ticket", ticket.id)} size="sm" type="button" variant="outline"></Button></div></Table.Cell>
</Table.Row>
))}
{filteredTickets.length === 0 ? <Table.Row><Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={7}></Table.Cell></Table.Row> : null}
</Table.Body>
</Table>
</CardContent>
</Card>
<Dialog onClose={() => setDialog(undefined)} open={dialog !== undefined} title={dialog?.kind === "device" ? "设备台账" : "维修工单"} width="lg">
{dialog ? (
<form className="grid gap-3" onSubmit={submitDialog}>
{dialog.kind === "device" ? <DeviceForm dialog={dialog} setDialog={setDialog} /> : <TicketForm devices={data.devices} dialog={dialog} setDialog={setDialog} />}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button disabled={isPending} onClick={() => setDialog(undefined)} type="button" variant="outline"></Button>
<Button disabled={isPending || !canManage} type="submit"></Button>
</div>
</form>
) : null}
</Dialog>
</div>
);
}
function DeviceForm({ dialog, setDialog }: { dialog: Extract<DialogState, { kind: "device" }>; setDialog: (dialog: DialogState) => void }): React.ReactElement {
const value = dialog.value;
function update(next: Partial<DeviceAssetInput>): void {
setDialog({ ...dialog, value: { ...value, ...next } });
}
return (
<>
<Input label="设备名称" onChange={(event) => update({ name: event.target.value })} required value={value.name} />
<Input label="设备编号" onChange={(event) => update({ code: event.target.value })} required value={value.code} />
<Input label="分类" onChange={(event) => update({ category: event.target.value })} value={value.category} />
<Input label="位置" onChange={(event) => update({ location: event.target.value })} value={value.location} />
<Select aria-label="设备状态" onValueChange={(status) => update({ status: status as DeviceAssetStatus })} options={DEVICE_ASSET_STATUS_VALUES.map((status) => ({ value: status, label: DEVICE_ASSET_STATUS_LABELS[status] }))} value={value.status} />
<Input label="最近巡检时间" onChange={(event) => update({ lastInspectedAt: event.target.value || undefined })} type="datetime-local" value={value.lastInspectedAt ? value.lastInspectedAt.slice(0, 16) : ""} />
<Textarea label="备注" onChange={(event) => update({ notes: event.target.value })} value={value.notes} />
</>
);
}
function TicketForm({ devices, dialog, setDialog }: { devices: DeviceAsset[]; dialog: Extract<DialogState, { kind: "ticket" }>; setDialog: (dialog: DialogState) => void }): React.ReactElement {
const value = dialog.value;
function update(next: Partial<MaintenanceTicketInput>): void {
setDialog({ ...dialog, value: { ...value, ...next } });
}
return (
<>
<Select aria-label="关联设备" onValueChange={(deviceId) => update({ deviceId: deviceId || undefined })} options={[{ value: "", label: "不关联设备" }, ...devices.map((device) => ({ value: device.id, label: `${device.name} / ${device.code}` }))]} value={value.deviceId ?? ""} />
<Input label="工单标题" onChange={(event) => update({ title: event.target.value })} required value={value.title} />
<Textarea label="问题描述" onChange={(event) => update({ description: event.target.value })} value={value.description} />
<Select aria-label="优先级" onValueChange={(priority) => update({ priority: priority as MaintenanceTicketPriority })} options={MAINTENANCE_TICKET_PRIORITY_VALUES.map((priority) => ({ value: priority, label: MAINTENANCE_TICKET_PRIORITY_LABELS[priority] }))} value={value.priority} />
<Select aria-label="工单状态" onValueChange={(status) => update({ status: status as MaintenanceTicketStatus })} options={MAINTENANCE_TICKET_STATUS_VALUES.map((status) => ({ value: status, label: MAINTENANCE_TICKET_STATUS_LABELS[status] }))} value={value.status} />
<Input label="负责人" onChange={(event) => update({ assigneeLabel: event.target.value })} value={value.assigneeLabel} />
<Textarea label="处理记录" onChange={(event) => update({ resolutionNotes: event.target.value })} value={value.resolutionNotes} />
</>
);
}
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><p className="text-sm text-muted-foreground">{label}</p><CardTitle className="mt-2 text-3xl">{value}</CardTitle></div>
<Icon className="size-5 text-primary" aria-hidden={true} />
</CardHeader>
</Card>
);
}

View File

@@ -0,0 +1,218 @@
import { and, desc, eq, inArray } from "drizzle-orm";
import { getDatabase } from "@/modules/core/server/db";
import { deviceAssets, maintenanceTickets } from "@/modules/core/server/schema";
import type { DeviceAsset, DeviceAssetInput, DeviceOperationsData, MaintenanceTicket, MaintenanceTicketInput } from "@/modules/devices/types";
export type DeviceMutationFailure = {
success: false;
reason: string;
status: number;
};
export function isDeviceMutationFailure(value: unknown): value is DeviceMutationFailure {
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 toDeviceAsset(row: typeof deviceAssets.$inferSelect): DeviceAsset {
return {
id: row.id,
organizationId: row.organizationId,
name: row.name,
code: row.code,
category: row.category,
location: row.location,
status: row.status,
lastInspectedAt: optionalIso(row.lastInspectedAt),
notes: row.notes,
createdAt: iso(row.createdAt),
updatedAt: iso(row.updatedAt),
};
}
function toMaintenanceTicket(row: typeof maintenanceTickets.$inferSelect, deviceNameById: Map<string, string>): MaintenanceTicket {
const deviceId = row.deviceId ?? undefined;
return {
id: row.id,
organizationId: row.organizationId,
deviceId,
deviceName: deviceId ? deviceNameById.get(deviceId) ?? "" : "",
title: row.title,
description: row.description,
priority: row.priority,
status: row.status,
assigneeLabel: row.assigneeLabel,
resolutionNotes: row.resolutionNotes,
resolvedAt: optionalIso(row.resolvedAt),
createdAt: iso(row.createdAt),
updatedAt: iso(row.updatedAt),
};
}
export async function listDeviceOperationsData(organizationId: string): Promise<DeviceOperationsData> {
const database = getDatabase();
const [deviceRows, ticketRows] = await Promise.all([
database.select().from(deviceAssets).where(eq(deviceAssets.organizationId, organizationId)).orderBy(desc(deviceAssets.updatedAt)),
database.select().from(maintenanceTickets).where(eq(maintenanceTickets.organizationId, organizationId)).orderBy(desc(maintenanceTickets.updatedAt)),
]);
const deviceNameById = new Map(deviceRows.map((device) => [device.id, device.name]));
return {
metrics: {
activeDevices: deviceRows.filter((device) => device.status === "active").length,
maintenanceDevices: deviceRows.filter((device) => device.status === "maintenance").length,
openTickets: ticketRows.filter((ticket) => ticket.status === "open" || ticket.status === "assigned").length,
urgentTickets: ticketRows.filter((ticket) => ticket.priority === "urgent").length,
},
devices: deviceRows.map(toDeviceAsset),
tickets: ticketRows.map((ticket) => toMaintenanceTicket(ticket, deviceNameById)),
};
}
export async function createDeviceAsset(input: DeviceAssetInput & { organizationId: string }): Promise<DeviceAsset | DeviceMutationFailure> {
const database = getDatabase();
const rows = await database
.insert(deviceAssets)
.values({
organizationId: input.organizationId,
name: input.name,
code: input.code,
category: input.category,
location: input.location,
status: input.status,
lastInspectedAt: input.lastInspectedAt ? new Date(input.lastInspectedAt) : undefined,
notes: input.notes,
})
.returning();
const device = rows[0];
return device ? toDeviceAsset(device) : { success: false, reason: "设备创建失败", status: 500 };
}
export async function updateDeviceAsset(input: DeviceAssetInput & { id: string; organizationId: string }): Promise<DeviceAsset | DeviceMutationFailure> {
const database = getDatabase();
const rows = await database
.update(deviceAssets)
.set({
name: input.name,
code: input.code,
category: input.category,
location: input.location,
status: input.status,
lastInspectedAt: input.lastInspectedAt ? new Date(input.lastInspectedAt) : null,
notes: input.notes,
updatedAt: new Date(),
})
.where(and(eq(deviceAssets.id, input.id), eq(deviceAssets.organizationId, input.organizationId)))
.returning();
const device = rows[0];
return device ? toDeviceAsset(device) : { success: false, reason: "设备不存在", status: 404 };
}
export async function deleteDeviceAsset(input: { id: string; organizationId: string }): Promise<DeviceAsset | DeviceMutationFailure> {
const database = getDatabase();
const rows = await database
.delete(deviceAssets)
.where(and(eq(deviceAssets.id, input.id), eq(deviceAssets.organizationId, input.organizationId)))
.returning();
const device = rows[0];
return device ? toDeviceAsset(device) : { success: false, reason: "设备不存在", status: 404 };
}
async function getDeviceNameById(organizationId: string, deviceId: string | undefined): Promise<Map<string, string> | DeviceMutationFailure> {
if (!deviceId) {
return new Map();
}
const database = getDatabase();
const rows = await database
.select({ id: deviceAssets.id, name: deviceAssets.name })
.from(deviceAssets)
.where(and(eq(deviceAssets.id, deviceId), eq(deviceAssets.organizationId, organizationId)));
const device = rows[0];
return device ? new Map([[device.id, device.name]]) : { success: false, reason: "设备不存在", status: 404 };
}
export async function createMaintenanceTicket(input: MaintenanceTicketInput & { organizationId: string }): Promise<MaintenanceTicket | DeviceMutationFailure> {
const deviceNameById = await getDeviceNameById(input.organizationId, input.deviceId);
if (isDeviceMutationFailure(deviceNameById)) {
return deviceNameById;
}
const database = getDatabase();
const resolvedAt = input.status === "resolved" || input.status === "closed" ? new Date() : undefined;
const rows = await database
.insert(maintenanceTickets)
.values({
organizationId: input.organizationId,
deviceId: input.deviceId,
title: input.title,
description: input.description,
priority: input.priority,
status: input.status,
assigneeLabel: input.assigneeLabel,
resolutionNotes: input.resolutionNotes,
resolvedAt,
})
.returning();
const ticket = rows[0];
return ticket ? toMaintenanceTicket(ticket, deviceNameById) : { success: false, reason: "维修工单创建失败", status: 500 };
}
export async function updateMaintenanceTicket(input: MaintenanceTicketInput & { id: string; organizationId: string }): Promise<MaintenanceTicket | DeviceMutationFailure> {
const deviceNameById = await getDeviceNameById(input.organizationId, input.deviceId);
if (isDeviceMutationFailure(deviceNameById)) {
return deviceNameById;
}
const database = getDatabase();
const resolvedAt = input.status === "resolved" || input.status === "closed" ? new Date() : null;
const rows = await database
.update(maintenanceTickets)
.set({
deviceId: input.deviceId ?? null,
title: input.title,
description: input.description,
priority: input.priority,
status: input.status,
assigneeLabel: input.assigneeLabel,
resolutionNotes: input.resolutionNotes,
resolvedAt,
updatedAt: new Date(),
})
.where(and(eq(maintenanceTickets.id, input.id), eq(maintenanceTickets.organizationId, input.organizationId)))
.returning();
const ticket = rows[0];
return ticket ? toMaintenanceTicket(ticket, deviceNameById) : { success: false, reason: "维修工单不存在", status: 404 };
}
export async function deleteMaintenanceTicket(input: { id: string; organizationId: string }): Promise<MaintenanceTicket | DeviceMutationFailure> {
const database = getDatabase();
const rows = await database
.delete(maintenanceTickets)
.where(and(eq(maintenanceTickets.id, input.id), eq(maintenanceTickets.organizationId, input.organizationId)))
.returning();
const ticket = rows[0];
if (!ticket) {
return { success: false, reason: "维修工单不存在", status: 404 };
}
const deviceIds = ticket.deviceId ? [ticket.deviceId] : [];
const deviceRows =
deviceIds.length > 0
? await database.select({ id: deviceAssets.id, name: deviceAssets.name }).from(deviceAssets).where(inArray(deviceAssets.id, deviceIds))
: [];
return toMaintenanceTicket(ticket, new Map(deviceRows.map((device) => [device.id, device.name])));
}

200
modules/devices/types.ts Normal file
View File

@@ -0,0 +1,200 @@
export const DEVICE_ASSET_STATUS_VALUES = ["active", "maintenance", "disabled", "retired"] as const;
export type DeviceAssetStatus = (typeof DEVICE_ASSET_STATUS_VALUES)[number];
export const MAINTENANCE_TICKET_STATUS_VALUES = ["open", "assigned", "resolved", "closed", "cancelled"] as const;
export type MaintenanceTicketStatus = (typeof MAINTENANCE_TICKET_STATUS_VALUES)[number];
export const MAINTENANCE_TICKET_PRIORITY_VALUES = ["low", "normal", "high", "urgent"] as const;
export type MaintenanceTicketPriority = (typeof MAINTENANCE_TICKET_PRIORITY_VALUES)[number];
export const DEVICE_ASSET_STATUS_LABELS: Record<DeviceAssetStatus, string> = {
active: "运行中",
maintenance: "维护中",
disabled: "停用",
retired: "已退役",
};
export const MAINTENANCE_TICKET_STATUS_LABELS: Record<MaintenanceTicketStatus, string> = {
open: "待处理",
assigned: "已派单",
resolved: "已解决",
closed: "已关闭",
cancelled: "已取消",
};
export const MAINTENANCE_TICKET_PRIORITY_LABELS: Record<MaintenanceTicketPriority, string> = {
low: "低",
normal: "普通",
high: "高",
urgent: "紧急",
};
export type DeviceAsset = {
id: string;
organizationId: string;
name: string;
code: string;
category: string;
location: string;
status: DeviceAssetStatus;
lastInspectedAt?: string;
notes: string;
createdAt: string;
updatedAt: string;
};
export type MaintenanceTicket = {
id: string;
organizationId: string;
deviceId?: string;
deviceName: string;
title: string;
description: string;
priority: MaintenanceTicketPriority;
status: MaintenanceTicketStatus;
assigneeLabel: string;
resolutionNotes: string;
resolvedAt?: string;
createdAt: string;
updatedAt: string;
};
export type DeviceOperationsData = {
metrics: {
activeDevices: number;
maintenanceDevices: number;
openTickets: number;
urgentTickets: number;
};
devices: DeviceAsset[];
tickets: MaintenanceTicket[];
};
export type DeviceAssetInput = {
name: string;
code: string;
category: string;
location: string;
status: DeviceAssetStatus;
lastInspectedAt?: string;
notes: string;
};
export type MaintenanceTicketInput = {
deviceId?: string;
title: string;
description: string;
priority: MaintenanceTicketPriority;
status: MaintenanceTicketStatus;
assigneeLabel: string;
resolutionNotes: string;
};
type ValidationSuccess<T> = {
success: true;
data: T;
};
type ValidationFailure = {
success: false;
reason: string;
};
export type ValidationResult<T> = ValidationSuccess<T> | ValidationFailure;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readString(source: Record<string, unknown>, key: string): string {
const value = source[key];
return typeof value === "string" ? value.trim() : "";
}
function readOptionalString(source: Record<string, unknown>, key: string): string | undefined {
const value = readString(source, key);
return value || undefined;
}
function readEnum<T extends string>(value: unknown, values: readonly T[]): T | null {
return typeof value === "string" ? values.find((item) => item === value) ?? null : null;
}
function validateIsoDate(value: string | undefined, label: string): ValidationResult<string | undefined> {
if (!value) {
return { success: true, data: undefined };
}
return Number.isNaN(new Date(value).getTime())
? { success: false, reason: `${label}无效` }
: { success: true, data: value };
}
export function validateDeviceAssetInput(value: unknown): ValidationResult<DeviceAssetInput> {
if (!isRecord(value)) {
return { success: false, reason: "请求数据格式无效" };
}
const name = readString(value, "name");
const code = readString(value, "code");
if (!name || !code) {
return { success: false, reason: "设备名称和编号不能为空" };
}
const status = readEnum(value.status, DEVICE_ASSET_STATUS_VALUES);
if (!status) {
return { success: false, reason: "设备状态无效" };
}
const lastInspectedAt = validateIsoDate(readOptionalString(value, "lastInspectedAt"), "最近巡检时间");
if (!lastInspectedAt.success) {
return lastInspectedAt;
}
return {
success: true,
data: {
name,
code,
category: readString(value, "category"),
location: readString(value, "location"),
status,
lastInspectedAt: lastInspectedAt.data,
notes: readString(value, "notes"),
},
};
}
export function validateMaintenanceTicketInput(value: unknown): ValidationResult<MaintenanceTicketInput> {
if (!isRecord(value)) {
return { success: false, reason: "请求数据格式无效" };
}
const title = readString(value, "title");
if (!title) {
return { success: false, reason: "工单标题不能为空" };
}
const priority = readEnum(value.priority, MAINTENANCE_TICKET_PRIORITY_VALUES);
if (!priority) {
return { success: false, reason: "工单优先级无效" };
}
const status = readEnum(value.status, MAINTENANCE_TICKET_STATUS_VALUES);
if (!status) {
return { success: false, reason: "工单状态无效" };
}
return {
success: true,
data: {
deviceId: readOptionalString(value, "deviceId"),
title,
description: readString(value, "description"),
priority,
status,
assigneeLabel: readString(value, "assigneeLabel"),
resolutionNotes: readString(value, "resolutionNotes"),
},
};
}

View File

@@ -558,11 +558,11 @@ export function EldersClient({ elderContexts = {}, initialElders, permissions }:
</Dialog>
<Dialog
className="max-w-md"
description={deleteTarget ? `确认删除 ${deleteTarget.name} 的档案?该操作会写入审计日志。` : undefined}
onClose={() => setDeleteTarget(null)}
open={deleteTarget !== null}
title="删除确认"
width="sm"
>
<div className="flex flex-col gap-4">
{deleteTarget ? (

View File

@@ -249,7 +249,7 @@ export function EmergencyWorkspaceClient({ canManage, initialData }: EmergencyWo
</CardContent>
</Card>
<Dialog className="max-w-xl" description="手工登记安全、应急或巡检异常事件。" onClose={() => setIsCreateOpen(false)} open={isCreateOpen} title="新建应急事件">
<Dialog description="手工登记安全、应急或巡检异常事件。" onClose={() => setIsCreateOpen(false)} open={isCreateOpen} title="新建应急事件">
<form className="grid gap-3" onSubmit={createIncident}>
<Input label="事件标题" onChange={(event) => setCreateForm((current) => ({ ...current, title: event.target.value }))} required value={createForm.title} />
<Select

View File

@@ -6,9 +6,11 @@ import { BedDouble, DoorOpen, History, MoveRight, UserCheck, UserMinus, Wrench }
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 { Textarea } from "@/components/ui/input";
import { Select, type SelectOption } from "@/components/ui/select";
import { Table } from "@/components/ui/table";
import { cn } from "@/lib/utils";
import type { ApiResult } from "@/modules/core/server/api";
import type { Admission, BedStatus, FacilityBed, Permission, Room } from "@/modules/core/types";
import type { Elder } from "@/modules/elders/types";
@@ -24,6 +26,11 @@ type BedsWorkspaceProps = {
};
type WorkspaceTab = "overview" | "operations" | "history";
type BedDetailTab = "context" | "records" | "actions";
type RoomView = {
room: Room;
beds: FacilityBed[];
};
const workspaceTabs: Array<{ value: WorkspaceTab; label: string }> = [
{ value: "overview", label: "概览" },
@@ -31,6 +38,12 @@ const workspaceTabs: Array<{ value: WorkspaceTab; label: string }> = [
{ value: "history", label: "记录" },
];
const bedDetailTabs: Array<{ value: BedDetailTab; label: string }> = [
{ value: "context", label: "上下文" },
{ value: "records", label: "流水记录" },
{ value: "actions", label: "专项办理" },
];
const bedStatusLabels: Record<BedStatus, string> = {
available: "空闲",
occupied: "占用",
@@ -44,6 +57,13 @@ const admissionStatusLabels: Record<Admission["status"], string> = {
discharged: "已退住",
};
const contextKindLabels: Record<OperationalContextLine["kind"], string> = {
admission: "入住",
care: "照护",
health: "健康",
incident: "预警",
};
function formatDateTime(value: string): string {
return new Date(value).toLocaleString("zh-CN");
}
@@ -65,6 +85,24 @@ function admissionBedLabel(admission: Admission): string {
return `${admission.roomName}-${admission.bedCode}`;
}
function buildRoomViews(rooms: Room[], beds: FacilityBed[]): RoomView[] {
const bedsByRoomId = new Map<string, FacilityBed[]>();
for (const bed of beds) {
const roomBeds = bedsByRoomId.get(bed.roomId) ?? [];
roomBeds.push(bed);
bedsByRoomId.set(bed.roomId, roomBeds);
}
return [...rooms]
.sort((first, second) => first.code.localeCompare(second.code, "zh-CN", { numeric: true }))
.map((room) => ({
room,
beds: (bedsByRoomId.get(room.id) ?? []).sort((first, second) =>
first.code.localeCompare(second.code, "zh-CN", { numeric: true }),
),
}));
}
function makeSelectOptions(items: Array<{ id: string; label: string }>, emptyLabel: string): SelectOption[] {
return [
{ value: "", label: emptyLabel, disabled: true },
@@ -72,6 +110,32 @@ function makeSelectOptions(items: Array<{ id: string; label: string }>, emptyLab
];
}
function bedTileClass(status: BedStatus): string {
switch (status) {
case "available":
return "border-emerald-200 bg-emerald-50 text-emerald-900 hover:border-emerald-400";
case "occupied":
return "border-zinc-200 bg-zinc-50 text-zinc-900 hover:border-zinc-400";
case "maintenance":
return "border-amber-200 bg-amber-50 text-amber-950 hover:border-amber-400";
case "disabled":
return "border-rose-200 bg-rose-50 text-rose-950 hover:border-rose-400";
}
}
function statusDotClass(status: BedStatus): string {
switch (status) {
case "available":
return "bg-emerald-500";
case "occupied":
return "bg-zinc-500";
case "maintenance":
return "bg-amber-500";
case "disabled":
return "bg-rose-500";
}
}
export function BedsWorkspace({
bedContexts = {},
initialRooms,
@@ -95,6 +159,8 @@ export function BedsWorkspace({
const [transferNotes, setTransferNotes] = useState("");
const [dischargeAdmissionId, setDischargeAdmissionId] = useState("");
const [dischargeNotes, setDischargeNotes] = useState("");
const [selectedBedId, setSelectedBedId] = useState("");
const [bedDetailTab, setBedDetailTab] = useState<BedDetailTab>("context");
const canManageAdmissions = permissions.includes("admission:manage");
const activeAdmissions = useMemo(() => admissions.filter((admission) => admission.status === "active"), [admissions]);
@@ -104,6 +170,15 @@ export function BedsWorkspace({
const maintenanceBeds = beds.filter((bed) => bed.status === "maintenance").length;
const occupancyRate = beds.length > 0 ? Math.round((occupiedBeds / beds.length) * 100) : 0;
const admissionEligibleElders = elders.filter((elder) => !activeElderIds.has(elder.id));
const roomViews = useMemo(() => buildRoomViews(rooms, beds), [rooms, beds]);
const selectedBed = useMemo(() => beds.find((bed) => bed.id === selectedBedId), [beds, selectedBedId]);
const selectedAdmission = selectedBed
? activeAdmissions.find((admission) => admission.bedId === selectedBed.id)
: undefined;
const selectedBedAdmissions = selectedBed
? admissions.filter((admission) => admission.bedId === selectedBed.id)
: [];
const selectedBedContext = selectedBed ? bedContexts[selectedBed.id] : undefined;
const elderOptions = makeSelectOptions(
admissionEligibleElders.map((elder) => ({ id: elder.id, label: `${elder.name} / ${elder.phone}` })),
@@ -121,6 +196,17 @@ export function BedsWorkspace({
"选择在住记录",
);
function openBedDetail(bed: FacilityBed, initialTab: BedDetailTab = "context"): void {
setSelectedBedId(bed.id);
setBedDetailTab(initialTab);
}
function startAdmissionFromBed(bed: FacilityBed): void {
setAdmitBedId(bed.id);
setSelectedBedId(bed.id);
setBedDetailTab("actions");
}
async function refreshData(): Promise<void> {
const [eldersResponse, roomsResponse, bedsResponse, admissionsResponse] = await Promise.all([
fetch("/api/elders"),
@@ -187,6 +273,43 @@ export function BedsWorkspace({
setMessage(result.reason);
}
async function submitAdmissionForBed(event: FormEvent<HTMLFormElement>, bed: FacilityBed): Promise<void> {
event.preventDefault();
if (!canManageAdmissions) {
setMessage("当前角色无权办理入住");
return;
}
if (bed.status !== "available") {
setMessage("当前床位不可办理入住");
return;
}
if (!admitElderId) {
setMessage("请选择老人");
return;
}
setIsPending(true);
setMessage("");
const response = await fetch("/api/admissions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ elderId: admitElderId, bedId: bed.id, notes: admitNotes }),
});
const result = (await response.json()) as ApiResult<{ admission: Admission }>;
setIsPending(false);
if (!result.success) {
setMessage(result.reason);
return;
}
setAdmitElderId("");
setAdmitBedId("");
setAdmitNotes("");
await refreshData();
setMessage(result.reason);
}
async function submitTransfer(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
const admission = activeAdmissions.find((item) => item.id === transferAdmissionId);
@@ -221,6 +344,39 @@ export function BedsWorkspace({
setMessage("换床已办理");
}
async function submitTransferForAdmission(event: FormEvent<HTMLFormElement>, admission: Admission): Promise<void> {
event.preventDefault();
if (!canManageAdmissions) {
setMessage("当前角色无权办理换床");
return;
}
if (!transferBedId) {
setMessage("请选择新床位");
return;
}
setIsPending(true);
setMessage("");
const response = await fetch("/api/admissions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ elderId: admission.elderId, bedId: transferBedId, notes: transferNotes || "办理换床" }),
});
const result = (await response.json()) as ApiResult<{ admission: Admission }>;
setIsPending(false);
if (!result.success) {
setMessage(result.reason);
return;
}
setTransferAdmissionId("");
setTransferBedId("");
setTransferNotes("");
await refreshData();
setMessage("换床已办理");
}
async function submitDischarge(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
if (!canManageAdmissions) {
@@ -253,6 +409,34 @@ export function BedsWorkspace({
setMessage(result.reason);
}
async function submitDischargeForAdmission(event: FormEvent<HTMLFormElement>, admission: Admission): Promise<void> {
event.preventDefault();
if (!canManageAdmissions) {
setMessage("当前角色无权办理退住");
return;
}
setIsPending(true);
setMessage("");
const response = await fetch(`/api/admissions/${admission.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ notes: dischargeNotes }),
});
const result = (await response.json()) as ApiResult<{ admission: Admission }>;
setIsPending(false);
if (!result.success) {
setMessage(result.reason);
return;
}
setDischargeAdmissionId("");
setDischargeNotes("");
await refreshData();
setMessage(result.reason);
}
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<section className="flex border-b pb-4">
@@ -324,97 +508,65 @@ export function BedsWorkspace({
</Card>
</section>
<section className="grid gap-4 xl:grid-cols-[0.9fr_1.1fr]">
<section className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_20rem]">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-md border">
<Table className="w-full min-w-[620px] 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.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{rooms.map((room) => (
<Table.Row key={room.id}>
<Table.Cell className="px-4 py-3 font-medium">{room.name}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{room.code}</Table.Cell>
<Table.Cell className="px-4 py-3">{room.capacity}</Table.Cell>
<Table.Cell className="px-4 py-3">
{room.occupiedBedCount}/{room.bedCount}
</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={room.status === "active" ? "success" : "danger"}>{room.status}</Badge>
</Table.Cell>
</Table.Row>
<CardContent className="grid gap-4">
<StatusLegend />
{roomViews.length > 0 ? (
<div className="grid gap-4 lg:grid-cols-2">
{roomViews.map((view) => (
<RoomGrid
bedContexts={bedContexts}
key={view.room.id}
onSelectBed={openBedDetail}
onStartAdmission={startAdmissionFromBed}
roomView={view}
/>
))}
{rooms.length === 0 ? (
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={5}>
</Table.Cell>
</Table.Row>
) : null}
</Table.Body>
</Table>
</div>
) : (
<p className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground"></p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-md border">
<Table className="w-full min-w-[700px] 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">
{beds.map((bed) => {
const context = bedContexts[bed.id];
return (
<Table.Row key={bed.id}>
<Table.Cell className="px-4 py-3">{bed.roomName}</Table.Cell>
<Table.Cell className="px-4 py-3 font-medium">{bed.code}</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={bedBadgeVariant(bed.status)}>{bedStatusLabels[bed.status]}</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3">
<OccupantSummary context={context} fallbackName={bed.currentElderName} />
</Table.Cell>
<Table.Cell className="px-4 py-3">
<ContextLines lines={context?.lines ?? []} />
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{bed.notes || "-"}</Table.Cell>
</Table.Row>
);
})}
{beds.length === 0 ? (
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
</Table.Cell>
</Table.Row>
) : null}
</Table.Body>
</Table>
</div>
<form className="grid gap-3" onSubmit={(event) => void submitAdmission(event)}>
<Select
aria-label="入住老人"
disabled={!canManageAdmissions || isPending}
onValueChange={setAdmitElderId}
options={elderOptions}
value={admitElderId}
/>
<Select
aria-label="入住房间床位"
disabled={!canManageAdmissions || isPending}
onValueChange={setAdmitBedId}
options={bedOptions}
value={admitBedId}
/>
<Textarea
aria-label="入住备注"
disabled={!canManageAdmissions || isPending}
onChange={(event) => setAdmitNotes(event.target.value)}
placeholder="备注"
value={admitNotes}
/>
<Button disabled={!canManageAdmissions || isPending} type="submit">
<UserCheck className="size-4" aria-hidden="true" />
</Button>
</form>
</CardContent>
</Card>
</section>
@@ -583,6 +735,435 @@ export function BedsWorkspace({
</CardContent>
</Card>
) : null}
<BedDetailDialog
activeAdmission={selectedAdmission}
activeTab={bedDetailTab}
admissions={selectedBedAdmissions}
bed={selectedBed}
bedOptions={bedOptions}
canManageAdmissions={canManageAdmissions}
context={selectedBedContext}
dischargeNotes={dischargeNotes}
elderOptions={elderOptions}
isPending={isPending}
notes={admitNotes}
onClose={() => setSelectedBedId("")}
onDischargeNotesChange={setDischargeNotes}
onElderChange={setAdmitElderId}
onNotesChange={setAdmitNotes}
onSubmitAdmission={submitAdmissionForBed}
onSubmitDischarge={submitDischargeForAdmission}
onSubmitTransfer={submitTransferForAdmission}
onTabChange={setBedDetailTab}
onTransferBedChange={setTransferBedId}
onTransferNotesChange={setTransferNotes}
selectedElderId={admitElderId}
selectedTransferBedId={transferBedId}
transferNotes={transferNotes}
/>
</div>
);
}
function StatusLegend(): React.ReactElement {
return (
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground" aria-label="床位状态图例">
{(["available", "occupied", "maintenance", "disabled"] as BedStatus[]).map((status) => (
<span className="inline-flex items-center gap-2" key={status}>
<span className={cn("size-2 rounded-full", statusDotClass(status))} aria-hidden="true" />
{bedStatusLabels[status]}
</span>
))}
</div>
);
}
function RoomGrid({
bedContexts,
onSelectBed,
onStartAdmission,
roomView,
}: {
bedContexts: Record<string, BedOperationalContext>;
onSelectBed: (bed: FacilityBed, initialTab?: BedDetailTab) => void;
onStartAdmission: (bed: FacilityBed) => void;
roomView: RoomView;
}): React.ReactElement {
const availableCount = roomView.beds.filter((bed) => bed.status === "available").length;
const maintenanceCount = roomView.beds.filter((bed) => bed.status === "maintenance").length;
return (
<article className="rounded-lg border border-kumo-line bg-kumo-base p-4">
<header className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="truncate text-sm font-semibold text-kumo-strong">{roomView.room.name}</h3>
<p className="mt-1 text-xs text-muted-foreground">
{roomView.room.code} · {roomView.room.occupiedBedCount}/{roomView.room.bedCount} · {roomView.room.capacity}
</p>
</div>
<Badge variant={roomView.room.status === "active" ? "success" : "danger"}>
{roomView.room.status === "active" ? "启用" : "停用"}
</Badge>
</header>
<div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3">
{roomView.beds.map((bed) => (
<BedTile
bed={bed}
context={bedContexts[bed.id]}
key={bed.id}
onSelect={() => onSelectBed(bed)}
onStartAdmission={() => onStartAdmission(bed)}
/>
))}
{roomView.beds.length === 0 ? (
<p className="col-span-full rounded-md border border-dashed px-3 py-5 text-center text-xs text-muted-foreground"></p>
) : null}
</div>
<footer className="mt-4 flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>{availableCount} </span>
<span>{maintenanceCount} </span>
</footer>
</article>
);
}
function BedTile({
bed,
context,
onSelect,
onStartAdmission,
}: {
bed: FacilityBed;
context: BedOperationalContext | undefined;
onSelect: () => void;
onStartAdmission: () => void;
}): React.ReactElement {
const occupantName = context?.occupant?.elderName ?? bed.currentElderName;
const bedActionLabel = bed.status === "available" ? "可办理入住" : bedStatusLabels[bed.status];
return (
<button
className={cn(
"grid min-h-28 content-between rounded-md border p-3 text-left transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary",
bedTileClass(bed.status),
)}
onClick={bed.status === "available" ? onStartAdmission : onSelect}
type="button"
>
<span className="flex items-start justify-between gap-2">
<span className="min-w-0">
<span className="block truncate text-sm font-semibold">{bed.code}</span>
<span className="mt-1 block text-xs opacity-75">{bedStatusLabels[bed.status]}</span>
</span>
<span className={cn("mt-1 size-2 shrink-0 rounded-full", statusDotClass(bed.status))} aria-hidden="true" />
</span>
<span className="min-w-0">
<span className="block truncate text-xs font-medium">{occupantName ?? bedActionLabel}</span>
<span className="mt-1 block truncate text-[11px] opacity-75">{bed.notes || "无备注"}</span>
</span>
</button>
);
}
function BedDetailDialog({
activeAdmission,
activeTab,
admissions,
bed,
bedOptions,
canManageAdmissions,
context,
dischargeNotes,
elderOptions,
isPending,
notes,
onClose,
onDischargeNotesChange,
onElderChange,
onNotesChange,
onSubmitAdmission,
onSubmitDischarge,
onSubmitTransfer,
onTabChange,
onTransferBedChange,
onTransferNotesChange,
selectedElderId,
selectedTransferBedId,
transferNotes,
}: {
activeAdmission: Admission | undefined;
activeTab: BedDetailTab;
admissions: Admission[];
bed: FacilityBed | undefined;
bedOptions: SelectOption[];
canManageAdmissions: boolean;
context: BedOperationalContext | undefined;
dischargeNotes: string;
elderOptions: SelectOption[];
isPending: boolean;
notes: string;
onClose: () => void;
onDischargeNotesChange: (value: string) => void;
onElderChange: (value: string) => void;
onNotesChange: (value: string) => void;
onSubmitAdmission: (event: FormEvent<HTMLFormElement>, bed: FacilityBed) => Promise<void>;
onSubmitDischarge: (event: FormEvent<HTMLFormElement>, admission: Admission) => Promise<void>;
onSubmitTransfer: (event: FormEvent<HTMLFormElement>, admission: Admission) => Promise<void>;
onTabChange: (value: BedDetailTab) => void;
onTransferBedChange: (value: string) => void;
onTransferNotesChange: (value: string) => void;
selectedElderId: string;
selectedTransferBedId: string;
transferNotes: string;
}): React.ReactElement {
const title = bed ? `${bed.roomName} · ${bed.code}` : "床位详情";
const description = bed ? `${bedStatusLabels[bed.status]} · ${bed.notes || "无备注"}` : undefined;
return (
<Dialog description={description} onClose={onClose} open={Boolean(bed)} title={title} width="wide">
{bed ? (
<div className="grid gap-5">
<div className="flex flex-wrap gap-2" role="tablist" aria-label="床位详情">
{bedDetailTabs.map((tab) => (
<Button
aria-selected={activeTab === tab.value}
key={tab.value}
onClick={() => onTabChange(tab.value)}
role="tab"
size="sm"
type="button"
variant={activeTab === tab.value ? "default" : "outline"}
>
{tab.label}
</Button>
))}
</div>
{activeTab === "context" ? <BedContextPanel bed={bed} context={context} /> : null}
{activeTab === "records" ? <AdmissionRecords admissions={admissions} /> : null}
{activeTab === "actions" ? (
<BedActionsPanel
activeAdmission={activeAdmission}
bed={bed}
bedOptions={bedOptions}
canManageAdmissions={canManageAdmissions}
dischargeNotes={dischargeNotes}
elderOptions={elderOptions}
isPending={isPending}
notes={notes}
onDischargeNotesChange={onDischargeNotesChange}
onElderChange={onElderChange}
onNotesChange={onNotesChange}
onSubmitAdmission={onSubmitAdmission}
onSubmitDischarge={onSubmitDischarge}
onSubmitTransfer={onSubmitTransfer}
onTransferBedChange={onTransferBedChange}
onTransferNotesChange={onTransferNotesChange}
selectedElderId={selectedElderId}
selectedTransferBedId={selectedTransferBedId}
transferNotes={transferNotes}
/>
) : null}
</div>
) : null}
</Dialog>
);
}
function BedContextPanel({
bed,
context,
}: {
bed: FacilityBed;
context: BedOperationalContext | undefined;
}): React.ReactElement {
return (
<div className="grid gap-4 lg:grid-cols-[18rem_minmax(0,1fr)]">
<section className="rounded-lg border border-kumo-line p-4">
<p className="text-xs text-muted-foreground"></p>
<div className="mt-3">
<OccupantSummary context={context} fallbackName={bed.currentElderName} />
</div>
<dl className="mt-4 grid gap-3 text-sm">
<div>
<dt className="text-xs text-muted-foreground"></dt>
<dd className="mt-1">
<Badge variant={bedBadgeVariant(bed.status)}>{bedStatusLabels[bed.status]}</Badge>
</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground"></dt>
<dd className="mt-1 text-kumo-strong">{bed.notes || "-"}</dd>
</div>
</dl>
</section>
<section className="rounded-lg border border-kumo-line p-4">
<h3 className="text-sm font-semibold text-kumo-strong"></h3>
<div className="mt-3">
<ContextLines lines={context?.lines ?? []} expanded />
</div>
</section>
</div>
);
}
function AdmissionRecords({ admissions }: { admissions: Admission[] }): React.ReactElement {
return (
<div className="overflow-x-auto rounded-md border">
<Table className="w-full min-w-[720px] 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.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{admissions.map((admission) => (
<Table.Row key={admission.id}>
<Table.Cell className="px-4 py-3 font-medium">{admission.elderName}</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={admission.status === "active" ? "success" : "secondary"}>{admissionStatusLabels[admission.status]}</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDateTime(admission.admittedAt)}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">
{admission.dischargedAt ? formatDateTime(admission.dischargedAt) : "-"}
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{admission.notes || "-"}</Table.Cell>
</Table.Row>
))}
{admissions.length === 0 ? (
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={5}>
</Table.Cell>
</Table.Row>
) : null}
</Table.Body>
</Table>
</div>
);
}
function BedActionsPanel({
activeAdmission,
bed,
bedOptions,
canManageAdmissions,
dischargeNotes,
elderOptions,
isPending,
notes,
onDischargeNotesChange,
onElderChange,
onNotesChange,
onSubmitAdmission,
onSubmitDischarge,
onSubmitTransfer,
onTransferBedChange,
onTransferNotesChange,
selectedElderId,
selectedTransferBedId,
transferNotes,
}: {
activeAdmission: Admission | undefined;
bed: FacilityBed;
bedOptions: SelectOption[];
canManageAdmissions: boolean;
dischargeNotes: string;
elderOptions: SelectOption[];
isPending: boolean;
notes: string;
onDischargeNotesChange: (value: string) => void;
onElderChange: (value: string) => void;
onNotesChange: (value: string) => void;
onSubmitAdmission: (event: FormEvent<HTMLFormElement>, bed: FacilityBed) => Promise<void>;
onSubmitDischarge: (event: FormEvent<HTMLFormElement>, admission: Admission) => Promise<void>;
onSubmitTransfer: (event: FormEvent<HTMLFormElement>, admission: Admission) => Promise<void>;
onTransferBedChange: (value: string) => void;
onTransferNotesChange: (value: string) => void;
selectedElderId: string;
selectedTransferBedId: string;
transferNotes: string;
}): React.ReactElement {
if (bed.status === "available") {
return (
<form className="grid gap-3 rounded-lg border border-kumo-line p-4" onSubmit={(event) => void onSubmitAdmission(event, bed)}>
<h3 className="text-sm font-semibold text-kumo-strong"></h3>
<Select
aria-label="入住老人"
disabled={!canManageAdmissions || isPending}
onValueChange={onElderChange}
options={elderOptions}
value={selectedElderId}
/>
<Textarea
aria-label="入住备注"
disabled={!canManageAdmissions || isPending}
onChange={(event) => onNotesChange(event.target.value)}
placeholder="备注"
value={notes}
/>
<Button disabled={!canManageAdmissions || isPending} type="submit">
<UserCheck className="size-4" aria-hidden="true" />
</Button>
</form>
);
}
if (!activeAdmission) {
return <p className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground"></p>;
}
return (
<div className="grid gap-4 lg:grid-cols-2">
<form className="grid gap-3 rounded-lg border border-kumo-line p-4" onSubmit={(event) => void onSubmitTransfer(event, activeAdmission)}>
<h3 className="text-sm font-semibold text-kumo-strong"></h3>
<p className="text-xs text-muted-foreground">
{activeAdmission.elderName} / {admissionBedLabel(activeAdmission)}
</p>
<Select
aria-label="换床新床位"
disabled={!canManageAdmissions || isPending}
onValueChange={onTransferBedChange}
options={bedOptions}
value={selectedTransferBedId}
/>
<Textarea
aria-label="换床备注"
disabled={!canManageAdmissions || isPending}
onChange={(event) => onTransferNotesChange(event.target.value)}
placeholder="备注"
value={transferNotes}
/>
<Button disabled={!canManageAdmissions || isPending} type="submit" variant="outline">
<MoveRight className="size-4" aria-hidden="true" />
</Button>
</form>
<form className="grid gap-3 rounded-lg border border-kumo-line p-4" onSubmit={(event) => void onSubmitDischarge(event, activeAdmission)}>
<h3 className="text-sm font-semibold text-kumo-strong">退</h3>
<p className="text-xs text-muted-foreground">退</p>
<Textarea
aria-label="退住备注"
disabled={!canManageAdmissions || isPending}
onChange={(event) => onDischargeNotesChange(event.target.value)}
placeholder="备注"
value={dischargeNotes}
/>
<Button disabled={!canManageAdmissions || isPending} type="submit" variant="destructive">
<UserMinus className="size-4" aria-hidden="true" />
退
</Button>
</form>
</div>
);
}
@@ -597,7 +1178,7 @@ function contextToneClass(tone: OperationalContextLine["tone"]): string {
return "text-muted-foreground";
}
function ContextLines({ lines }: { lines: OperationalContextLine[] }): React.ReactElement {
function ContextLines({ expanded = false, lines }: { expanded?: boolean; lines: OperationalContextLine[] }): React.ReactElement {
if (lines.length === 0) {
return <span className="text-muted-foreground">-</span>;
}
@@ -605,8 +1186,11 @@ function ContextLines({ lines }: { lines: OperationalContextLine[] }): React.Rea
return (
<div className="grid gap-1">
{lines.map((line) => (
<p className={`max-w-[260px] truncate text-xs ${contextToneClass(line.tone)}`} key={`${line.kind}-${line.label}-${line.value}`}>
<span className="font-medium">{line.label}</span> {line.value}
<p
className={cn(expanded ? "text-sm" : "max-w-[260px] truncate text-xs", contextToneClass(line.tone))}
key={`${line.kind}-${line.label}-${line.value}`}
>
<span className="font-medium">{contextKindLabels[line.kind]} · {line.label}</span> {line.value}
</p>
))}
</div>

View File

@@ -0,0 +1,181 @@
"use client";
import { FormEvent, useMemo, useState } from "react";
import { CalendarCheck, MessageSquareText, Search, UserRoundCheck } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, 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 { FamilyContact, FamilyContactInput, FamilyContactStatus, FamilyFeedback, FamilyFeedbackInput, FamilyFeedbackStatus, FamilyFeedbackType, FamilyServiceData, FamilyVisitAppointment, FamilyVisitInput, FamilyVisitStatus } from "@/modules/family/types";
import { FAMILY_CONTACT_STATUS_LABELS, FAMILY_CONTACT_STATUS_VALUES, FAMILY_FEEDBACK_STATUS_LABELS, FAMILY_FEEDBACK_STATUS_VALUES, FAMILY_FEEDBACK_TYPE_LABELS, FAMILY_FEEDBACK_TYPE_VALUES, FAMILY_VISIT_STATUS_LABELS, FAMILY_VISIT_STATUS_VALUES } from "@/modules/family/types";
type Props = { canManage: boolean; initialData: FamilyServiceData };
type DialogState =
| { kind: "contact"; mode: "create"; value: FamilyContactInput }
| { kind: "contact"; mode: "edit"; id: string; value: FamilyContactInput }
| { kind: "visit"; mode: "create"; value: FamilyVisitInput }
| { kind: "visit"; mode: "edit"; id: string; value: FamilyVisitInput }
| { kind: "feedback"; mode: "create"; value: FamilyFeedbackInput }
| { kind: "feedback"; mode: "edit"; id: string; value: FamilyFeedbackInput };
const emptyContact: FamilyContactInput = { elderId: "", name: "", relationship: "", phone: "", status: "active", notes: "" };
const emptyVisit: FamilyVisitInput = { elderId: "", contactId: undefined, scheduledAt: "", status: "requested", notes: "" };
const emptyFeedback: FamilyFeedbackInput = { elderId: "", contactId: undefined, feedbackType: "service", content: "", status: "open", responseNotes: "" };
function formatDateTime(value: string | undefined): string {
return value ? new Date(value).toLocaleString("zh-CN") : "-";
}
function toLocalDateTime(value: string | undefined): string {
return value ? value.slice(0, 16) : "";
}
function contactToInput(contact: FamilyContact): FamilyContactInput {
return { elderId: contact.elderId, name: contact.name, relationship: contact.relationship, phone: contact.phone, status: contact.status, notes: contact.notes };
}
function visitToInput(visit: FamilyVisitAppointment): FamilyVisitInput {
return { elderId: visit.elderId, contactId: visit.contactId, scheduledAt: visit.scheduledAt, status: visit.status, notes: visit.notes };
}
function feedbackToInput(feedback: FamilyFeedback): FamilyFeedbackInput {
return { elderId: feedback.elderId, contactId: feedback.contactId, feedbackType: feedback.feedbackType, content: feedback.content, status: feedback.status, responseNotes: feedback.responseNotes };
}
function badgeVariant(value: string): "secondary" | "success" | "warning" {
if (value === "active" || value === "approved" || value === "completed" || value === "resolved" || value === "closed") return "success";
if (value === "requested" || value === "open" || value === "in_progress") return "warning";
return "secondary";
}
export function FamilyWorkspaceClient({ canManage, initialData }: Props): React.ReactElement {
const [data, setData] = useState(initialData);
const [query, setQuery] = useState("");
const [dialog, setDialog] = useState<DialogState | undefined>();
const [message, setMessage] = useState("");
const [isPending, setIsPending] = useState(false);
const normalizedQuery = query.trim().toLowerCase();
const filteredContacts = useMemo(() => data.contacts.filter((item) => !normalizedQuery || [item.elderName, item.name, item.relationship, item.phone, item.notes].join(" ").toLowerCase().includes(normalizedQuery)), [data.contacts, normalizedQuery]);
const filteredVisits = useMemo(() => data.visits.filter((item) => !normalizedQuery || [item.elderName, item.contactName, item.notes].join(" ").toLowerCase().includes(normalizedQuery)), [data.visits, normalizedQuery]);
const filteredFeedback = useMemo(() => data.feedback.filter((item) => !normalizedQuery || [item.elderName, item.contactName, item.content, item.responseNotes].join(" ").toLowerCase().includes(normalizedQuery)), [data.feedback, normalizedQuery]);
async function refreshData(): Promise<void> {
const response = await fetch("/api/family/contacts");
const result = (await response.json()) as ApiResult<{ data: FamilyServiceData }>;
if (result.success) setData(result.data);
}
async function submitDialog(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
if (!dialog || !canManage) {
setMessage("当前角色无权维护家属服务");
return;
}
const isCreate = dialog.mode === "create";
const path =
dialog.kind === "contact"
? isCreate ? "/api/family/contacts" : `/api/family/contacts/${dialog.id}`
: dialog.kind === "visit"
? isCreate ? "/api/family/visits" : `/api/family/visits/${dialog.id}`
: isCreate ? "/api/family/feedback" : `/api/family/feedback/${dialog.id}`;
setIsPending(true);
const response = await fetch(path, { method: isCreate ? "POST" : "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(dialog.value) });
const result = (await response.json()) as ApiResult<Record<string, unknown>>;
setIsPending(false);
setMessage(result.reason);
if (result.success) {
setDialog(undefined);
await refreshData();
}
}
async function remove(kind: "contact" | "visit" | "feedback", id: string): Promise<void> {
if (!canManage) {
setMessage("当前角色无权删除家属服务记录");
return;
}
const path = kind === "contact" ? `/api/family/contacts/${id}` : kind === "visit" ? `/api/family/visits/${id}` : `/api/family/feedback/${id}`;
setIsPending(true);
const response = await fetch(path, { method: "DELETE" });
const result = (await response.json()) as ApiResult<Record<string, unknown>>;
setIsPending(false);
setMessage(result.reason);
if (result.success) await refreshData();
}
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard icon={UserRoundCheck} label="有效联系人" value={data.metrics.activeContacts} />
<MetricCard icon={CalendarCheck} label="待确认探访" value={data.metrics.pendingVisits} />
<MetricCard icon={MessageSquareText} label="待处理反馈" value={data.metrics.openFeedback} />
<MetricCard icon={MessageSquareText} label="已处理反馈" value={data.metrics.resolvedFeedback} />
</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>
<Button disabled={!canManage} onClick={() => setDialog({ kind: "contact", mode: "create", value: emptyContact })} type="button"></Button>
<Button disabled={!canManage} onClick={() => setDialog({ kind: "visit", mode: "create", value: emptyVisit })} type="button" variant="outline">访</Button>
<Button disabled={!canManage} onClick={() => setDialog({ kind: "feedback", mode: "create", value: emptyFeedback })} type="button" variant="outline"></Button>
</div>
</section>
{message ? <p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">{message}</p> : null}
<DataTable title="家属联系人" heads={["联系人", "老人", "状态", "备注", "操作"]}>
{filteredContacts.map((contact) => <Table.Row key={contact.id}><Table.Cell className="px-4 py-3"><p className="font-medium">{contact.name}</p><p className="text-xs text-muted-foreground">{contact.relationship} / {contact.phone}</p></Table.Cell><Table.Cell className="px-4 py-3 text-muted-foreground">{contact.elderName}</Table.Cell><Table.Cell className="px-4 py-3"><Badge variant={badgeVariant(contact.status)}>{FAMILY_CONTACT_STATUS_LABELS[contact.status]}</Badge></Table.Cell><Table.Cell className="px-4 py-3 text-muted-foreground">{contact.notes || "-"}</Table.Cell><Table.Cell className="px-4 py-3"><RowActions disabled={!canManage || isPending} onDelete={() => remove("contact", contact.id)} onEdit={() => setDialog({ kind: "contact", mode: "edit", id: contact.id, value: contactToInput(contact) })} /></Table.Cell></Table.Row>)}
{filteredContacts.length === 0 ? <EmptyRow colSpan={5} text="暂无家属联系人" /> : null}
</DataTable>
<DataTable title="探访预约" heads={["预约", "家属", "状态", "备注", "操作"]}>
{filteredVisits.map((visit) => <Table.Row key={visit.id}><Table.Cell className="px-4 py-3"><p className="font-medium">{visit.elderName}</p><p className="text-xs text-muted-foreground">{formatDateTime(visit.scheduledAt)}</p></Table.Cell><Table.Cell className="px-4 py-3 text-muted-foreground">{visit.contactName || "-"}</Table.Cell><Table.Cell className="px-4 py-3"><Badge variant={badgeVariant(visit.status)}>{FAMILY_VISIT_STATUS_LABELS[visit.status]}</Badge></Table.Cell><Table.Cell className="px-4 py-3 text-muted-foreground">{visit.notes || "-"}</Table.Cell><Table.Cell className="px-4 py-3"><RowActions disabled={!canManage || isPending} onDelete={() => remove("visit", visit.id)} onEdit={() => setDialog({ kind: "visit", mode: "edit", id: visit.id, value: visitToInput(visit) })} /></Table.Cell></Table.Row>)}
{filteredVisits.length === 0 ? <EmptyRow colSpan={5} text="暂无探访预约" /> : null}
</DataTable>
<DataTable title="服务反馈" heads={["反馈", "家属", "类型", "状态", "操作"]}>
{filteredFeedback.map((feedback) => <Table.Row key={feedback.id}><Table.Cell className="px-4 py-3"><p className="font-medium">{feedback.elderName}</p><p className="text-xs text-muted-foreground">{feedback.content}</p></Table.Cell><Table.Cell className="px-4 py-3 text-muted-foreground">{feedback.contactName || "-"}</Table.Cell><Table.Cell className="px-4 py-3 text-muted-foreground">{FAMILY_FEEDBACK_TYPE_LABELS[feedback.feedbackType]}</Table.Cell><Table.Cell className="px-4 py-3"><Badge variant={badgeVariant(feedback.status)}>{FAMILY_FEEDBACK_STATUS_LABELS[feedback.status]}</Badge></Table.Cell><Table.Cell className="px-4 py-3"><RowActions disabled={!canManage || isPending} onDelete={() => remove("feedback", feedback.id)} onEdit={() => setDialog({ kind: "feedback", mode: "edit", id: feedback.id, value: feedbackToInput(feedback) })} /></Table.Cell></Table.Row>)}
{filteredFeedback.length === 0 ? <EmptyRow colSpan={5} text="暂无服务反馈" /> : null}
</DataTable>
<Dialog onClose={() => setDialog(undefined)} open={dialog !== undefined} title={dialog?.kind === "contact" ? "家属联系人" : dialog?.kind === "visit" ? "探访预约" : "服务反馈"} width="lg">
{dialog ? <form className="grid gap-3" onSubmit={submitDialog}><FamilyForm contacts={data.contacts} dialog={dialog} elders={data.elderOptions} setDialog={setDialog} /><div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"><Button disabled={isPending} onClick={() => setDialog(undefined)} type="button" variant="outline"></Button><Button disabled={isPending || !canManage} type="submit"></Button></div></form> : null}
</Dialog>
</div>
);
}
function FamilyForm({ contacts, dialog, elders, setDialog }: { contacts: FamilyContact[]; dialog: DialogState; elders: Array<{ id: string; name: string }>; setDialog: (dialog: DialogState) => void }): React.ReactElement {
const elderOptions = [{ value: "", label: "选择老人", disabled: true }, ...elders.map((elder) => ({ value: elder.id, label: elder.name }))];
if (dialog.kind === "contact") {
const value = dialog.value;
const update = (next: Partial<FamilyContactInput>) => setDialog({ ...dialog, value: { ...value, ...next } });
return <><Select aria-label="老人" onValueChange={(elderId) => update({ elderId })} options={elderOptions} value={value.elderId} /><Input label="家属姓名" onChange={(event) => update({ name: event.target.value })} required value={value.name} /><Input label="关系" onChange={(event) => update({ relationship: event.target.value })} required value={value.relationship} /><Input label="手机号" onChange={(event) => update({ phone: event.target.value })} required value={value.phone} /><Select aria-label="状态" onValueChange={(status) => update({ status: status as FamilyContactStatus })} options={FAMILY_CONTACT_STATUS_VALUES.map((status) => ({ value: status, label: FAMILY_CONTACT_STATUS_LABELS[status] }))} value={value.status} /><Textarea label="备注" onChange={(event) => update({ notes: event.target.value })} value={value.notes} /></>;
}
const contactOptions = [{ value: "", label: "不关联联系人" }, ...contacts.map((contact) => ({ value: contact.id, label: `${contact.name} / ${contact.elderName}` }))];
if (dialog.kind === "visit") {
const value = dialog.value;
const update = (next: Partial<FamilyVisitInput>) => setDialog({ ...dialog, value: { ...value, ...next } });
return <><Select aria-label="老人" onValueChange={(elderId) => update({ elderId })} options={elderOptions} value={value.elderId} /><Select aria-label="家属联系人" onValueChange={(contactId) => update({ contactId: contactId || undefined })} options={contactOptions} value={value.contactId ?? ""} /><Input label="预约时间" onChange={(event) => update({ scheduledAt: event.target.value })} required type="datetime-local" value={toLocalDateTime(value.scheduledAt)} /><Select aria-label="状态" onValueChange={(status) => update({ status: status as FamilyVisitStatus })} options={FAMILY_VISIT_STATUS_VALUES.map((status) => ({ value: status, label: FAMILY_VISIT_STATUS_LABELS[status] }))} value={value.status} /><Textarea label="备注" onChange={(event) => update({ notes: event.target.value })} value={value.notes} /></>;
}
const value = dialog.value;
const update = (next: Partial<FamilyFeedbackInput>) => setDialog({ ...dialog, value: { ...value, ...next } });
return <><Select aria-label="老人" onValueChange={(elderId) => update({ elderId })} options={elderOptions} value={value.elderId} /><Select aria-label="家属联系人" onValueChange={(contactId) => update({ contactId: contactId || undefined })} options={contactOptions} value={value.contactId ?? ""} /><Select aria-label="反馈类型" onValueChange={(feedbackType) => update({ feedbackType: feedbackType as FamilyFeedbackType })} options={FAMILY_FEEDBACK_TYPE_VALUES.map((item) => ({ value: item, label: FAMILY_FEEDBACK_TYPE_LABELS[item] }))} value={value.feedbackType} /><Select aria-label="状态" onValueChange={(status) => update({ status: status as FamilyFeedbackStatus })} options={FAMILY_FEEDBACK_STATUS_VALUES.map((status) => ({ value: status, label: FAMILY_FEEDBACK_STATUS_LABELS[status] }))} value={value.status} /><Textarea label="反馈内容" onChange={(event) => update({ content: event.target.value })} required value={value.content} /><Textarea label="处理记录" onChange={(event) => update({ responseNotes: event.target.value })} value={value.responseNotes} /></>;
}
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><p className="text-sm text-muted-foreground">{label}</p><CardTitle className="mt-2 text-3xl">{value}</CardTitle></div><Icon className="size-5 text-primary" aria-hidden={true} /></CardHeader></Card>;
}
function DataTable({ children, heads, title }: { children: React.ReactNode; heads: string[]; title: string }): React.ReactElement {
return <Card><CardHeader><CardTitle>{title}</CardTitle></CardHeader><CardContent className="overflow-x-auto"><Table className="w-full min-w-[900px] text-sm"><Table.Header className="bg-secondary text-left text-xs text-muted-foreground"><Table.Row>{heads.map((head) => <Table.Head className={head === "操作" ? "px-4 py-3 text-right" : "px-4 py-3"} key={head}>{head}</Table.Head>)}</Table.Row></Table.Header><Table.Body className="divide-y bg-card">{children}</Table.Body></Table></CardContent></Card>;
}
function EmptyRow({ colSpan, text }: { colSpan: number; text: string }): React.ReactElement {
return <Table.Row><Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={colSpan}>{text}</Table.Cell></Table.Row>;
}
function RowActions({ disabled, onDelete, onEdit }: { disabled: boolean; onDelete: () => void; onEdit: () => void }): React.ReactElement {
return <div className="flex justify-end gap-2"><Button disabled={disabled} onClick={onEdit} size="sm" type="button" variant="outline"></Button><Button disabled={disabled} onClick={onDelete} size="sm" type="button" variant="outline"></Button></div>;
}

View File

@@ -0,0 +1,340 @@
import { and, desc, eq, inArray } from "drizzle-orm";
import { getDatabase } from "@/modules/core/server/db";
import { accounts, elders, familyContacts, familyFeedback, familyVisitAppointments } from "@/modules/core/server/schema";
import type {
FamilyContact,
FamilyContactInput,
FamilyFeedback,
FamilyFeedbackInput,
FamilyServiceData,
FamilyVisitAppointment,
FamilyVisitInput,
} from "@/modules/family/types";
export type FamilyMutationFailure = {
success: false;
reason: string;
status: number;
};
export function isFamilyMutationFailure(value: unknown): value is FamilyMutationFailure {
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 toContact(row: typeof familyContacts.$inferSelect, elderNameById: Map<string, string>): FamilyContact {
return {
id: row.id,
organizationId: row.organizationId,
elderId: row.elderId,
elderName: elderNameById.get(row.elderId) ?? "",
name: row.name,
relationship: row.relationship,
phone: row.phone,
status: row.status,
notes: row.notes,
createdAt: iso(row.createdAt),
updatedAt: iso(row.updatedAt),
};
}
function toVisit(
row: typeof familyVisitAppointments.$inferSelect,
elderNameById: Map<string, string>,
contactNameById: Map<string, string>,
accountNameById: Map<string, string>,
): FamilyVisitAppointment {
const contactId = row.contactId ?? undefined;
const handledByAccountId = row.handledByAccountId ?? undefined;
return {
id: row.id,
organizationId: row.organizationId,
elderId: row.elderId,
elderName: elderNameById.get(row.elderId) ?? "",
contactId,
contactName: contactId ? contactNameById.get(contactId) ?? "" : "",
scheduledAt: iso(row.scheduledAt),
status: row.status,
notes: row.notes,
handledByAccountId,
handledByName: handledByAccountId ? accountNameById.get(handledByAccountId) : undefined,
handledAt: optionalIso(row.handledAt),
createdAt: iso(row.createdAt),
updatedAt: iso(row.updatedAt),
};
}
function toFeedback(
row: typeof familyFeedback.$inferSelect,
elderNameById: Map<string, string>,
contactNameById: Map<string, string>,
accountNameById: Map<string, string>,
): FamilyFeedback {
const contactId = row.contactId ?? undefined;
const handledByAccountId = row.handledByAccountId ?? undefined;
return {
id: row.id,
organizationId: row.organizationId,
elderId: row.elderId,
elderName: elderNameById.get(row.elderId) ?? "",
contactId,
contactName: contactId ? contactNameById.get(contactId) ?? "" : "",
feedbackType: row.feedbackType,
content: row.content,
status: row.status,
responseNotes: row.responseNotes,
handledByAccountId,
handledByName: handledByAccountId ? accountNameById.get(handledByAccountId) : undefined,
handledAt: optionalIso(row.handledAt),
createdAt: iso(row.createdAt),
updatedAt: iso(row.updatedAt),
};
}
async function validateElderAndContact(input: {
contactId?: string;
elderId: string;
organizationId: string;
}): Promise<{
contactNameById: Map<string, string>;
elderNameById: Map<string, string>;
} | FamilyMutationFailure> {
const database = getDatabase();
const [elderRows, contactRows] = await Promise.all([
database.select({ id: elders.id, name: elders.name }).from(elders).where(and(eq(elders.id, input.elderId), eq(elders.organizationId, input.organizationId))),
input.contactId
? database.select({ id: familyContacts.id, name: familyContacts.name }).from(familyContacts).where(and(eq(familyContacts.id, input.contactId), eq(familyContacts.organizationId, input.organizationId)))
: [],
]);
const elder = elderRows[0];
if (!elder) {
return { success: false, reason: "老人档案不存在", status: 404 };
}
if (input.contactId && !contactRows[0]) {
return { success: false, reason: "家属联系人不存在", status: 404 };
}
return {
elderNameById: new Map([[elder.id, elder.name]]),
contactNameById: new Map(contactRows.map((contact) => [contact.id, contact.name])),
};
}
export async function listFamilyServiceData(organizationId: string): Promise<FamilyServiceData> {
const database = getDatabase();
const [elderRows, contactRows, visitRows, feedbackRows] = await Promise.all([
database.select({ id: elders.id, name: elders.name }).from(elders).where(eq(elders.organizationId, organizationId)).orderBy(desc(elders.createdAt)),
database.select().from(familyContacts).where(eq(familyContacts.organizationId, organizationId)).orderBy(desc(familyContacts.updatedAt)),
database.select().from(familyVisitAppointments).where(eq(familyVisitAppointments.organizationId, organizationId)).orderBy(desc(familyVisitAppointments.scheduledAt)),
database.select().from(familyFeedback).where(eq(familyFeedback.organizationId, organizationId)).orderBy(desc(familyFeedback.updatedAt)),
]);
const contactIds = Array.from(
new Set(
[...visitRows.map((visit) => visit.contactId), ...feedbackRows.map((feedback) => feedback.contactId)].filter((id): id is string => typeof id === "string"),
),
);
const accountIds = Array.from(
new Set(
[...visitRows.map((visit) => visit.handledByAccountId), ...feedbackRows.map((feedback) => feedback.handledByAccountId)].filter(
(id): id is string => typeof id === "string",
),
),
);
const [linkedContactRows, accountRows] = await Promise.all([
contactIds.length > 0 ? database.select({ id: familyContacts.id, name: familyContacts.name }).from(familyContacts).where(inArray(familyContacts.id, contactIds)) : [],
accountIds.length > 0 ? database.select({ id: accounts.id, name: accounts.name }).from(accounts).where(inArray(accounts.id, accountIds)) : [],
]);
const elderNameById = new Map(elderRows.map((elder) => [elder.id, elder.name]));
const contactNameById = new Map([...contactRows.map((contact) => [contact.id, contact.name] as const), ...linkedContactRows.map((contact) => [contact.id, contact.name] as const)]);
const accountNameById = new Map(accountRows.map((account) => [account.id, account.name]));
return {
metrics: {
activeContacts: contactRows.filter((contact) => contact.status === "active").length,
pendingVisits: visitRows.filter((visit) => visit.status === "requested").length,
openFeedback: feedbackRows.filter((feedback) => feedback.status === "open" || feedback.status === "in_progress").length,
resolvedFeedback: feedbackRows.filter((feedback) => feedback.status === "resolved" || feedback.status === "closed").length,
},
elderOptions: elderRows,
contacts: contactRows.map((contact) => toContact(contact, elderNameById)),
visits: visitRows.map((visit) => toVisit(visit, elderNameById, contactNameById, accountNameById)),
feedback: feedbackRows.map((feedback) => toFeedback(feedback, elderNameById, contactNameById, accountNameById)),
};
}
export async function createFamilyContact(input: FamilyContactInput & { organizationId: string }): Promise<FamilyContact | FamilyMutationFailure> {
const maps = await validateElderAndContact({ elderId: input.elderId, organizationId: input.organizationId });
if (isFamilyMutationFailure(maps)) {
return maps;
}
const database = getDatabase();
const rows = await database.insert(familyContacts).values({ ...input }).returning();
const contact = rows[0];
return contact ? toContact(contact, maps.elderNameById) : { success: false, reason: "家属联系人创建失败", status: 500 };
}
export async function updateFamilyContact(input: FamilyContactInput & { id: string; organizationId: string }): Promise<FamilyContact | FamilyMutationFailure> {
const maps = await validateElderAndContact({ elderId: input.elderId, organizationId: input.organizationId });
if (isFamilyMutationFailure(maps)) {
return maps;
}
const database = getDatabase();
const rows = await database
.update(familyContacts)
.set({
elderId: input.elderId,
name: input.name,
relationship: input.relationship,
phone: input.phone,
status: input.status,
notes: input.notes,
updatedAt: new Date(),
})
.where(and(eq(familyContacts.id, input.id), eq(familyContacts.organizationId, input.organizationId)))
.returning();
const contact = rows[0];
return contact ? toContact(contact, maps.elderNameById) : { success: false, reason: "家属联系人不存在", status: 404 };
}
export async function deleteFamilyContact(input: { id: string; organizationId: string }): Promise<FamilyContact | FamilyMutationFailure> {
const database = getDatabase();
const rows = await database.delete(familyContacts).where(and(eq(familyContacts.id, input.id), eq(familyContacts.organizationId, input.organizationId))).returning();
const contact = rows[0];
return contact ? toContact(contact, new Map()) : { success: false, reason: "家属联系人不存在", status: 404 };
}
export async function createFamilyVisit(input: FamilyVisitInput & { accountId: string; organizationId: string }): Promise<FamilyVisitAppointment | FamilyMutationFailure> {
const maps = await validateElderAndContact(input);
if (isFamilyMutationFailure(maps)) {
return maps;
}
const database = getDatabase();
const handledAt = input.status === "approved" || input.status === "completed" || input.status === "cancelled" ? new Date() : undefined;
const rows = await database
.insert(familyVisitAppointments)
.values({
organizationId: input.organizationId,
elderId: input.elderId,
contactId: input.contactId,
scheduledAt: new Date(input.scheduledAt),
status: input.status,
notes: input.notes,
handledByAccountId: handledAt ? input.accountId : undefined,
handledAt,
})
.returning();
const visit = rows[0];
return visit ? toVisit(visit, maps.elderNameById, maps.contactNameById, new Map()) : { success: false, reason: "探访预约创建失败", status: 500 };
}
export async function updateFamilyVisit(input: FamilyVisitInput & { accountId: string; id: string; organizationId: string }): Promise<FamilyVisitAppointment | FamilyMutationFailure> {
const maps = await validateElderAndContact(input);
if (isFamilyMutationFailure(maps)) {
return maps;
}
const database = getDatabase();
const handledAt = input.status === "approved" || input.status === "completed" || input.status === "cancelled" ? new Date() : null;
const rows = await database
.update(familyVisitAppointments)
.set({
elderId: input.elderId,
contactId: input.contactId ?? null,
scheduledAt: new Date(input.scheduledAt),
status: input.status,
notes: input.notes,
handledByAccountId: handledAt ? input.accountId : null,
handledAt,
updatedAt: new Date(),
})
.where(and(eq(familyVisitAppointments.id, input.id), eq(familyVisitAppointments.organizationId, input.organizationId)))
.returning();
const visit = rows[0];
return visit ? toVisit(visit, maps.elderNameById, maps.contactNameById, new Map()) : { success: false, reason: "探访预约不存在", status: 404 };
}
export async function deleteFamilyVisit(input: { id: string; organizationId: string }): Promise<FamilyVisitAppointment | FamilyMutationFailure> {
const database = getDatabase();
const rows = await database
.delete(familyVisitAppointments)
.where(and(eq(familyVisitAppointments.id, input.id), eq(familyVisitAppointments.organizationId, input.organizationId)))
.returning();
const visit = rows[0];
return visit ? toVisit(visit, new Map(), new Map(), new Map()) : { success: false, reason: "探访预约不存在", status: 404 };
}
export async function createFamilyFeedback(input: FamilyFeedbackInput & { accountId: string; organizationId: string }): Promise<FamilyFeedback | FamilyMutationFailure> {
const maps = await validateElderAndContact(input);
if (isFamilyMutationFailure(maps)) {
return maps;
}
const database = getDatabase();
const handledAt = input.status === "resolved" || input.status === "closed" || input.status === "in_progress" ? new Date() : undefined;
const rows = await database
.insert(familyFeedback)
.values({
organizationId: input.organizationId,
elderId: input.elderId,
contactId: input.contactId,
feedbackType: input.feedbackType,
content: input.content,
status: input.status,
responseNotes: input.responseNotes,
handledByAccountId: handledAt ? input.accountId : undefined,
handledAt,
})
.returning();
const feedback = rows[0];
return feedback ? toFeedback(feedback, maps.elderNameById, maps.contactNameById, new Map()) : { success: false, reason: "家属反馈创建失败", status: 500 };
}
export async function updateFamilyFeedback(input: FamilyFeedbackInput & { accountId: string; id: string; organizationId: string }): Promise<FamilyFeedback | FamilyMutationFailure> {
const maps = await validateElderAndContact(input);
if (isFamilyMutationFailure(maps)) {
return maps;
}
const database = getDatabase();
const handledAt = input.status === "resolved" || input.status === "closed" || input.status === "in_progress" ? new Date() : null;
const rows = await database
.update(familyFeedback)
.set({
elderId: input.elderId,
contactId: input.contactId ?? null,
feedbackType: input.feedbackType,
content: input.content,
status: input.status,
responseNotes: input.responseNotes,
handledByAccountId: handledAt ? input.accountId : null,
handledAt,
updatedAt: new Date(),
})
.where(and(eq(familyFeedback.id, input.id), eq(familyFeedback.organizationId, input.organizationId)))
.returning();
const feedback = rows[0];
return feedback ? toFeedback(feedback, maps.elderNameById, maps.contactNameById, new Map()) : { success: false, reason: "家属反馈不存在", status: 404 };
}
export async function deleteFamilyFeedback(input: { id: string; organizationId: string }): Promise<FamilyFeedback | FamilyMutationFailure> {
const database = getDatabase();
const rows = await database.delete(familyFeedback).where(and(eq(familyFeedback.id, input.id), eq(familyFeedback.organizationId, input.organizationId))).returning();
const feedback = rows[0];
return feedback ? toFeedback(feedback, new Map(), new Map(), new Map()) : { success: false, reason: "家属反馈不存在", status: 404 };
}

246
modules/family/types.ts Normal file
View File

@@ -0,0 +1,246 @@
export const FAMILY_CONTACT_STATUS_VALUES = ["active", "suspended", "archived"] as const;
export type FamilyContactStatus = (typeof FAMILY_CONTACT_STATUS_VALUES)[number];
export const FAMILY_VISIT_STATUS_VALUES = ["requested", "approved", "completed", "cancelled"] as const;
export type FamilyVisitStatus = (typeof FAMILY_VISIT_STATUS_VALUES)[number];
export const FAMILY_FEEDBACK_TYPE_VALUES = ["service", "care", "meal", "environment", "other"] as const;
export type FamilyFeedbackType = (typeof FAMILY_FEEDBACK_TYPE_VALUES)[number];
export const FAMILY_FEEDBACK_STATUS_VALUES = ["open", "in_progress", "resolved", "closed"] as const;
export type FamilyFeedbackStatus = (typeof FAMILY_FEEDBACK_STATUS_VALUES)[number];
export const FAMILY_CONTACT_STATUS_LABELS: Record<FamilyContactStatus, string> = {
active: "有效",
suspended: "暂停",
archived: "归档",
};
export const FAMILY_VISIT_STATUS_LABELS: Record<FamilyVisitStatus, string> = {
requested: "待确认",
approved: "已确认",
completed: "已完成",
cancelled: "已取消",
};
export const FAMILY_FEEDBACK_TYPE_LABELS: Record<FamilyFeedbackType, string> = {
service: "服务",
care: "照护",
meal: "餐饮",
environment: "环境",
other: "其他",
};
export const FAMILY_FEEDBACK_STATUS_LABELS: Record<FamilyFeedbackStatus, string> = {
open: "待处理",
in_progress: "处理中",
resolved: "已解决",
closed: "已关闭",
};
export type FamilyContact = {
id: string;
organizationId: string;
elderId: string;
elderName: string;
name: string;
relationship: string;
phone: string;
status: FamilyContactStatus;
notes: string;
createdAt: string;
updatedAt: string;
};
export type FamilyVisitAppointment = {
id: string;
organizationId: string;
elderId: string;
elderName: string;
contactId?: string;
contactName: string;
scheduledAt: string;
status: FamilyVisitStatus;
notes: string;
handledByAccountId?: string;
handledByName?: string;
handledAt?: string;
createdAt: string;
updatedAt: string;
};
export type FamilyFeedback = {
id: string;
organizationId: string;
elderId: string;
elderName: string;
contactId?: string;
contactName: string;
feedbackType: FamilyFeedbackType;
content: string;
status: FamilyFeedbackStatus;
responseNotes: string;
handledByAccountId?: string;
handledByName?: string;
handledAt?: string;
createdAt: string;
updatedAt: string;
};
export type FamilyServiceData = {
metrics: {
activeContacts: number;
pendingVisits: number;
openFeedback: number;
resolvedFeedback: number;
};
elderOptions: Array<{ id: string; name: string }>;
contacts: FamilyContact[];
visits: FamilyVisitAppointment[];
feedback: FamilyFeedback[];
};
export type FamilyContactInput = {
elderId: string;
name: string;
relationship: string;
phone: string;
status: FamilyContactStatus;
notes: string;
};
export type FamilyVisitInput = {
elderId: string;
contactId?: string;
scheduledAt: string;
status: FamilyVisitStatus;
notes: string;
};
export type FamilyFeedbackInput = {
elderId: string;
contactId?: string;
feedbackType: FamilyFeedbackType;
content: string;
status: FamilyFeedbackStatus;
responseNotes: 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 readOptionalString(source: Record<string, unknown>, key: string): string | undefined {
const value = readString(source, key);
return value || undefined;
}
function readEnum<T extends string>(value: unknown, values: readonly T[]): T | null {
return typeof value === "string" ? values.find((item) => item === value) ?? null : null;
}
function validateRequiredDate(value: string, label: string): ValidationResult<string> {
return value && !Number.isNaN(new Date(value).getTime())
? { success: true, data: value }
: { success: false, reason: `${label}无效` };
}
export function validateFamilyContactInput(value: unknown): ValidationResult<FamilyContactInput> {
if (!isRecord(value)) {
return { success: false, reason: "请求数据格式无效" };
}
const elderId = readString(value, "elderId");
const name = readString(value, "name");
const relationship = readString(value, "relationship");
const phone = readString(value, "phone");
if (!elderId || !name || !relationship || !phone) {
return { success: false, reason: "老人、家属姓名、关系和手机号不能为空" };
}
const status = readEnum(value.status, FAMILY_CONTACT_STATUS_VALUES);
if (!status) {
return { success: false, reason: "家属联系人状态无效" };
}
return { success: true, data: { elderId, name, relationship, phone, status, notes: readString(value, "notes") } };
}
export function validateFamilyVisitInput(value: unknown): ValidationResult<FamilyVisitInput> {
if (!isRecord(value)) {
return { success: false, reason: "请求数据格式无效" };
}
const elderId = readString(value, "elderId");
if (!elderId) {
return { success: false, reason: "探访预约必须选择老人" };
}
const scheduledAt = validateRequiredDate(readString(value, "scheduledAt"), "预约时间");
if (!scheduledAt.success) {
return scheduledAt;
}
const status = readEnum(value.status, FAMILY_VISIT_STATUS_VALUES);
if (!status) {
return { success: false, reason: "探访预约状态无效" };
}
return {
success: true,
data: {
elderId,
contactId: readOptionalString(value, "contactId"),
scheduledAt: scheduledAt.data,
status,
notes: readString(value, "notes"),
},
};
}
export function validateFamilyFeedbackInput(value: unknown): ValidationResult<FamilyFeedbackInput> {
if (!isRecord(value)) {
return { success: false, reason: "请求数据格式无效" };
}
const elderId = readString(value, "elderId");
const content = readString(value, "content");
if (!elderId || !content) {
return { success: false, reason: "反馈必须选择老人并填写内容" };
}
const feedbackType = readEnum(value.feedbackType, FAMILY_FEEDBACK_TYPE_VALUES);
const status = readEnum(value.status, FAMILY_FEEDBACK_STATUS_VALUES);
if (!feedbackType || !status) {
return { success: false, reason: "反馈类型或状态无效" };
}
return {
success: true,
data: {
elderId,
contactId: readOptionalString(value, "contactId"),
feedbackType,
content,
status,
responseNotes: readString(value, "responseNotes"),
},
};
}

View File

@@ -610,11 +610,11 @@ export function HealthAdminClient({ canManage, initialData }: HealthAdminClientP
) : null}
<Dialog
className="max-w-2xl"
description="维护老人健康档案基础信息。"
onClose={closeProfileDialog}
open={profileElderId.length > 0}
title={editingProfile ? "编辑健康档案" : "新增健康档案"}
width="lg"
>
<form className="grid gap-3" onSubmit={saveProfile}>
<Textarea
@@ -646,7 +646,7 @@ export function HealthAdminClient({ canManage, initialData }: HealthAdminClientP
</form>
</Dialog>
<Dialog className="max-w-2xl" description="录入一条真实生命体征记录。" onClose={() => setIsVitalOpen(false)} open={isVitalOpen} title="录入生命体征">
<Dialog description="录入一条真实生命体征记录。" onClose={() => setIsVitalOpen(false)} open={isVitalOpen} title="录入生命体征" width="lg">
<form className="grid gap-3" onSubmit={saveVital}>
<Select
label="老人"
@@ -683,7 +683,7 @@ export function HealthAdminClient({ canManage, initialData }: HealthAdminClientP
</form>
</Dialog>
<Dialog className="max-w-xl" description="新增老人慢病管理记录。" onClose={() => setIsConditionOpen(false)} open={isConditionOpen} title="新增慢病记录">
<Dialog description="新增老人慢病管理记录。" onClose={() => setIsConditionOpen(false)} open={isConditionOpen} title="新增慢病记录">
<form className="grid gap-3" onSubmit={saveCondition}>
<Select
label="老人"
@@ -711,7 +711,7 @@ export function HealthAdminClient({ canManage, initialData }: HealthAdminClientP
</form>
</Dialog>
<Dialog className="max-w-lg" description={reviewTarget?.description} onClose={closeReviewDialog} open={reviewTarget !== undefined} title={reviewTarget?.title ?? "处理异常复核"}>
<Dialog description={reviewTarget?.description} onClose={closeReviewDialog} open={reviewTarget !== undefined} title={reviewTarget?.title ?? "处理异常复核"}>
<form className="grid gap-3" onSubmit={saveReview}>
<Select
label="处理状态"

View File

@@ -0,0 +1,210 @@
"use client";
import { FormEvent, useMemo, useState } from "react";
import { Bell, CheckCircle2, FileText, Search, Undo2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, 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 { Notice, NoticeCenterData, NoticeInput, NoticeStatus } from "@/modules/notices/types";
import { NOTICE_STATUS_LABELS, NOTICE_STATUS_VALUES } from "@/modules/notices/types";
type NoticesWorkspaceClientProps = {
canManage: boolean;
initialData: NoticeCenterData;
};
type DialogState = { mode: "create"; value: NoticeInput } | { mode: "edit"; id: string; value: NoticeInput };
const emptyNotice: NoticeInput = {
title: "",
content: "",
audience: "全体员工",
status: "draft",
};
function formatDateTime(value: string | undefined): string {
return value ? new Date(value).toLocaleString("zh-CN") : "-";
}
function noticeToInput(notice: Notice): NoticeInput {
return {
title: notice.title,
content: notice.content,
audience: notice.audience,
status: notice.status,
};
}
function badgeVariant(status: NoticeStatus): "secondary" | "success" | "warning" {
if (status === "published") {
return "success";
}
return status === "draft" ? "warning" : "secondary";
}
export function NoticesWorkspaceClient({ canManage, initialData }: NoticesWorkspaceClientProps): React.ReactElement {
const [data, setData] = useState(initialData);
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | NoticeStatus>("all");
const [dialog, setDialog] = useState<DialogState | undefined>();
const [message, setMessage] = useState("");
const [isPending, setIsPending] = useState(false);
const normalizedQuery = query.trim().toLowerCase();
const filteredNotices = useMemo(
() =>
data.notices.filter((notice) => {
const matchesQuery = !normalizedQuery || [notice.title, notice.content, notice.audience].join(" ").toLowerCase().includes(normalizedQuery);
const matchesStatus = statusFilter === "all" || notice.status === statusFilter;
return matchesQuery && matchesStatus;
}),
[data.notices, normalizedQuery, statusFilter],
);
async function refreshData(): Promise<void> {
const response = await fetch("/api/notices");
const result = (await response.json()) as ApiResult<{ data: NoticeCenterData }>;
if (result.success) {
setData(result.data);
}
}
async function submitDialog(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
if (!dialog || !canManage) {
setMessage("当前角色无权维护公告");
return;
}
setIsPending(true);
const response = await fetch(dialog.mode === "create" ? "/api/notices" : `/api/notices/${dialog.id}`, {
method: dialog.mode === "create" ? "POST" : "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(dialog.value),
});
const result = (await response.json()) as ApiResult<Record<string, unknown>>;
setIsPending(false);
setMessage(result.reason);
if (result.success) {
setDialog(undefined);
await refreshData();
}
}
async function remove(id: string): Promise<void> {
if (!canManage) {
setMessage("当前角色无权删除公告");
return;
}
setIsPending(true);
const response = await fetch(`/api/notices/${id}`, { method: "DELETE" });
const result = (await response.json()) as ApiResult<Record<string, unknown>>;
setIsPending(false);
setMessage(result.reason);
if (result.success) {
await refreshData();
}
}
async function markRead(id: string): Promise<void> {
setIsPending(true);
const response = await fetch(`/api/notices/${id}`, { method: "POST" });
const result = (await response.json()) as ApiResult<Record<string, unknown>>;
setIsPending(false);
setMessage(result.reason);
if (result.success) {
await refreshData();
}
}
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard icon={Bell} label="已发布" value={data.metrics.published} />
<MetricCard icon={FileText} label="草稿" value={data.metrics.drafts} />
<MetricCard icon={CheckCircle2} label="未读" value={data.metrics.unread} />
<MetricCard icon={Undo2} label="已撤回" value={data.metrics.retracted} />
</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" | NoticeStatus)} options={[{ value: "all", label: "全部状态" }, ...NOTICE_STATUS_VALUES.map((value) => ({ value, label: NOTICE_STATUS_LABELS[value] }))]} value={statusFilter} />
<Button disabled={!canManage} onClick={() => setDialog({ mode: "create", value: emptyNotice })} type="button"></Button>
</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></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"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3 text-right"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{filteredNotices.map((notice) => (
<Table.Row key={notice.id}>
<Table.Cell className="px-4 py-3"><p className="font-medium">{notice.title}</p><p className="line-clamp-2 text-xs text-muted-foreground">{notice.content}</p></Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{notice.audience}</Table.Cell>
<Table.Cell className="px-4 py-3"><Badge variant={badgeVariant(notice.status)}>{NOTICE_STATUS_LABELS[notice.status]}</Badge></Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{notice.hasRead ? "已读" : "未读"} / {notice.readCount}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDateTime(notice.publishedAt)}</Table.Cell>
<Table.Cell className="px-4 py-3"><div className="flex justify-end gap-2"><Button disabled={isPending || notice.hasRead} onClick={() => markRead(notice.id)} size="sm" type="button" variant="outline"></Button><Button disabled={!canManage || isPending} onClick={() => setDialog({ mode: "edit", id: notice.id, value: noticeToInput(notice) })} size="sm" type="button" variant="outline"></Button><Button disabled={!canManage || isPending} onClick={() => remove(notice.id)} size="sm" type="button" variant="outline"></Button></div></Table.Cell>
</Table.Row>
))}
{filteredNotices.length === 0 ? <Table.Row><Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}></Table.Cell></Table.Row> : null}
</Table.Body>
</Table>
</CardContent>
</Card>
<Dialog onClose={() => setDialog(undefined)} open={dialog !== undefined} title="公告通知" width="lg">
{dialog ? (
<form className="grid gap-3" onSubmit={submitDialog}>
<Input label="标题" onChange={(event) => setDialog({ ...dialog, value: { ...dialog.value, title: event.target.value } })} required value={dialog.value.title} />
<Input label="发布范围" onChange={(event) => setDialog({ ...dialog, value: { ...dialog.value, audience: event.target.value } })} value={dialog.value.audience} />
<Select aria-label="公告状态" onValueChange={(status) => setDialog({ ...dialog, value: { ...dialog.value, status: status as NoticeStatus } })} options={NOTICE_STATUS_VALUES.map((status) => ({ value: status, label: NOTICE_STATUS_LABELS[status] }))} value={dialog.value.status} />
<Textarea label="内容" onChange={(event) => setDialog({ ...dialog, value: { ...dialog.value, content: event.target.value } })} required value={dialog.value.content} />
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button disabled={isPending} onClick={() => setDialog(undefined)} type="button" variant="outline"></Button>
<Button disabled={isPending || !canManage} type="submit"></Button>
</div>
</form>
) : null}
</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><p className="text-sm text-muted-foreground">{label}</p><CardTitle className="mt-2 text-3xl">{value}</CardTitle></div>
<Icon className="size-5 text-primary" aria-hidden={true} />
</CardHeader>
</Card>
);
}

View File

@@ -0,0 +1,180 @@
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import { getDatabase } from "@/modules/core/server/db";
import { accounts, noticeReadReceipts, notices } from "@/modules/core/server/schema";
import type { Notice, NoticeCenterData, NoticeInput } from "@/modules/notices/types";
export type NoticeMutationFailure = {
success: false;
reason: string;
status: number;
};
type ReadCountRow = {
noticeId: string;
count: number;
};
export function isNoticeMutationFailure(value: unknown): value is NoticeMutationFailure {
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 toNotice(
row: typeof notices.$inferSelect,
accountNameById: Map<string, string>,
readCountByNoticeId: Map<string, number>,
readNoticeIds: Set<string>,
): Notice {
const createdByAccountId = row.createdByAccountId ?? undefined;
const updatedByAccountId = row.updatedByAccountId ?? undefined;
return {
id: row.id,
organizationId: row.organizationId,
title: row.title,
content: row.content,
audience: row.audience,
status: row.status,
publishedAt: optionalIso(row.publishedAt),
createdByAccountId,
createdByName: createdByAccountId ? accountNameById.get(createdByAccountId) : undefined,
updatedByAccountId,
updatedByName: updatedByAccountId ? accountNameById.get(updatedByAccountId) : undefined,
readCount: readCountByNoticeId.get(row.id) ?? 0,
hasRead: readNoticeIds.has(row.id),
createdAt: iso(row.createdAt),
updatedAt: iso(row.updatedAt),
};
}
export async function listNoticeCenterData(organizationId: string, accountId: string): Promise<NoticeCenterData> {
const database = getDatabase();
const noticeRows = await database.select().from(notices).where(eq(notices.organizationId, organizationId)).orderBy(desc(notices.updatedAt));
const noticeIds = noticeRows.map((notice) => notice.id);
const accountIds = Array.from(
new Set(
noticeRows
.flatMap((notice) => [notice.createdByAccountId, notice.updatedByAccountId])
.filter((item): item is string => typeof item === "string"),
),
);
const [accountRows, readCountRows, readRows] = await Promise.all([
accountIds.length > 0 ? database.select({ id: accounts.id, name: accounts.name }).from(accounts).where(inArray(accounts.id, accountIds)) : [],
noticeIds.length > 0
? database
.select({ noticeId: noticeReadReceipts.noticeId, count: sql<number>`count(*)::int` })
.from(noticeReadReceipts)
.where(and(eq(noticeReadReceipts.organizationId, organizationId), inArray(noticeReadReceipts.noticeId, noticeIds)))
.groupBy(noticeReadReceipts.noticeId)
: ([] as ReadCountRow[]),
noticeIds.length > 0
? database
.select({ noticeId: noticeReadReceipts.noticeId })
.from(noticeReadReceipts)
.where(
and(
eq(noticeReadReceipts.organizationId, organizationId),
eq(noticeReadReceipts.accountId, accountId),
inArray(noticeReadReceipts.noticeId, noticeIds),
),
)
: [],
]);
const readCountByNoticeId = new Map(readCountRows.map((row) => [row.noticeId, Number(row.count)]));
const readNoticeIds = new Set(readRows.map((row) => row.noticeId));
const noticeData = noticeRows.map((notice) =>
toNotice(
notice,
new Map(accountRows.map((account) => [account.id, account.name])),
readCountByNoticeId,
readNoticeIds,
),
);
return {
metrics: {
published: noticeRows.filter((notice) => notice.status === "published").length,
drafts: noticeRows.filter((notice) => notice.status === "draft").length,
unread: noticeRows.filter((notice) => notice.status === "published" && !readNoticeIds.has(notice.id)).length,
retracted: noticeRows.filter((notice) => notice.status === "retracted").length,
},
notices: noticeData,
};
}
export async function createNotice(input: NoticeInput & { accountId: string; organizationId: string }): Promise<Notice | NoticeMutationFailure> {
const database = getDatabase();
const now = new Date();
const rows = await database
.insert(notices)
.values({
organizationId: input.organizationId,
title: input.title,
content: input.content,
audience: input.audience,
status: input.status,
publishedAt: input.status === "published" ? now : undefined,
createdByAccountId: input.accountId,
updatedByAccountId: input.accountId,
})
.returning();
const notice = rows[0];
return notice ? toNotice(notice, new Map(), new Map(), new Set()) : { success: false, reason: "公告创建失败", status: 500 };
}
export async function updateNotice(input: NoticeInput & { accountId: string; id: string; organizationId: string }): Promise<Notice | NoticeMutationFailure> {
const database = getDatabase();
const now = new Date();
const rows = await database
.update(notices)
.set({
title: input.title,
content: input.content,
audience: input.audience,
status: input.status,
publishedAt: input.status === "published" ? now : null,
updatedByAccountId: input.accountId,
updatedAt: now,
})
.where(and(eq(notices.id, input.id), eq(notices.organizationId, input.organizationId)))
.returning();
const notice = rows[0];
return notice ? toNotice(notice, new Map(), new Map(), new Set()) : { success: false, reason: "公告不存在", status: 404 };
}
export async function deleteNotice(input: { id: string; organizationId: string }): Promise<Notice | NoticeMutationFailure> {
const database = getDatabase();
const rows = await database.delete(notices).where(and(eq(notices.id, input.id), eq(notices.organizationId, input.organizationId))).returning();
const notice = rows[0];
return notice ? toNotice(notice, new Map(), new Map(), new Set()) : { success: false, reason: "公告不存在", status: 404 };
}
export async function markNoticeRead(input: { accountId: string; id: string; organizationId: string }): Promise<Notice | NoticeMutationFailure> {
const database = getDatabase();
const rows = await database.select().from(notices).where(and(eq(notices.id, input.id), eq(notices.organizationId, input.organizationId)));
const notice = rows[0];
if (!notice) {
return { success: false, reason: "公告不存在", status: 404 };
}
await database
.insert(noticeReadReceipts)
.values({ organizationId: input.organizationId, noticeId: input.id, accountId: input.accountId })
.onConflictDoUpdate({
target: [noticeReadReceipts.noticeId, noticeReadReceipts.accountId],
set: { readAt: new Date() },
});
return toNotice(notice, new Map(), new Map([[notice.id, 1]]), new Set([notice.id]));
}

95
modules/notices/types.ts Normal file
View File

@@ -0,0 +1,95 @@
export const NOTICE_STATUS_VALUES = ["draft", "published", "retracted"] as const;
export type NoticeStatus = (typeof NOTICE_STATUS_VALUES)[number];
export const NOTICE_STATUS_LABELS: Record<NoticeStatus, string> = {
draft: "草稿",
published: "已发布",
retracted: "已撤回",
};
export type Notice = {
id: string;
organizationId: string;
title: string;
content: string;
audience: string;
status: NoticeStatus;
publishedAt?: string;
createdByAccountId?: string;
createdByName?: string;
updatedByAccountId?: string;
updatedByName?: string;
readCount: number;
hasRead: boolean;
createdAt: string;
updatedAt: string;
};
export type NoticeCenterData = {
metrics: {
published: number;
drafts: number;
unread: number;
retracted: number;
};
notices: Notice[];
};
export type NoticeInput = {
title: string;
content: string;
audience: string;
status: NoticeStatus;
};
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 validateNoticeInput(value: unknown): ValidationResult<NoticeInput> {
if (!isRecord(value)) {
return { success: false, reason: "请求数据格式无效" };
}
const title = readString(value, "title");
const content = readString(value, "content");
if (!title || !content) {
return { success: false, reason: "公告标题和内容不能为空" };
}
const status = readEnum(value.status, NOTICE_STATUS_VALUES);
if (!status) {
return { success: false, reason: "公告状态无效" };
}
return {
success: true,
data: {
title,
content,
audience: readString(value, "audience") || "全体员工",
status,
},
};
}

View File

@@ -84,7 +84,6 @@ export function OrganizationInviteDialog({
return (
<Dialog
className="max-w-xl"
description={`生成 ${organization.name} 的邀请链接,可限定邮箱并指定入组角色。`}
onClose={closeDialog}
open={open}

View File

@@ -65,7 +65,6 @@ export function OrganizationManagementClient(): React.ReactElement {
</Button>
<Dialog
className="max-w-xl"
description="创建后会自动初始化机构内置角色。"
onClose={closeCreateDialog}
open={isCreateOpen}
@@ -195,7 +194,6 @@ export function OrganizationRowActions({
</Button>
)}
<Dialog
className="max-w-lg"
description="编辑机构名称、唯一标识和启停状态。"
onClose={() => setIsEditOpen(false)}
open={isEditOpen}

View File

@@ -204,11 +204,11 @@ export function RoleManagementClient({ permissions }: RoleManagementClientProps)
</Button>
<Dialog
className="w-[min(calc(100vw-1.5rem),64rem)]"
description="角色由系统注册权限点组合而成。"
onClose={closeCreateDialog}
open={isCreateOpen}
title="新增角色"
width="wide"
>
<form className="grid gap-4" onSubmit={handleSubmit}>
<div className="grid gap-3 md:grid-cols-3">
@@ -345,11 +345,11 @@ export function RoleRowActions({ permissions, role }: RoleRowActionsProps): Reac
</Button>
)}
<Dialog
className="w-[min(calc(100vw-1.5rem),64rem)]"
description="编辑自定义角色的名称、说明、状态和权限点。"
onClose={closeEditDialog}
open={isEditOpen}
title="编辑角色"
width="wide"
>
<form className="grid gap-4" onSubmit={handleSubmit}>
<div className="grid gap-3 md:grid-cols-2">

View File

@@ -234,7 +234,6 @@ export function AccountMenu({
) : null}
<Dialog
className="max-w-2xl"
description="管理你的个人资料和第三方登录绑定状态。"
onClose={() => {
if (!isPending) {
@@ -244,6 +243,7 @@ export function AccountMenu({
}}
open={isSettingsOpen}
title="用户设置"
width="lg"
>
<form className="grid gap-5" onSubmit={handleProfileSubmit}>
<div className="grid gap-4 md:grid-cols-[auto_1fr]">
@@ -292,7 +292,6 @@ export function AccountMenu({
</Dialog>
<Dialog
className="max-w-md"
description="退出后需要重新登录才能继续访问工作台。"
onClose={() => {
if (!isPending) {
@@ -301,6 +300,7 @@ export function AccountMenu({
}}
open={isSignOutConfirmOpen}
title="确认退出登录"
width="sm"
>
<div className="grid gap-4">
<p className="text-sm text-muted-foreground">退</p>

View File

@@ -15,6 +15,7 @@ import {
Settings,
ShieldAlert,
TabletSmartphone,
UserRoundCheck,
Users,
Wrench,
} from "lucide-react";
@@ -33,6 +34,7 @@ const iconMap: Record<NavIconKey, LucideIcon> = {
dashboard: LayoutDashboard,
devices: TabletSmartphone,
emergency: ShieldAlert,
family: UserRoundCheck,
global: Globe2,
health: HeartPulse,
notices: Bell,

View File

@@ -8,6 +8,7 @@ export type NavIconKey =
| "dashboard"
| "devices"
| "emergency"
| "family"
| "global"
| "health"
| "notices"
@@ -78,23 +79,25 @@ export const navGroups: NavGroup[] = [
path: "/devices",
label: "设备运维",
icon: "devices",
permission: "facility:read",
permission: "device:read",
},
{
path: "/notices",
label: "公告通知",
icon: "notices",
permission: "notice:read",
},
{
path: "/alerts",
label: "规则预警",
icon: "alerts",
permission: "incident:read",
permission: "alert:read",
},
{
path: "/family",
label: "家属服务",
icon: "devices",
icon: "family",
permission: "family:read",
},
],
},