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>;
|
||||
}
|
||||
Reference in New Issue
Block a user