TorbatYar/frontend/modules/experience/components/ExperienceListCrudPage.tsx
Mortezakoohjani 0d424c500a feat(platform): complete commercial runtime consolidation
Unify commercial runtime ownership across backend and frontend so platform, experience, and hospitality modules use the shared commercial source of truth.

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

578 lines
21 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, useMemo, 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,
ExperienceEmptyState,
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 {
formatExperienceError,
normalizeList,
} from "@/modules/experience/services/experience-api";
export type ExperienceFieldConfig = {
name: string;
label: string;
type?: "text" | "number" | "email" | "textarea" | "select" | "workspace" | "site" | "boolean";
options?: { value: string; label: string }[];
required?: boolean;
createOnly?: boolean;
editOnly?: boolean;
placeholder?: string;
hint?: string;
};
export type ExperienceColumnConfig = {
key: string;
header: string;
type?: "status" | "datetime" | "text" | "link" | "boolean";
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[];
/** When true (default), append optimistic `version` on edit. Disable for resources without version. */
optimisticLocking?: boolean;
/** Required list query filters — e.g. site_domains API requires site_id. */
listRequires?: Array<"workspace" | "site">;
readOnly?: boolean;
detailHref?: (row: Record<string, unknown>) => string | null;
};
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 sanitizeBody(
fields: ExperienceFieldConfig[],
data: FieldValues
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const f of fields) {
const raw = data[f.name];
if (raw === undefined || raw === null || raw === "") continue;
if (f.type === "number" || f.name === "version") {
const n = Number(raw);
if (!Number.isNaN(n)) out[f.name] = n;
continue;
}
if (f.type === "boolean") {
out[f.name] = raw === true || raw === "true" || raw === "1";
continue;
}
out[f.name] = raw;
}
return out;
}
function renderCell(col: ExperienceColumnConfig, row: Record<string, unknown>) {
if (col.render) return col.render(row);
const val = row[col.key];
if (col.type === "boolean") return val ? "بله" : "خیر";
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} hint={field.hint}>
<Textarea {...register(field.name)} rows={3} placeholder={field.placeholder} />
</FormField>
);
}
if (field.type === "boolean") {
return (
<FormField key={field.name} label={field.label} hint={field.hint}>
<Select {...register(field.name)}>
<option value="false">خیر</option>
<option value="true">بله</option>
</Select>
</FormField>
);
}
if (field.type === "select" && field.options) {
return (
<FormField key={field.name} label={field.label} hint={field.hint}>
<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} hint={field.hint}>
<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} hint={field.hint}>
<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} hint={field.hint}>
<Input
{...register(field.name)}
type={field.type === "number" ? "number" : field.type === "email" ? "email" : "text"}
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 [filterWorkspaceId, setFilterWorkspaceId] = useState("");
const [filterSiteId, setFilterSiteId] = useState("");
const pageSize = 20;
const requires = config.listRequires ?? [];
const optimisticLocking = config.optimisticLocking !== false;
const createFields = config.createFields ?? [];
const editFields = useMemo(() => {
if (config.editFields) return config.editFields;
const base = createFields.filter((f) => !f.createOnly);
if (optimisticLocking) {
return [...base, { name: "version", label: "نسخه", required: true, editOnly: true, type: "number" as const }];
}
return base;
}, [config.editFields, createFields, optimisticLocking]);
const createSchema = buildSchema(createFields);
const editSchema = buildSchema(editFields);
const filtersReady =
(!requires.includes("workspace") || !!filterWorkspaceId) &&
(!requires.includes("site") || !!filterSiteId);
const listParams = useMemo(() => {
const p: Record<string, string | number | undefined> = { page, page_size: pageSize };
if (filterWorkspaceId) p.workspace_id = filterWorkspaceId;
if (filterSiteId) p.site_id = filterSiteId;
return p;
}, [page, filterWorkspaceId, filterSiteId]);
const listQ = useQuery({
queryKey: ["experience", tenantId, config.resourceKey, listParams],
queryFn: async () => normalizeList(await config.api.list(tenantId!, listParams)),
enabled: !!tenantId && filtersReady,
});
const createForm = useForm({
resolver: zodResolver(createSchema),
defaultValues: Object.fromEntries(createFields.map((f) => [f.name, f.type === "boolean" ? "false" : ""])) 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!, sanitizeBody(createFields, d)),
onSuccess: () => {
toast.success("با موفقیت ایجاد شد");
setCreateOpen(false);
createForm.reset();
invalidate();
},
onError: (e: unknown) => toast.error(formatExperienceError(e)),
});
const updateM = useMutation({
mutationFn: (d: FieldValues) =>
config.api.update!(tenantId!, String(editRow!.id), sanitizeBody(editFields, d)),
onSuccess: () => {
toast.success("با موفقیت به‌روزرسانی شد");
setEditRow(null);
invalidate();
},
onError: (e: unknown) => toast.error(formatExperienceError(e)),
});
const deleteM = useMutation({
mutationFn: (id: string) => config.api.remove!(tenantId!, id),
onSuccess: () => {
toast.success("حذف شد");
setDeleteId(null);
invalidate();
},
onError: (e: unknown) => toast.error(formatExperienceError(e)),
});
if (!tenantId || lookupsQ.isLoading) return <ExperiencePageLoader />;
if (lookupsQ.error) {
return <ExperiencePageError error={lookupsQ.error} onRetry={() => lookupsQ.refetch()} />;
}
if (config.permission && !perms.can(config.permission)) {
return <PermissionDeniedState />;
}
const lookups = lookupsQ.data ?? { workspaces: [], sites: [] };
const filteredSites = filterWorkspaceId
? lookups.sites.filter((s) => String(s.workspace_id) === filterWorkspaceId)
: lookups.sites;
if (!filtersReady) {
return (
<div className="space-y-4">
<ExperienceTablePage
title={config.title}
description={config.description}
breadcrumb={config.breadcrumb ?? config.title}
columns={[]}
rows={[]}
page={1}
pageSize={pageSize}
onPageChange={() => undefined}
actions={null}
/>
<div className="grid gap-3 rounded-xl border border-[var(--border)] p-4 sm:grid-cols-2">
{requires.includes("workspace") ? (
<FormField label="فضای کاری">
<Select
value={filterWorkspaceId}
onChange={(e) => {
setFilterWorkspaceId(e.target.value);
setFilterSiteId("");
setPage(1);
}}
>
<option value="">انتخاب فضای کاری</option>
{lookups.workspaces.map((w) => (
<option key={String(w.id)} value={String(w.id)}>
{String(w.name)}
</option>
))}
</Select>
</FormField>
) : null}
{requires.includes("site") ? (
<FormField label="سایت">
<Select
value={filterSiteId}
onChange={(e) => {
setFilterSiteId(e.target.value);
setPage(1);
}}
>
<option value="">انتخاب سایت</option>
{filteredSites.map((s) => (
<option key={String(s.id)} value={String(s.id)}>
{String(s.name)}
</option>
))}
</Select>
</FormField>
) : null}
</div>
<ExperienceEmptyState
title="فیلتر لازم است"
description="برای نمایش این فهرست، فیلترهای بالا را انتخاب کنید."
/>
</div>
);
}
if (listQ.isLoading) return <ExperiencePageLoader />;
if (listQ.error) return <ExperiencePageError error={listQ.error} onRetry={() => listQ.refetch()} />;
const rows = listQ.data ?? [];
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 (
<>
{requires.length > 0 ? (
<div className="mb-4 grid gap-3 rounded-xl border border-[var(--border)] p-4 sm:grid-cols-2">
{requires.includes("workspace") ? (
<FormField label="فضای کاری">
<Select
value={filterWorkspaceId}
onChange={(e) => {
setFilterWorkspaceId(e.target.value);
setFilterSiteId("");
setPage(1);
}}
>
<option value="">انتخاب فضای کاری</option>
{lookups.workspaces.map((w) => (
<option key={String(w.id)} value={String(w.id)}>
{String(w.name)}
</option>
))}
</Select>
</FormField>
) : null}
{requires.includes("site") ? (
<FormField label="سایت">
<Select
value={filterSiteId}
onChange={(e) => {
setFilterSiteId(e.target.value);
setPage(1);
}}
>
<option value="">انتخاب سایت</option>
{filteredSites.map((s) => (
<option key={String(s.id)} value={String(s.id)}>
{String(s.name)}
</option>
))}
</Select>
</FormField>
) : null}
</div>
) : null}
<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" || r.is_verified === true).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,
f.type === "boolean"
? row[f.name] === true
? "true"
: "false"
: 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}
</>
);
};
}