Commit completed Torbat Pages admin frontend module, BFF proxy, routes, and validation artifacts for official platform deploy. Co-authored-by: Cursor <cursoragent@cursor.com>
385 lines
14 KiB
TypeScript
385 lines
14 KiB
TypeScript
"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}
|
||
</>
|
||
);
|
||
};
|
||
}
|