feat: add care execution workspace

This commit is contained in:
2026-07-03 00:35:04 -07:00
parent df7c2efcc5
commit b6b15acc69
20 changed files with 4270 additions and 18 deletions

View File

@@ -0,0 +1,287 @@
"use client";
import { FormEvent, useMemo, useState } from "react";
import { CheckCircle2, Clock3, Play, Search, Siren } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog } from "@/components/ui/dialog";
import { Input, Textarea } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Table } from "@/components/ui/table";
import type { ApiResult } from "@/modules/core/server/api";
import type { CareExecutionData, CareTask, CareTaskPriority, CareTaskStatus, CareTaskType } from "@/modules/care/types";
import { CARE_TASK_PRIORITY_LABELS, CARE_TASK_STATUS_LABELS, CARE_TASK_TYPE_LABELS } from "@/modules/care/types";
type CareWorkspaceClientProps = {
canManage: boolean;
initialData: CareExecutionData;
};
function formatDateTime(value: string | undefined): string {
return value ? new Date(value).toLocaleString("zh-CN") : "-";
}
function statusVariant(status: CareTaskStatus): "success" | "secondary" | "warning" {
if (status === "completed") {
return "success";
}
return status === "pending" ? "warning" : "secondary";
}
function priorityVariant(priority: CareTaskPriority): "danger" | "secondary" | "warning" {
if (priority === "urgent") {
return "danger";
}
return priority === "high" ? "warning" : "secondary";
}
export function CareWorkspaceClient({ canManage, initialData }: CareWorkspaceClientProps): React.ReactElement {
const [data, setData] = useState(initialData);
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | CareTaskStatus>("all");
const [priorityFilter, setPriorityFilter] = useState<"all" | CareTaskPriority>("all");
const [typeFilter, setTypeFilter] = useState<"all" | CareTaskType>("all");
const [message, setMessage] = useState("");
const [isPending, setIsPending] = useState(false);
const [completeTarget, setCompleteTarget] = useState<CareTask | undefined>();
const [executionNotes, setExecutionNotes] = useState("");
const normalizedQuery = query.trim().toLowerCase();
const filteredTasks = useMemo(
() =>
data.tasks.filter((task) => {
const matchesQuery =
!normalizedQuery ||
[task.title, task.elderName, task.assigneeLabel, task.executionNotes].join(" ").toLowerCase().includes(normalizedQuery);
const matchesStatus = statusFilter === "all" || task.status === statusFilter;
const matchesPriority = priorityFilter === "all" || task.priority === priorityFilter;
const matchesType = typeFilter === "all" || task.careType === typeFilter;
return matchesQuery && matchesStatus && matchesPriority && matchesType;
}),
[data.tasks, normalizedQuery, priorityFilter, statusFilter, typeFilter],
);
async function refreshData(): Promise<void> {
const response = await fetch("/api/care/tasks");
const result = (await response.json()) as ApiResult<{ data: CareExecutionData }>;
if (result.success) {
setData(result.data);
}
}
async function updateTask(task: CareTask, status: CareTaskStatus, notes = task.executionNotes): Promise<void> {
if (!canManage) {
setMessage("当前角色无权处理护理任务");
return;
}
setIsPending(true);
setMessage("");
const response = await fetch(`/api/care/tasks/${task.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ status, executionNotes: notes }),
});
const result = (await response.json()) as ApiResult<{ task: CareTask }>;
setIsPending(false);
setMessage(result.reason);
if (result.success) {
setCompleteTarget(undefined);
setExecutionNotes("");
await refreshData();
}
}
function openCompleteDialog(task: CareTask): void {
setCompleteTarget(task);
setExecutionNotes(task.executionNotes);
setMessage("");
}
function closeCompleteDialog(): void {
if (isPending) {
return;
}
setCompleteTarget(undefined);
setExecutionNotes("");
}
async function submitComplete(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
if (!completeTarget) {
return;
}
await updateTask(completeTarget, "completed", executionNotes);
}
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard icon={Clock3} label="待处理" value={data.metrics.pending} />
<MetricCard icon={Play} label="进行中" value={data.metrics.inProgress} />
<MetricCard icon={CheckCircle2} label="今日完成" value={data.metrics.completedToday} />
<MetricCard icon={Siren} label="高优先级" value={data.metrics.highPriority} />
</section>
<section className="flex flex-col gap-3 border-b pb-4 xl:flex-row xl:items-center xl:justify-between">
<div>
<h1 className="text-3xl font-semibold tracking-normal"></h1>
<p className="mt-2 text-sm text-muted-foreground"></p>
</div>
<div className="grid gap-2 sm:grid-cols-2 xl:flex xl:items-center">
<label className="relative block sm:col-span-2 xl:col-span-1">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="w-full pl-9 xl:w-72"
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索老人、任务或执行人"
value={query}
/>
</label>
<Select
aria-label="任务状态"
onValueChange={(value) => setStatusFilter(value as "all" | CareTaskStatus)}
options={[
{ value: "all", label: "全部状态" },
...Object.entries(CARE_TASK_STATUS_LABELS).map(([value, label]) => ({ value, label })),
]}
value={statusFilter}
/>
<Select
aria-label="优先级"
onValueChange={(value) => setPriorityFilter(value as "all" | CareTaskPriority)}
options={[
{ value: "all", label: "全部优先级" },
...Object.entries(CARE_TASK_PRIORITY_LABELS).map(([value, label]) => ({ value, label })),
]}
value={priorityFilter}
/>
<Select
aria-label="护理类型"
onValueChange={(value) => setTypeFilter(value as "all" | CareTaskType)}
options={[
{ value: "all", label: "全部类型" },
...Object.entries(CARE_TASK_TYPE_LABELS).map(([value, label]) => ({ value, label })),
]}
value={typeFilter}
/>
</div>
</section>
{message ? (
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
{message}
</p>
) : null}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="overflow-x-auto">
<Table className="w-full min-w-[980px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 text-right font-medium"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{filteredTasks.map((task) => (
<Table.Row key={task.id}>
<Table.Cell className="px-4 py-3">
<p className="font-medium">{task.title}</p>
<p className="text-xs text-muted-foreground">{task.executionNotes || "-"}</p>
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{task.elderName || "公共区域"}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{CARE_TASK_TYPE_LABELS[task.careType]}</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={priorityVariant(task.priority)}>{CARE_TASK_PRIORITY_LABELS[task.priority]}</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={statusVariant(task.status)}>{CARE_TASK_STATUS_LABELS[task.status]}</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDateTime(task.scheduledAt)}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{task.assigneeLabel || "-"}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDateTime(task.completedAt)}</Table.Cell>
<Table.Cell className="px-4 py-3">
<div className="flex justify-end gap-2">
{task.status === "pending" ? (
<Button disabled={!canManage || isPending} onClick={() => updateTask(task, "in_progress")} size="sm" type="button" variant="outline">
</Button>
) : null}
{task.status === "pending" || task.status === "in_progress" ? (
<Button disabled={!canManage || isPending} onClick={() => openCompleteDialog(task)} size="sm" type="button">
</Button>
) : null}
</div>
</Table.Cell>
</Table.Row>
))}
{filteredTasks.length === 0 ? (
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={9}>
</Table.Cell>
</Table.Row>
) : null}
</Table.Body>
</Table>
</CardContent>
</Card>
<Dialog className="max-w-lg" description={completeTarget?.title} onClose={closeCompleteDialog} open={completeTarget !== undefined} title="完成护理任务">
<form className="grid gap-3" onSubmit={submitComplete}>
<Textarea label="执行备注" onChange={(event) => setExecutionNotes(event.target.value)} value={executionNotes} />
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button disabled={isPending} onClick={closeCompleteDialog} type="button" variant="outline">
</Button>
<Button disabled={isPending} type="submit">
</Button>
</div>
</form>
</Dialog>
</div>
);
}
function MetricCard({
icon: Icon,
label,
value,
}: {
icon: React.ComponentType<{ "aria-hidden"?: boolean; className?: string }>;
label: string;
value: number;
}): React.ReactElement {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between p-4">
<div>
<CardDescription>{label}</CardDescription>
<CardTitle className="mt-2 text-3xl">{value}</CardTitle>
</div>
<Icon className="size-5 text-primary" aria-hidden={true} />
</CardHeader>
</Card>
);
}

View File

@@ -0,0 +1,141 @@
import { and, desc, eq, inArray } from "drizzle-orm";
import { getDatabase } from "@/modules/core/server/db";
import { accounts, careTasks, elders } from "@/modules/core/server/schema";
import type { CareExecutionData, CareTask, CareTaskStatusUpdateInput } from "@/modules/care/types";
export type CareMutationFailure = {
success: false;
reason: string;
status: number;
};
type AccountNameRow = {
id: string;
name: string;
};
type ElderNameRow = {
id: string;
name: string;
};
export function isCareMutationFailure(value: unknown): value is CareMutationFailure {
return (
typeof value === "object" &&
value !== null &&
"success" in value &&
(value as { success?: unknown }).success === false
);
}
function iso(value: Date): string {
return value.toISOString();
}
function optionalIso(value: Date | null): string | undefined {
return value ? iso(value) : undefined;
}
function buildNameMap(rows: AccountNameRow[] | ElderNameRow[]): Map<string, string> {
return new Map(rows.map((row) => [row.id, row.name]));
}
function toCareTask(
row: typeof careTasks.$inferSelect,
elderNameById: Map<string, string>,
accountNameById: Map<string, string>,
): CareTask {
const elderId = row.elderId ?? undefined;
const completedByAccountId = row.completedByAccountId ?? undefined;
return {
id: row.id,
organizationId: row.organizationId,
elderId,
elderName: elderId ? elderNameById.get(elderId) ?? "" : "",
title: row.title,
careType: row.careType,
priority: row.priority,
status: row.status,
scheduledAt: iso(row.scheduledAt),
assigneeLabel: row.assigneeLabel,
executionNotes: row.executionNotes,
completedAt: optionalIso(row.completedAt),
completedByAccountId,
completedByName: completedByAccountId ? accountNameById.get(completedByAccountId) : undefined,
createdAt: iso(row.createdAt),
updatedAt: iso(row.updatedAt),
};
}
export async function listCareExecutionData(organizationId: string): Promise<CareExecutionData> {
const database = getDatabase();
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const taskRows = await database
.select()
.from(careTasks)
.where(eq(careTasks.organizationId, organizationId))
.orderBy(desc(careTasks.scheduledAt));
const elderIds = Array.from(new Set(taskRows.map((task) => task.elderId).filter((elderId): elderId is string => typeof elderId === "string")));
const accountIds = Array.from(
new Set(taskRows.map((task) => task.completedByAccountId).filter((accountId): accountId is string => typeof accountId === "string")),
);
const [elderRows, accountRows] = await Promise.all([
elderIds.length > 0
? database.select({ id: elders.id, name: elders.name }).from(elders).where(and(eq(elders.organizationId, organizationId), inArray(elders.id, elderIds)))
: [],
accountIds.length > 0 ? database.select({ id: accounts.id, name: accounts.name }).from(accounts).where(inArray(accounts.id, accountIds)) : [],
]);
const tasks = taskRows.map((task) => toCareTask(task, buildNameMap(elderRows), buildNameMap(accountRows)));
return {
metrics: {
pending: taskRows.filter((task) => task.status === "pending").length,
inProgress: taskRows.filter((task) => task.status === "in_progress").length,
completedToday: taskRows.filter((task) => task.status === "completed" && task.completedAt !== null && task.completedAt >= todayStart).length,
highPriority: taskRows.filter((task) => task.priority === "high" || task.priority === "urgent").length,
},
tasks,
};
}
export async function updateCareTaskStatus(
input: CareTaskStatusUpdateInput & { accountId: string; id: string; organizationId: string },
): Promise<CareTask | CareMutationFailure> {
const database = getDatabase();
const completedAt = input.status === "completed" ? new Date() : null;
const completedByAccountId = input.status === "completed" ? input.accountId : null;
const rows = await database
.update(careTasks)
.set({
status: input.status,
executionNotes: input.executionNotes,
completedAt,
completedByAccountId,
updatedAt: new Date(),
})
.where(and(eq(careTasks.id, input.id), eq(careTasks.organizationId, input.organizationId)))
.returning();
const task = rows[0];
if (!task) {
return { success: false, reason: "护理任务不存在", status: 404 };
}
const [elderRows, accountRows] = await Promise.all([
task.elderId
? database.select({ id: elders.id, name: elders.name }).from(elders).where(and(eq(elders.id, task.elderId), eq(elders.organizationId, input.organizationId)))
: [],
task.completedByAccountId
? database.select({ id: accounts.id, name: accounts.name }).from(accounts).where(eq(accounts.id, task.completedByAccountId))
: [],
]);
return toCareTask(task, buildNameMap(elderRows), buildNameMap(accountRows));
}

112
modules/care/types.ts Normal file
View File

@@ -0,0 +1,112 @@
export const CARE_TASK_TYPE_VALUES = ["daily_care", "meal", "medication", "rehab", "inspection", "cleaning", "other"] as const;
export type CareTaskType = (typeof CARE_TASK_TYPE_VALUES)[number];
export const CARE_TASK_PRIORITY_VALUES = ["low", "normal", "high", "urgent"] as const;
export type CareTaskPriority = (typeof CARE_TASK_PRIORITY_VALUES)[number];
export const CARE_TASK_STATUS_VALUES = ["pending", "in_progress", "completed", "cancelled"] as const;
export type CareTaskStatus = (typeof CARE_TASK_STATUS_VALUES)[number];
export const CARE_TASK_TYPE_LABELS: Record<CareTaskType, string> = {
daily_care: "日常照护",
meal: "助餐",
medication: "用药",
rehab: "康复",
inspection: "巡检",
cleaning: "清洁",
other: "其他",
};
export const CARE_TASK_PRIORITY_LABELS: Record<CareTaskPriority, string> = {
low: "低",
normal: "普通",
high: "高",
urgent: "紧急",
};
export const CARE_TASK_STATUS_LABELS: Record<CareTaskStatus, string> = {
pending: "待处理",
in_progress: "进行中",
completed: "已完成",
cancelled: "已取消",
};
export type CareTask = {
id: string;
organizationId: string;
elderId?: string;
elderName: string;
title: string;
careType: CareTaskType;
priority: CareTaskPriority;
status: CareTaskStatus;
scheduledAt: string;
assigneeLabel: string;
executionNotes: string;
completedAt?: string;
completedByAccountId?: string;
completedByName?: string;
createdAt: string;
updatedAt: string;
};
export type CareExecutionMetrics = {
pending: number;
inProgress: number;
completedToday: number;
highPriority: number;
};
export type CareExecutionData = {
metrics: CareExecutionMetrics;
tasks: CareTask[];
};
export type CareTaskStatusUpdateInput = {
status: CareTaskStatus;
executionNotes: string;
};
type ValidationSuccess<T> = {
success: true;
data: T;
};
type ValidationFailure = {
success: false;
reason: string;
};
export type ValidationResult<T> = ValidationSuccess<T> | ValidationFailure;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readString(source: Record<string, unknown>, key: string): string {
const value = source[key];
return typeof value === "string" ? value.trim() : "";
}
function readEnum<T extends string>(value: unknown, values: readonly T[]): T | null {
return typeof value === "string" ? values.find((item) => item === value) ?? null : null;
}
export function validateCareTaskStatusUpdateInput(value: unknown): ValidationResult<CareTaskStatusUpdateInput> {
if (!isRecord(value)) {
return { success: false, reason: "请求数据格式无效" };
}
const status = readEnum(value.status, CARE_TASK_STATUS_VALUES);
if (!status) {
return { success: false, reason: "护理任务状态无效" };
}
return {
success: true,
data: {
status,
executionNotes: readString(value, "executionNotes"),
},
};
}

View File

@@ -6,6 +6,7 @@ import {
beds,
buildings,
campuses,
careTasks,
chronicConditions,
elders,
floors,
@@ -92,6 +93,18 @@ type SeedHealthReview = {
vitalIndex: number;
};
type SeedCareTask = {
assigneeLabel: string;
careType: "daily_care" | "meal" | "medication" | "rehab" | "inspection" | "cleaning" | "other";
completedHoursAgo?: number;
elderName: string;
executionNotes: string;
priority: "low" | "normal" | "high" | "urgent";
scheduledHoursOffset: number;
status: "pending" | "in_progress" | "completed" | "cancelled";
title: string;
};
const DEFAULT_ROOMS: SeedRoom[] = [
{ campusName: "主院区", buildingName: "护理楼", floorName: "一层", floorLevel: 1, code: "A101", name: "阳光房", capacity: 2 },
{ campusName: "主院区", buildingName: "护理楼", floorName: "一层", floorLevel: 1, code: "A102", name: "康养房", capacity: 2 },
@@ -374,6 +387,60 @@ const DEFAULT_HEALTH_REVIEWS: SeedHealthReview[] = [
},
];
const DEFAULT_CARE_TASKS: SeedCareTask[] = [
{
elderName: "王桂兰",
title: "晨间协助洗漱与翻身",
careType: "daily_care",
priority: "normal",
status: "pending",
scheduledHoursOffset: 1,
assigneeLabel: "一号护理组",
executionNotes: "",
},
{
elderName: "孙秀梅",
title: "膝关节康复训练陪同",
careType: "rehab",
priority: "high",
status: "in_progress",
scheduledHoursOffset: -1,
assigneeLabel: "康复护理组",
executionNotes: "已完成热身,等待康复师复核动作。",
},
{
elderName: "周玉珍",
title: "餐前血糖记录与助餐",
careType: "meal",
priority: "normal",
status: "completed",
scheduledHoursOffset: -4,
completedHoursAgo: 3,
assigneeLabel: "二号护理组",
executionNotes: "餐前血糖已记录,午餐摄入约八成。",
},
{
elderName: "赵国华",
title: "重点照护夜间巡检补记",
careType: "inspection",
priority: "urgent",
status: "pending",
scheduledHoursOffset: -2,
assigneeLabel: "夜班护理组",
executionNotes: "",
},
{
elderName: "刘长青",
title: "用药提醒与胸痛询问",
careType: "medication",
priority: "high",
status: "pending",
scheduledHoursOffset: 3,
assigneeLabel: "三号护理组",
executionNotes: "",
},
];
function hasRows(rows: unknown[]): boolean {
return rows.length > 0;
}
@@ -558,6 +625,31 @@ export async function seedDefaultWorkspaceData(organizationId: string): Promise<
}),
);
const now = new Date();
await transaction.insert(careTasks).values(
DEFAULT_CARE_TASKS.map((task) => {
const elderId = elderIdByName.get(task.elderName);
if (!elderId) {
throw new Error("默认护理任务初始化失败");
}
const completedAt = task.completedHoursAgo === undefined ? undefined : new Date(now.getTime() - task.completedHoursAgo * 60 * 60 * 1000);
return {
organizationId,
elderId,
title: task.title,
careType: task.careType,
priority: task.priority,
status: task.status,
scheduledAt: new Date(now.getTime() + task.scheduledHoursOffset * 60 * 60 * 1000),
assigneeLabel: task.assigneeLabel,
executionNotes: task.executionNotes,
completedAt,
};
}),
);
await transaction.insert(healthProfiles).values(
DEFAULT_HEALTH_PROFILES.map((profile) => {
const elderId = elderIdByName.get(profile.elderName);
@@ -577,7 +669,6 @@ export async function seedDefaultWorkspaceData(organizationId: string): Promise<
}),
);
const now = new Date();
const vitalRows = await transaction
.insert(vitalRecords)
.values(

View File

@@ -25,6 +25,8 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
{ id: "audit:read", label: "查看审计", category: "审计", description: "查看关键操作审计日志。" },
{ id: "incident:read", label: "查看故障", category: "运维", description: "查看系统健康和故障中心。" },
{ id: "incident:manage", label: "处理故障", category: "运维", description: "确认、处理和关闭故障事件。" },
{ id: "care:read", label: "查看护理任务", category: "护理", description: "查看护理服务任务、执行状态和记录。" },
{ id: "care:manage", label: "处理护理任务", category: "护理", description: "启动、完成和维护护理服务执行记录。" },
{ id: "health:read", label: "查看健康数据", category: "健康", description: "查看老人健康档案、生命体征和异常复核。" },
{ id: "health:manage", label: "管理健康数据", category: "健康", description: "维护健康档案、生命体征、慢病和异常复核。" },
{ id: "facility:read", label: "查看房间床位", category: "床位", description: "查看房间、床位和占用状态。" },
@@ -91,6 +93,8 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [
"permission:read",
"audit:read",
"incident:read",
"care:read",
"care:manage",
"health:read",
"health:manage",
"facility:read",
@@ -111,6 +115,8 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [
"account:read",
"role:read",
"audit:read",
"care:read",
"care:manage",
"health:read",
"health:manage",
"facility:read",
@@ -127,13 +133,13 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [
key: "caregiver",
scope: "organization",
description: "照护人员,查看床位和维护老人照护信息。",
permissions: ["health:read", "health:manage", "facility:read", "admission:read", "elder:read", "elder:update"],
permissions: ["care:read", "care:manage", "health:read", "health:manage", "facility:read", "admission:read", "elder:read", "elder:update"],
},
{
key: "viewer",
scope: "organization",
description: "普通用户,只读查看老人、床位和入住信息。",
permissions: ["health:read", "facility:read", "admission:read", "elder:read"],
permissions: ["care:read", "health:read", "facility:read", "admission:read", "elder:read"],
},
{
key: "family",

View File

@@ -27,6 +27,9 @@ export const joinRequestStatusEnum = pgEnum("join_request_status", ["pending", "
export const vitalRecordSourceEnum = pgEnum("vital_record_source", ["manual", "device", "import"]);
export const chronicConditionStatusEnum = pgEnum("chronic_condition_status", ["active", "controlled", "resolved"]);
export const healthReviewStatusEnum = pgEnum("health_review_status", ["pending", "reviewed", "resolved"]);
export const careTaskTypeEnum = pgEnum("care_task_type", ["daily_care", "meal", "medication", "rehab", "inspection", "cleaning", "other"]);
export const careTaskPriorityEnum = pgEnum("care_task_priority", ["low", "normal", "high", "urgent"]);
export const careTaskStatusEnum = pgEnum("care_task_status", ["pending", "in_progress", "completed", "cancelled"]);
export const systemSettings = pgTable("system_settings", {
id: text("id").primaryKey().default("global"),
@@ -299,6 +302,27 @@ export const healthAnomalyReviews = pgTable("health_anomaly_reviews", {
reviewQueueLookup: index("health_anomaly_reviews_queue_lookup").on(table.organizationId, table.status, table.severity),
}));
export const careTasks = pgTable("care_tasks", {
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
elderId: uuid("elder_id").references(() => elders.id, { onDelete: "set null" }),
title: text("title").notNull(),
careType: careTaskTypeEnum("care_type").notNull(),
priority: careTaskPriorityEnum("priority").notNull().default("normal"),
status: careTaskStatusEnum("status").notNull().default("pending"),
scheduledAt: timestamp("scheduled_at", { withTimezone: true }).notNull(),
assigneeLabel: text("assignee_label").notNull().default(""),
executionNotes: text("execution_notes").notNull().default(""),
completedAt: timestamp("completed_at", { withTimezone: true }),
createdByAccountId: uuid("created_by_account_id").references(() => accounts.id),
completedByAccountId: uuid("completed_by_account_id").references(() => accounts.id),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
queueLookup: index("care_tasks_queue_lookup").on(table.organizationId, table.status, table.priority),
scheduleLookup: index("care_tasks_schedule_lookup").on(table.organizationId, table.scheduledAt),
}));
export const admissions = pgTable("admissions", {
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),

View File

@@ -39,6 +39,8 @@ export const PERMISSIONS = [
"audit:read",
"incident:read",
"incident:manage",
"care:read",
"care:manage",
"health:read",
"health:manage",
"facility:read",

View File

@@ -61,7 +61,7 @@ export const navGroups: NavGroup[] = [
path: "/care",
label: "护理服务",
icon: "care",
permission: "elder:update",
permission: "care:read",
},
{
path: "/emergency",