Ship the delivery platform UI under modules/delivery with thin app routes, BFF proxy, CRUD for phase 10.0-10.1 APIs, and capability-gated future screens. Co-authored-by: Cursor <cursoragent@cursor.com>
430 lines
15 KiB
TypeScript
430 lines
15 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 { DeliveryTablePage, DeliveryStatGrid } from "@/modules/delivery/design-system";
|
||
import {
|
||
DeliveryPageLoader,
|
||
DeliveryPageError,
|
||
exportToCsv,
|
||
} from "@/modules/delivery/pages/shared";
|
||
import { useTenantId } from "@/hooks/useTenantId";
|
||
import { useDeliveryLookups } from "@/modules/delivery/hooks/useDeliveryCapabilities";
|
||
import { useDeliveryPermissions } from "@/modules/delivery/hooks/useDeliveryPermissions";
|
||
import { DeliveryStatusChip } from "@/modules/delivery/design-system";
|
||
import { PermissionDeniedState } from "@/src/shared/ui";
|
||
|
||
export type DeliveryFieldConfig = {
|
||
name: string;
|
||
label: string;
|
||
type?: "text" | "number" | "email" | "tel" | "textarea" | "select" | "organization" | "hub";
|
||
options?: { value: string; label: string }[];
|
||
required?: boolean;
|
||
createOnly?: boolean;
|
||
editOnly?: boolean;
|
||
placeholder?: string;
|
||
};
|
||
|
||
export type DeliveryColumnConfig = {
|
||
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>[] | { items: Record<string, unknown>[]; total: number }>;
|
||
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 DeliveryListConfig = {
|
||
title: string;
|
||
description?: string;
|
||
breadcrumb?: string;
|
||
resourceKey: string;
|
||
permission?: string;
|
||
api: ApiResource;
|
||
columns: DeliveryColumnConfig[];
|
||
createFields?: DeliveryFieldConfig[];
|
||
editFields?: DeliveryFieldConfig[];
|
||
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: DeliveryFieldConfig[]) {
|
||
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 normalizeList(
|
||
data: Record<string, unknown>[] | { items: Record<string, unknown>[]; total: number }
|
||
): Record<string, unknown>[] {
|
||
if (Array.isArray(data)) return data;
|
||
return data.items ?? [];
|
||
}
|
||
|
||
function renderCell(col: DeliveryColumnConfig, row: Record<string, unknown>) {
|
||
if (col.render) return col.render(row);
|
||
const val = row[col.key];
|
||
if (col.type === "status" && val) return <DeliveryStatusChip 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(--delivery-accent)] hover:underline">
|
||
{val != null ? String(val) : "—"}
|
||
</Link>
|
||
);
|
||
}
|
||
return val != null ? String(val) : "—";
|
||
}
|
||
|
||
function renderField(
|
||
field: DeliveryFieldConfig,
|
||
register: ReturnType<typeof useForm>["register"],
|
||
mode: "create" | "edit",
|
||
lookups: { organizations: Record<string, unknown>[]; hubs: 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 === "organization") {
|
||
return (
|
||
<FormField key={field.name} label={field.label}>
|
||
<Select {...register(field.name)}>
|
||
<option value="">انتخاب سازمان…</option>
|
||
{lookups.organizations.map((o) => (
|
||
<option key={String(o.id)} value={String(o.id)}>
|
||
{String(o.name)} ({String(o.code)})
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</FormField>
|
||
);
|
||
}
|
||
if (field.type === "hub") {
|
||
return (
|
||
<FormField key={field.name} label={field.label}>
|
||
<Select {...register(field.name)}>
|
||
<option value="">انتخاب هاب…</option>
|
||
{lookups.hubs.map((h) => (
|
||
<option key={String(h.id)} value={String(h.id)}>
|
||
{String(h.name)} ({String(h.code)})
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</FormField>
|
||
);
|
||
}
|
||
return (
|
||
<FormField key={field.name} label={field.label}>
|
||
<Input {...register(field.name)} placeholder={field.placeholder} />
|
||
</FormField>
|
||
);
|
||
}
|
||
|
||
export function createDeliveryListPage(config: DeliveryListConfig) {
|
||
return function DeliveryListPage() {
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const perms = useDeliveryPermissions();
|
||
const lookupsQ = useDeliveryLookups(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: ["delivery", 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: ["delivery", 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 <DeliveryPageLoader />;
|
||
if (listQ.error) return <DeliveryPageError error={listQ.error} onRetry={() => listQ.refetch()} />;
|
||
if (config.permission && !perms.can(config.permission)) {
|
||
return <PermissionDeniedState />;
|
||
}
|
||
|
||
const rows = listQ.data ?? [];
|
||
const lookups = lookupsQ.data ?? { organizations: [], hubs: [] };
|
||
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 (
|
||
<>
|
||
<DeliveryTablePage
|
||
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>
|
||
}
|
||
/>
|
||
|
||
<DeliveryStatGrid
|
||
stats={[
|
||
{ label: "کل", value: String(rows.length) },
|
||
{
|
||
label: "فعال",
|
||
value: String(rows.filter((r) => r.status === "active").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)}
|
||
/>
|
||
|
||
{/* Row action buttons rendered via table extension - inline actions in footer */}
|
||
{rows.length > 0 && (canEdit || canDelete || config.rowActions) ? (
|
||
<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, 5).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}
|
||
{config.rowActions?.map((a) => (
|
||
<Button
|
||
key={a.label}
|
||
variant={a.variant ?? "outline"}
|
||
size="sm"
|
||
onClick={() => a.onClick(tenantId, row)}
|
||
>
|
||
{a.label}
|
||
</Button>
|
||
))}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
) : null}
|
||
</>
|
||
);
|
||
};
|
||
}
|