feat: build collaboration workspaces
This commit is contained in:
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"),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user