TorbatYar/frontend/modules/crm/components/CrmListCrudPage.tsx
Mortezakoohjani e140908034 Ship enterprise CRM frontend with real API wiring and thin app routes.
Connect all CRM pages to the BFF and CRM service, add module docs, and mark CRM available in the SuperApp catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 20:58:17 +03:30

332 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { useState } from "react";
import { useForm, type FieldValues, type DefaultValues, type Resolver } 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, RotateCcw } from "lucide-react";
import type { z } from "zod";
import {
Button,
Dialog,
Drawer,
Input,
EmptyState,
FormField,
ConfirmDialog,
Textarea,
Select,
} from "@/components/ds";
import { CrmTablePage } from "@/modules/crm/design-system";
import { CrmBreadcrumbs } from "@/modules/crm/components/CrmBreadcrumbs";
import { CrmPageLoader, CrmPageError } from "@/modules/crm/pages/shared";
import { useTenantId } from "@/hooks/useTenantId";
import { exportToCsv } from "@/modules/crm/pages/shared";
export type CrmFieldConfig = {
name: string;
label: string;
type?: "text" | "email" | "tel" | "number" | "textarea" | "select";
options?: { value: string; label: string }[];
required?: boolean;
editOnly?: boolean;
createOnly?: boolean;
};
export type CrmListCrudConfig<T extends { id: string }> = {
title: string;
description?: string;
breadcrumb?: string;
resourceKey: string;
createSchema: z.ZodTypeAny;
editSchema: z.ZodTypeAny;
createDefaults: DefaultValues<FieldValues>;
fields: CrmFieldConfig[];
columns: {
key: string;
header: string;
render?: (row: T) => React.ReactNode;
}[];
list: (tenantId: string) => Promise<T[]>;
create: (tenantId: string, data: FieldValues) => Promise<T>;
update?: (tenantId: string, id: string, data: FieldValues) => Promise<T>;
remove?: (tenantId: string, id: string) => Promise<unknown>;
restore?: (tenantId: string, id: string) => Promise<unknown>;
exportColumns?: string[];
filterDeleted?: boolean;
};
function renderField(
field: CrmFieldConfig,
register: ReturnType<typeof useForm>["register"],
mode: "create" | "edit"
) {
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} />
</FormField>
);
}
if (field.type === "select" && field.options) {
return (
<FormField key={field.name} label={field.label}>
<Select {...register(field.name)}>
{field.options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</Select>
</FormField>
);
}
return (
<FormField key={field.name} label={field.label}>
<Input type={field.type ?? "text"} {...register(field.name)} />
</FormField>
);
}
export function createCrmListPage<T extends { id: string; is_deleted?: boolean }>(
config: CrmListCrudConfig<T>
) {
return function CrmListPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [editRow, setEditRow] = useState<T | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [selected, setSelected] = useState<Set<string>>(new Set());
const pageSize = 20;
const listQ = useQuery({
queryKey: ["crm", tenantId, config.resourceKey],
queryFn: () => config.list(tenantId!),
enabled: !!tenantId,
});
const createForm = useForm<FieldValues>({
resolver: zodResolver(config.createSchema as never) as Resolver<FieldValues>,
defaultValues: config.createDefaults,
});
const editForm = useForm<FieldValues>({
resolver: zodResolver(config.editSchema as never) as Resolver<FieldValues>,
});
const invalidate = () =>
qc.invalidateQueries({ queryKey: ["crm", tenantId, config.resourceKey] });
const createM = useMutation({
mutationFn: (d: FieldValues) => config.create(tenantId!, d),
onSuccess: async () => {
toast.success("رکورد ایجاد شد");
setCreateOpen(false);
createForm.reset(config.createDefaults);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const updateM = useMutation({
mutationFn: (d: FieldValues) => config.update!(tenantId!, editRow!.id, d),
onSuccess: async () => {
toast.success("رکورد به‌روز شد");
setEditRow(null);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const deleteM = useMutation({
mutationFn: (id: string) => config.remove!(tenantId!, id),
onSuccess: async () => {
toast.success("رکورد حذف شد");
setDeleteId(null);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const restoreM = useMutation({
mutationFn: (id: string) => config.restore!(tenantId!, id),
onSuccess: async () => {
toast.success("رکورد بازیابی شد");
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const openEdit = (row: T) => {
setEditRow(row);
const values: FieldValues = {};
for (const f of config.fields) {
if (f.createOnly) continue;
values[f.name] = (row as Record<string, unknown>)[f.name] ?? "";
}
editForm.reset(values);
};
if (!tenantId || listQ.isLoading) return <CrmPageLoader />;
if (listQ.error) return <CrmPageError error={listQ.error} onRetry={() => listQ.refetch()} />;
let rows = listQ.data ?? [];
if (config.filterDeleted) rows = rows.filter((r) => !r.is_deleted);
const tableColumns = [
...config.columns.map((c) => ({
key: c.key,
header: c.header,
render: c.render
? (row: Record<string, unknown>) => c.render!(row as T)
: undefined,
})),
{
key: "actions",
header: "عملیات",
searchable: false as const,
render: (row: Record<string, unknown>) => {
const r = row as T;
return (
<div className="flex gap-1">
{config.update ? (
<Button variant="ghost" size="icon" onClick={() => openEdit(r)} aria-label="ویرایش">
<Pencil className="h-4 w-4" />
</Button>
) : null}
{config.remove && !r.is_deleted ? (
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteId(r.id)}
aria-label="حذف"
>
<Trash2 className="h-4 w-4" />
</Button>
) : null}
{config.restore && r.is_deleted ? (
<Button
variant="ghost"
size="icon"
onClick={() => restoreM.mutate(r.id)}
aria-label="بازیابی"
>
<RotateCcw className="h-4 w-4" />
</Button>
) : null}
</div>
);
},
},
];
return (
<div>
<CrmTablePage
breadcrumbs={
config.breadcrumb ? (
<CrmBreadcrumbs items={[{ label: config.breadcrumb }]} />
) : undefined
}
title={config.title}
description={config.description}
columns={tableColumns}
rows={rows as unknown as Record<string, unknown>[]}
page={page}
pageSize={pageSize}
onPageChange={setPage}
selectedIds={selected}
onSelectionChange={setSelected}
bulkActions={
config.remove && selected.size > 0 ? (
<Button
variant="outline"
size="sm"
onClick={() => {
selected.forEach((id) => config.remove!(tenantId, id));
toast.success("حذف گروهی انجام شد");
setSelected(new Set());
invalidate();
}}
>
حذف انتخابشدهها
</Button>
) : undefined
}
actions={
<div className="flex gap-2">
{config.exportColumns ? (
<Button
variant="outline"
onClick={() =>
exportToCsv(
`${config.resourceKey}.csv`,
rows as unknown as Record<string, unknown>[],
config.exportColumns!
)
}
>
خروجی CSV
</Button>
) : null}
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
جدید
</Button>
</div>
}
empty={<EmptyState title="موردی ثبت نشده" action={<Button onClick={() => setCreateOpen(true)}>ایجاد</Button>} />}
/>
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} title={`${config.title} — جدید`}>
<form className="space-y-4" onSubmit={createForm.handleSubmit((d) => createM.mutate(d as FieldValues))}>
{config.fields.map((f) => renderField(f, createForm.register, "create"))}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={() => setCreateOpen(false)}>
انصراف
</Button>
<Button type="submit" loading={createM.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
{config.update ? (
<Drawer open={!!editRow} onClose={() => setEditRow(null)} title="ویرایش">
<form className="space-y-4" onSubmit={editForm.handleSubmit((d) => updateM.mutate(d as FieldValues))}>
{config.fields.map((f) => renderField(f, editForm.register, "edit"))}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={() => setEditRow(null)}>
انصراف
</Button>
<Button type="submit" loading={updateM.isPending}>
بهروزرسانی
</Button>
</div>
</form>
</Drawer>
) : null}
{config.remove ? (
<ConfirmDialog
open={!!deleteId}
onClose={() => setDeleteId(null)}
title="حذف رکورد"
description="آیا از حذف این مورد مطمئن هستید؟"
confirmLabel="حذف"
onConfirm={() => deleteId && deleteM.mutate(deleteId)}
loading={deleteM.isPending}
danger
/>
) : null}
</div>
);
};
}