TorbatYar/frontend/modules/experience/components/ExperienceListCrudPage.tsx
Mortezakoohjani 0eec9f729b feat(experience): deploy Experience frontend FE-11.0-11.5 portal
Commit completed Torbat Pages admin frontend module, BFF proxy, routes, and validation artifacts for official platform deploy.

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

385 lines
14 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 {
ExperienceTablePage,
ExperienceStatGrid,
ExperienceStatusChip,
} from "@/modules/experience/design-system";
import {
ExperiencePageLoader,
ExperiencePageError,
exportToCsv,
} from "@/modules/experience/pages/shared";
import { useTenantId } from "@/hooks/useTenantId";
import { useExperienceLookups } from "@/modules/experience/hooks/useExperienceCapabilities";
import { useExperiencePermissions } from "@/modules/experience/hooks/useExperiencePermissions";
import { PermissionDeniedState } from "@/src/shared/ui";
import { normalizeList } from "@/modules/experience/services/experience-api";
export type ExperienceFieldConfig = {
name: string;
label: string;
type?: "text" | "number" | "email" | "textarea" | "select" | "workspace" | "site";
options?: { value: string; label: string }[];
required?: boolean;
createOnly?: boolean;
editOnly?: boolean;
placeholder?: string;
};
export type ExperienceColumnConfig = {
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>[]>;
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 ExperienceListConfig = {
title: string;
description?: string;
breadcrumb?: string;
resourceKey: string;
permission?: string;
api: ApiResource;
columns: ExperienceColumnConfig[];
createFields?: ExperienceFieldConfig[];
editFields?: ExperienceFieldConfig[];
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: ExperienceFieldConfig[]) {
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 renderCell(col: ExperienceColumnConfig, row: Record<string, unknown>) {
if (col.render) return col.render(row);
const val = row[col.key];
if (col.type === "status" && val) return <ExperienceStatusChip 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(--experience-accent)] hover:underline">
{val != null ? String(val) : "—"}
</Link>
);
}
return val != null ? String(val) : "—";
}
function renderField(
field: ExperienceFieldConfig,
register: ReturnType<typeof useForm>["register"],
mode: "create" | "edit",
lookups: { workspaces: Record<string, unknown>[]; sites: 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 === "workspace") {
return (
<FormField key={field.name} label={field.label}>
<Select {...register(field.name)}>
<option value="">انتخاب فضای کاری</option>
{lookups.workspaces.map((w) => (
<option key={String(w.id)} value={String(w.id)}>
{String(w.name)} ({String(w.code)})
</option>
))}
</Select>
</FormField>
);
}
if (field.type === "site") {
return (
<FormField key={field.name} label={field.label}>
<Select {...register(field.name)}>
<option value="">انتخاب سایت</option>
{lookups.sites.map((s) => (
<option key={String(s.id)} value={String(s.id)}>
{String(s.name)} ({String(s.code)})
</option>
))}
</Select>
</FormField>
);
}
return (
<FormField key={field.name} label={field.label}>
<Input {...register(field.name)} placeholder={field.placeholder} />
</FormField>
);
}
export function createExperienceListPage(config: ExperienceListConfig) {
return function ExperienceListPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const perms = useExperiencePermissions();
const lookupsQ = useExperienceLookups(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: ["experience", 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: ["experience", 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 || lookupsQ.isLoading) return <ExperiencePageLoader />;
if (listQ.error) return <ExperiencePageError error={listQ.error} onRetry={() => listQ.refetch()} />;
if (config.permission && !perms.can(config.permission)) {
return <PermissionDeniedState />;
}
const rows = listQ.data ?? [];
const lookups = lookupsQ.data ?? { workspaces: [], sites: [] };
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 (
<>
<ExperienceTablePage
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>
}
/>
<ExperienceStatGrid
stats={[
{ label: "کل", value: String(rows.length) },
{ label: "فعال", value: String(rows.filter((r) => r.status === "active" || r.publish_status === "published").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)}
/>
{rows.length > 0 && (canEdit || canDelete || config.detailHref) ? (
<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, 8).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}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</>
);
};
}