TorbatYar/frontend/modules/loyalty/components/LoyaltyListCrudPage.tsx
Mortezakoohjani d579d0b142 feat(loyalty): add Loyalty Platform Frontend module
Add frontend/modules/loyalty with types, API client, design system, feature pages and thin App Router routes under app/loyalty/. BFF proxy at app/api/loyalty/. Include loyalty frontend docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 10:50:55 +03:30

348 lines
12 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 { LoyaltyTablePage } from "@/modules/loyalty/design-system";
import { LoyaltyBreadcrumbs } from "@/modules/loyalty/components/LoyaltyBreadcrumbs";
import { LoyaltyPageLoader, LoyaltyPageError, exportToCsv } from "@/modules/loyalty/pages/shared";
import { useTenantId } from "@/hooks/useTenantId";
export type LoyaltyFieldConfig = {
name: string;
label: string;
type?: "text" | "email" | "tel" | "number" | "textarea" | "select";
options?: { value: string; label: string }[];
required?: boolean;
editOnly?: boolean;
createOnly?: boolean;
};
export type LoyaltyListCrudConfig<T extends { id: string }> = {
title: string;
description?: string;
breadcrumb?: string;
resourceKey: string;
createSchema: z.ZodTypeAny;
editSchema: z.ZodTypeAny;
createDefaults: DefaultValues<FieldValues>;
fields: LoyaltyFieldConfig[];
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;
extraActions?: (row: T, helpers: { refresh: () => void }) => React.ReactNode;
};
function renderField(
field: LoyaltyFieldConfig,
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 createLoyaltyListPage<T extends { id: string; is_deleted?: boolean }>(
config: LoyaltyListCrudConfig<T>
) {
return function LoyaltyListPage() {
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: ["loyalty", 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: ["loyalty", 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] ?? "";
}
if ("version" in (row as object)) {
values.version = (row as { version?: number }).version;
}
editForm.reset(values);
};
if (!tenantId || listQ.isLoading) return <LoyaltyPageLoader />;
if (listQ.error) return <LoyaltyPageError 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.extraActions?.(r, { refresh: () => void invalidate() })}
{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>
<LoyaltyTablePage
breadcrumbs={
config.breadcrumb ? (
<LoyaltyBreadcrumbs 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());
void 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"))}
<input type="hidden" {...editForm.register("version", { valueAsNumber: true })} />
<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>
);
};
}