TorbatYar/frontend/modules/delivery/components/DeliveryListCrudPage.tsx
Mortezakoohjani 7978970783 feat(delivery): add Torbat Driver frontend module with real API wiring.
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>
2026-07-27 12:02:01 +03:30

430 lines
15 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 { 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}
</>
);
};
}