feat: build collaboration workspaces
This commit is contained in:
183
modules/alerts/components/AlertsWorkspaceClient.tsx
Normal file
183
modules/alerts/components/AlertsWorkspaceClient.tsx
Normal 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>;
|
||||
}
|
||||
242
modules/alerts/server/operations.ts
Normal file
242
modules/alerts/server/operations.ts
Normal 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
184
modules/alerts/types.ts
Normal 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"),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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",
|
||||
|
||||
325
modules/devices/components/DevicesWorkspaceClient.tsx
Normal file
325
modules/devices/components/DevicesWorkspaceClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
218
modules/devices/server/operations.ts
Normal file
218
modules/devices/server/operations.ts
Normal 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
200
modules/devices/types.ts
Normal 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"),
|
||||
},
|
||||
};
|
||||
}
|
||||
181
modules/family/components/FamilyWorkspaceClient.tsx
Normal file
181
modules/family/components/FamilyWorkspaceClient.tsx
Normal 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>;
|
||||
}
|
||||
340
modules/family/server/operations.ts
Normal file
340
modules/family/server/operations.ts
Normal 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
246
modules/family/types.ts
Normal 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"),
|
||||
},
|
||||
};
|
||||
}
|
||||
210
modules/notices/components/NoticesWorkspaceClient.tsx
Normal file
210
modules/notices/components/NoticesWorkspaceClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
180
modules/notices/server/operations.ts
Normal file
180
modules/notices/server/operations.ts
Normal 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
95
modules/notices/types.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user