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>
348 lines
12 KiB
TypeScript
348 lines
12 KiB
TypeScript
"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>
|
||
);
|
||
};
|
||
}
|