feat: build collaboration workspaces

This commit is contained in:
2026-07-03 03:02:36 -07:00
parent bf3dd256ab
commit 4ba2a11b88
53 changed files with 9557 additions and 29 deletions

View File

@@ -251,6 +251,73 @@ return jsonSuccess("健康数据已加载", data);
#### Correct #### 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 ```ts
const data = await listHealthAdminData(organizationId); const data = await listHealthAdminData(organizationId);
return jsonSuccess("健康数据已加载", { data }); 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": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "TalexDreamSoul",
"assignee": "TalexDreamSoul",
"createdAt": "2026-07-03",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -1,5 +1,5 @@
import { AlertsModulePage } from "@/modules/shared/components/ReservedModulePages"; import AlertsPage from "../../alerts/page";
export default function ScopedAlertsPage(): React.ReactElement { export default async function ScopedAlertsPage(): Promise<React.ReactElement> {
return <AlertsModulePage />; 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 { export default async function ScopedDevicesPage(): Promise<React.ReactElement> {
return <DevicesModulePage />; 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 { export default async function ScopedFamilyPage(): Promise<React.ReactElement> {
return <FamilyModulePage />; 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 { export default async function ScopedNoticesPage(): Promise<React.ReactElement> {
return <NoticesModulePage />; 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 { import { getCurrentAuthContext } from "@/modules/core/server/auth";
return <AlertsModulePage />; 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 { import { getCurrentAuthContext } from "@/modules/core/server/auth";
return <DevicesModulePage />; 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 { import { getCurrentAuthContext } from "@/modules/core/server/auth";
return <FamilyModulePage />; 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 { import { getCurrentAuthContext } from "@/modules/core/server/auth";
return <NoticesModulePage />; 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

@@ -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, "when": 1783064001398,
"tag": "0004_silly_carnage", "tag": "0004_silly_carnage",
"breakpoints": true "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

@@ -3,15 +3,23 @@ import { eq } from "drizzle-orm";
import { getDatabase } from "@/modules/core/server/db"; import { getDatabase } from "@/modules/core/server/db";
import { import {
admissions, admissions,
alertRules,
alertTriggers,
beds, beds,
buildings, buildings,
campuses, campuses,
careTasks, careTasks,
chronicConditions, chronicConditions,
deviceAssets,
elders, elders,
familyContacts,
familyFeedback,
familyVisitAppointments,
floors, floors,
healthAnomalyReviews, healthAnomalyReviews,
healthProfiles, healthProfiles,
maintenanceTickets,
notices,
rooms, rooms,
systemIncidents, systemIncidents,
vitalRecords, vitalRecords,
@@ -116,6 +124,80 @@ type SeedSystemIncident = {
title: string; 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[] = [ const DEFAULT_ROOMS: SeedRoom[] = [
{ campusName: "主院区", buildingName: "护理楼", floorName: "一层", floorLevel: 1, code: "A101", name: "阳光房", capacity: 2 }, { campusName: "主院区", buildingName: "护理楼", floorName: "一层", floorLevel: 1, code: "A101", name: "阳光房", capacity: 2 },
{ campusName: "主院区", buildingName: "护理楼", floorName: "一层", floorLevel: 1, code: "A102", name: "康养房", capacity: 2 }, { campusName: "主院区", buildingName: "护理楼", floorName: "一层", floorLevel: 1, code: "A102", name: "康养房", capacity: 2 },
@@ -871,6 +953,189 @@ const DEFAULT_SYSTEM_INCIDENTS: SeedSystemIncident[] = [
}, },
]; ];
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 { function hasRows(rows: unknown[]): boolean {
return rows.length > 0; return rows.length > 0;
} }
@@ -1187,5 +1452,160 @@ export async function seedDefaultWorkspaceData(organizationId: string): Promise<
), ),
})), })),
); );
const deviceRows = await transaction
.insert(deviceAssets)
.values(
DEFAULT_DEVICE_ASSETS.map((device) => ({
organizationId,
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,
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: "care:manage", label: "处理护理任务", category: "护理", description: "启动、完成和维护护理服务执行记录。" },
{ id: "health:read", label: "查看健康数据", category: "健康", description: "查看老人健康档案、生命体征和异常复核。" }, { id: "health:read", label: "查看健康数据", category: "健康", description: "查看老人健康档案、生命体征和异常复核。" },
{ id: "health:manage", label: "管理健康数据", category: "健康", description: "维护健康档案、生命体征、慢病和异常复核。" }, { id: "health:manage", label: "管理健康数据", category: "健康", description: "维护健康档案、生命体征、慢病和异常复核。" },
{ id: "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:read", label: "查看房间床位", category: "床位", description: "查看房间、床位和占用状态。" },
{ id: "facility:manage", label: "管理房间床位", category: "床位", description: "维护院区、房间、床位和床位状态。" }, { id: "facility:manage", label: "管理房间床位", category: "床位", description: "维护院区、房间、床位和床位状态。" },
{ id: "admission:read", label: "查看入住", category: "入住", description: "查看入住、换床、退住历史。" }, { id: "admission:read", label: "查看入住", category: "入住", description: "查看入住、换床、退住历史。" },
@@ -97,6 +105,14 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [
"care:manage", "care:manage",
"health:read", "health:read",
"health:manage", "health:manage",
"device:read",
"device:manage",
"notice:read",
"notice:manage",
"alert:read",
"alert:manage",
"family:read",
"family:manage",
"facility:read", "facility:read",
"facility:manage", "facility:manage",
"admission:read", "admission:read",
@@ -119,6 +135,14 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [
"care:manage", "care:manage",
"health:read", "health:read",
"health:manage", "health:manage",
"device:read",
"device:manage",
"notice:read",
"notice:manage",
"alert:read",
"alert:manage",
"family:read",
"family:manage",
"facility:read", "facility:read",
"facility:manage", "facility:manage",
"admission:read", "admission:read",
@@ -133,13 +157,35 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [
key: "caregiver", key: "caregiver",
scope: "organization", scope: "organization",
description: "照护人员,查看床位和维护老人照护信息。", 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", key: "viewer",
scope: "organization", scope: "organization",
description: "普通用户,只读查看老人、床位和入住信息。", 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", 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 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 careTaskPriorityEnum = pgEnum("care_task_priority", ["low", "normal", "high", "urgent"]);
export const careTaskStatusEnum = pgEnum("care_task_status", ["pending", "in_progress", "completed", "cancelled"]); 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", { export const systemSettings = pgTable("system_settings", {
id: text("id").primaryKey().default("global"), 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), 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", { export const admissions = pgTable("admissions", {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),

View File

@@ -43,6 +43,14 @@ export const PERMISSIONS = [
"care:manage", "care:manage",
"health:read", "health:read",
"health:manage", "health:manage",
"device:read",
"device:manage",
"notice:read",
"notice:manage",
"alert:read",
"alert:manage",
"family:read",
"family:manage",
"facility:read", "facility:read",
"facility:manage", "facility:manage",
"admission:read", "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

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

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

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

View File

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