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>
578 lines
21 KiB
TypeScript
578 lines
21 KiB
TypeScript
"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}
|
||
</>
|
||
);
|
||
};
|
||
}
|