TorbatYar/frontend/modules/communication/components/CommunicationListCrudPage.tsx
Mortezakoohjani 10c3c43a75 feat(communication): add SMS MVP frontend with governance artifacts.
Ship the full Communication portal (real SMS API, feature-locked future channels), BFF proxy, docs, snapshot, and phase handover.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 11:11:25 +03:30

430 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useCallback, useState } from "react";
import { useForm, type FieldValues, type DefaultValues } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Plus, Pencil, Trash2, Download, RefreshCw, Eye } from "lucide-react";
import Link from "next/link";
import { z } from "zod";
import {
Button,
Dialog,
Drawer,
Input,
ConfirmDialog,
Select,
Textarea,
FormField,
} from "@/components/ds";
import { CommunicationTablePage, CommunicationStatGrid } from "@/modules/communication/design-system";
import {
CommunicationPageLoader,
CommunicationPageError,
exportToCsv,
} from "@/modules/communication/pages/shared";
import { useTenantId } from "@/hooks/useTenantId";
import { useCommunicationLookups } from "@/modules/communication/hooks/useCommunicationCapabilities";
import { useCommunicationPermissions } from "@/modules/communication/hooks/useCommunicationPermissions";
import { CommunicationStatusChip } from "@/modules/communication/design-system";
import { PermissionDeniedState } from "@/src/shared/ui";
export type CommunicationFieldConfig = {
name: string;
label: string;
type?: "text" | "number" | "email" | "tel" | "textarea" | "select" | "organization" | "hub";
options?: { value: string; label: string }[];
required?: boolean;
createOnly?: boolean;
editOnly?: boolean;
placeholder?: string;
};
export type CommunicationColumnConfig = {
key: string;
header: string;
type?: "status" | "datetime" | "text" | "link";
linkPrefix?: string;
render?: (row: Record<string, unknown>) => React.ReactNode;
};
type ApiResource = {
list: (
tenantId: string,
params?: Record<string, string | number | undefined>
) => Promise<Record<string, unknown>[] | { items: Record<string, unknown>[]; total: number }>;
get?: (tenantId: string, id: string) => Promise<Record<string, unknown>>;
create?: (tenantId: string, body: Record<string, unknown>) => Promise<unknown>;
update?: (tenantId: string, id: string, body: Record<string, unknown>) => Promise<unknown>;
remove?: (tenantId: string, id: string) => Promise<unknown>;
};
export type CommunicationListConfig = {
title: string;
description?: string;
breadcrumb?: string;
resourceKey: string;
permission?: string;
api: ApiResource;
columns: CommunicationColumnConfig[];
createFields?: CommunicationFieldConfig[];
editFields?: CommunicationFieldConfig[];
readOnly?: boolean;
detailHref?: (row: Record<string, unknown>) => string | null;
rowActions?: {
label: string;
onClick: (tenantId: string, row: Record<string, unknown>) => Promise<void>;
variant?: "default" | "outline" | "danger";
}[];
};
function buildSchema(fields: CommunicationFieldConfig[]) {
const shape: Record<string, z.ZodTypeAny> = {};
for (const f of fields) {
shape[f.name] = f.required
? z.string().min(1, `${f.label} الزامی است`)
: z.string().optional();
}
return z.object(shape);
}
function normalizeList(
data: Record<string, unknown>[] | { items: Record<string, unknown>[]; total: number }
): Record<string, unknown>[] {
if (Array.isArray(data)) return data;
return data.items ?? [];
}
function renderCell(col: CommunicationColumnConfig, row: Record<string, unknown>) {
if (col.render) return col.render(row);
const val = row[col.key];
if (col.type === "status" && val) return <CommunicationStatusChip status={String(val)} />;
if (col.type === "datetime" && val) {
try {
return new Date(String(val)).toLocaleString("fa-IR");
} catch {
return String(val);
}
}
if (col.type === "link" && col.linkPrefix && row.id) {
return (
<Link href={`${col.linkPrefix}/${row.id}`} className="text-[var(--communication-accent)] hover:underline">
{val != null ? String(val) : "—"}
</Link>
);
}
return val != null ? String(val) : "—";
}
function renderField(
field: CommunicationFieldConfig,
register: ReturnType<typeof useForm>["register"],
mode: "create" | "edit",
lookups: { organizations: Record<string, unknown>[]; hubs: Record<string, unknown>[] }
) {
if (mode === "create" && field.editOnly) return null;
if (mode === "edit" && field.createOnly) return null;
if (field.type === "textarea") {
return (
<FormField key={field.name} label={field.label}>
<Textarea {...register(field.name)} rows={3} placeholder={field.placeholder} />
</FormField>
);
}
if (field.type === "select" && field.options) {
return (
<FormField key={field.name} label={field.label}>
<Select {...register(field.name)}>
<option value="">انتخاب</option>
{field.options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</Select>
</FormField>
);
}
if (field.type === "organization") {
return (
<FormField key={field.name} label={field.label}>
<Select {...register(field.name)}>
<option value="">انتخاب سازمان</option>
{lookups.organizations.map((o) => (
<option key={String(o.id)} value={String(o.id)}>
{String(o.name)} ({String(o.code)})
</option>
))}
</Select>
</FormField>
);
}
if (field.type === "hub") {
return (
<FormField key={field.name} label={field.label}>
<Select {...register(field.name)}>
<option value="">انتخاب هاب</option>
{lookups.hubs.map((h) => (
<option key={String(h.id)} value={String(h.id)}>
{String(h.name)} ({String(h.code)})
</option>
))}
</Select>
</FormField>
);
}
return (
<FormField key={field.name} label={field.label}>
<Input {...register(field.name)} placeholder={field.placeholder} />
</FormField>
);
}
export function createCommunicationListPage(config: CommunicationListConfig) {
return function CommunicationListPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const perms = useCommunicationPermissions();
const lookupsQ = useCommunicationLookups(tenantId);
const [createOpen, setCreateOpen] = useState(false);
const [editRow, setEditRow] = useState<Record<string, unknown> | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [page, setPage] = useState(1);
const pageSize = 20;
const createFields = config.createFields ?? [];
const editFields = config.editFields ?? [
...createFields.filter((f) => !f.createOnly),
{ name: "version", label: "نسخه", required: true, editOnly: true },
];
const createSchema = buildSchema(createFields);
const editSchema = buildSchema(editFields);
const listQ = useQuery({
queryKey: ["communication", tenantId, config.resourceKey, page],
queryFn: async () => normalizeList(await config.api.list(tenantId!, { page, page_size: pageSize })),
enabled: !!tenantId,
});
const createForm = useForm({
resolver: zodResolver(createSchema),
defaultValues: Object.fromEntries(createFields.map((f) => [f.name, ""])) as DefaultValues<FieldValues>,
});
const editForm = useForm({ resolver: zodResolver(editSchema) });
const invalidate = useCallback(
() => qc.invalidateQueries({ queryKey: ["communication", tenantId, config.resourceKey] }),
[qc, tenantId, config.resourceKey]
);
const createM = useMutation({
mutationFn: (d: FieldValues) => config.api.create!(tenantId!, d as Record<string, unknown>),
onSuccess: () => {
toast.success("با موفقیت ایجاد شد");
setCreateOpen(false);
createForm.reset();
invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const updateM = useMutation({
mutationFn: (d: FieldValues) =>
config.api.update!(tenantId!, String(editRow!.id), d as Record<string, unknown>),
onSuccess: () => {
toast.success("با موفقیت به‌روزرسانی شد");
setEditRow(null);
invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const deleteM = useMutation({
mutationFn: (id: string) => config.api.remove!(tenantId!, id),
onSuccess: () => {
toast.success("حذف شد");
setDeleteId(null);
invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || listQ.isLoading) return <CommunicationPageLoader />;
if (listQ.error) return <CommunicationPageError error={listQ.error} onRetry={() => listQ.refetch()} />;
if (config.permission && !perms.can(config.permission)) {
return <PermissionDeniedState />;
}
const rows = listQ.data ?? [];
const lookups = { organizations: [] as Record<string, unknown>[], hubs: [] as Record<string, unknown>[] };
const canCreate = !config.readOnly && config.api.create && (!config.permission || perms.can(config.permission.replace(".view", ".create")));
const canEdit = !config.readOnly && config.api.update;
const canDelete = !config.readOnly && config.api.remove;
const tableColumns = config.columns.map((c) => ({
...c,
render: (row: Record<string, unknown>) => renderCell(c, row),
}));
return (
<>
<CommunicationTablePage
title={config.title}
description={config.description}
breadcrumb={config.breadcrumb ?? config.title}
columns={tableColumns}
rows={rows}
page={page}
pageSize={pageSize}
onPageChange={setPage}
loading={listQ.isFetching}
actions={
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => listQ.refetch()}>
<RefreshCw className="ml-1 h-4 w-4" />
بروزرسانی
</Button>
<Button
variant="outline"
size="sm"
onClick={() => exportToCsv(config.title, rows, config.columns.map((c) => c.key))}
>
<Download className="ml-1 h-4 w-4" />
خروجی CSV
</Button>
{canCreate ? (
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="ml-1 h-4 w-4" />
ایجاد
</Button>
) : null}
</div>
}
/>
<CommunicationStatGrid
stats={[
{ label: "کل", value: String(rows.length) },
{
label: "فعال",
value: String(rows.filter((r) => r.status === "active").length),
},
]}
/>
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} title={`ایجاد ${config.title}`}>
<form
className="space-y-4"
onSubmit={createForm.handleSubmit((d) => createM.mutate(d))}
>
{createFields.map((f) => renderField(f, createForm.register, "create", lookups))}
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>
انصراف
</Button>
<Button type="submit" loading={createM.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
<Drawer
open={!!editRow}
onClose={() => setEditRow(null)}
title={`ویرایش ${config.title}`}
>
{editRow ? (
<form
className="space-y-4"
onSubmit={editForm.handleSubmit((d) => updateM.mutate(d))}
>
{editFields.map((f) => renderField(f, editForm.register, "edit", lookups))}
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setEditRow(null)}>
انصراف
</Button>
<Button type="submit" loading={updateM.isPending}>
ذخیره
</Button>
</div>
</form>
) : null}
</Drawer>
<ConfirmDialog
open={!!deleteId}
onClose={() => setDeleteId(null)}
title="حذف"
description="آیا از حذف این مورد اطمینان دارید؟"
confirmLabel="حذف"
danger
onConfirm={() => deleteId && deleteM.mutate(deleteId)}
/>
{/* Row action buttons rendered via table extension - inline actions in footer */}
{rows.length > 0 && (canEdit || canDelete || config.rowActions) ? (
<div className="mt-4 overflow-x-auto rounded-xl border border-[var(--border)]">
<table className="w-full text-xs">
<thead>
<tr className="bg-[var(--surface-muted)]">
<th className="px-3 py-2 text-right">عملیات سریع</th>
</tr>
</thead>
<tbody>
{rows.slice(0, 5).map((row) => (
<tr key={String(row.id)} className="border-t border-[var(--border)]">
<td className="flex flex-wrap gap-1 px-3 py-2">
{config.detailHref?.(row) ? (
<Link href={config.detailHref!(row)!}>
<Button variant="ghost" size="sm">
<Eye className="h-3 w-3" />
</Button>
</Link>
) : null}
{canEdit ? (
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditRow(row);
editForm.reset(
Object.fromEntries(
editFields.map((f) => [f.name, String(row[f.name] ?? "")])
) as DefaultValues<FieldValues>
);
}}
>
<Pencil className="h-3 w-3" />
</Button>
) : null}
{canDelete ? (
<Button variant="ghost" size="sm" onClick={() => setDeleteId(String(row.id))}>
<Trash2 className="h-3 w-3" />
</Button>
) : null}
{config.rowActions?.map((a) => (
<Button
key={a.label}
variant={a.variant ?? "outline"}
size="sm"
onClick={() => a.onClick(tenantId, row)}
>
{a.label}
</Button>
))}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</>
);
};
}