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

@@ -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 {
admissions,
alertRules,
alertTriggers,
beds,
buildings,
campuses,
careTasks,
chronicConditions,
deviceAssets,
elders,
familyContacts,
familyFeedback,
familyVisitAppointments,
floors,
healthAnomalyReviews,
healthProfiles,
maintenanceTickets,
notices,
rooms,
systemIncidents,
vitalRecords,
@@ -116,6 +124,80 @@ type SeedSystemIncident = {
title: string;
};
type SeedDeviceAsset = {
category: string;
code: string;
lastInspectedHoursAgo?: number;
location: string;
name: string;
notes: string;
status: "active" | "maintenance" | "disabled" | "retired";
};
type SeedMaintenanceTicket = {
assigneeLabel: string;
description: string;
deviceCode: string;
priority: "low" | "normal" | "high" | "urgent";
resolutionNotes: string;
status: "open" | "assigned" | "resolved" | "closed" | "cancelled";
title: string;
};
type SeedNotice = {
audience: string;
content: string;
publishedHoursAgo?: number;
status: "draft" | "published" | "retracted";
title: string;
};
type SeedAlertRule = {
conditionSummary: string;
name: string;
ruleType: "health" | "care" | "device" | "incident" | "admission" | "other";
severity: "info" | "warning" | "critical";
status: "enabled" | "disabled";
suggestion: string;
};
type SeedAlertTrigger = {
createdHoursAgo: number;
description: string;
elderName?: string;
handlingNotes: string;
ruleName: string;
source: string;
status: "open" | "acknowledged" | "resolved" | "closed";
title: string;
};
type SeedFamilyContact = {
elderName: string;
name: string;
notes: string;
phone: string;
relationship: string;
status: "active" | "suspended" | "archived";
};
type SeedFamilyVisit = {
contactName: string;
elderName: string;
notes: string;
scheduledHoursOffset: number;
status: "requested" | "approved" | "completed" | "cancelled";
};
type SeedFamilyFeedback = {
contactName: string;
content: string;
elderName: string;
feedbackType: "service" | "care" | "meal" | "environment" | "other";
responseNotes: string;
status: "open" | "in_progress" | "resolved" | "closed";
};
const DEFAULT_ROOMS: SeedRoom[] = [
{ campusName: "主院区", buildingName: "护理楼", floorName: "一层", floorLevel: 1, code: "A101", name: "阳光房", capacity: 2 },
{ campusName: "主院区", buildingName: "护理楼", floorName: "一层", floorLevel: 1, code: "A102", name: "康养房", capacity: 2 },
@@ -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 {
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: "health:read", label: "查看健康数据", category: "健康", description: "查看老人健康档案、生命体征和异常复核。" },
{ id: "health:manage", label: "管理健康数据", category: "健康", description: "维护健康档案、生命体征、慢病和异常复核。" },
{ id: "device:read", label: "查看设备运维", category: "协同", description: "查看设备台账和维修工单。" },
{ id: "device:manage", label: "管理设备运维", category: "协同", description: "维护设备台账、维修工单和处理状态。" },
{ id: "notice:read", label: "查看公告通知", category: "协同", description: "查看公告和阅读状态。" },
{ id: "notice:manage", label: "管理公告通知", category: "协同", description: "创建、发布、撤回和删除公告。" },
{ id: "alert:read", label: "查看规则预警", category: "协同", description: "查看预警规则和触发记录。" },
{ id: "alert:manage", label: "管理规则预警", category: "协同", description: "维护预警规则并处理触发记录。" },
{ id: "family:read", label: "查看家属服务", category: "协同", description: "查看家属联系人、探访预约和服务反馈。" },
{ id: "family:manage", label: "管理家属服务", category: "协同", description: "维护家属联系人、探访预约和反馈处理。" },
{ id: "facility:read", label: "查看房间床位", category: "床位", description: "查看房间、床位和占用状态。" },
{ id: "facility:manage", label: "管理房间床位", category: "床位", description: "维护院区、房间、床位和床位状态。" },
{ id: "admission:read", label: "查看入住", category: "入住", description: "查看入住、换床、退住历史。" },
@@ -97,6 +105,14 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [
"care:manage",
"health:read",
"health:manage",
"device:read",
"device:manage",
"notice:read",
"notice:manage",
"alert:read",
"alert:manage",
"family:read",
"family:manage",
"facility:read",
"facility:manage",
"admission:read",
@@ -119,6 +135,14 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [
"care:manage",
"health:read",
"health:manage",
"device:read",
"device:manage",
"notice:read",
"notice:manage",
"alert:read",
"alert:manage",
"family:read",
"family:manage",
"facility:read",
"facility:manage",
"admission:read",
@@ -133,13 +157,35 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [
key: "caregiver",
scope: "organization",
description: "照护人员,查看床位和维护老人照护信息。",
permissions: ["care:read", "care:manage", "health:read", "health:manage", "facility:read", "admission:read", "elder:read", "elder:update"],
permissions: [
"care:read",
"care:manage",
"health:read",
"health:manage",
"device:read",
"alert:read",
"family:read",
"facility:read",
"admission:read",
"elder:read",
"elder:update",
],
},
{
key: "viewer",
scope: "organization",
description: "普通用户,只读查看老人、床位和入住信息。",
permissions: ["care:read", "health:read", "facility:read", "admission:read", "elder:read"],
permissions: [
"care:read",
"health:read",
"device:read",
"notice:read",
"alert:read",
"family:read",
"facility:read",
"admission:read",
"elder:read",
],
},
{
key: "family",

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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