TorbatYar/frontend/modules/hospitality/components/HospitalityListCrudPage.tsx
Mortezakoohjani 065c053c16 Add complete Torbat Food hospitality frontend connected to backend APIs.
Introduces modules/hospitality with 54 routes, BFF proxy, capability-gated nav, CRUD factory, and architecture validation docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 20:54:26 +03:30

313 lines
11 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 { 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 } from "lucide-react";
import { z } from "zod";
import {
Button,
Dialog,
Input,
EmptyState,
FormField,
ConfirmDialog,
Select,
} from "@/components/ds";
import { HospitalityTablePage } from "@/modules/hospitality/design-system";
import { HospitalityPageLoader, HospitalityPageError, exportToCsv } from "@/modules/hospitality/pages/shared";
import { useTenantId } from "@/hooks/useTenantId";
import { useHospitalityCapabilities } from "@/modules/hospitality/hooks/useHospitalityCapabilities";
import { HospitalityStatusChip } from "@/modules/hospitality/design-system";
import { HospitalityBundleGate } from "@/modules/hospitality/components/HospitalityBundleGate";
export type HospitalityFieldConfig = {
name: string;
label: string;
type?: "text" | "number" | "select";
options?: { value: string; label: string }[];
required?: boolean;
};
export type HospitalityColumnConfig = {
key: string;
header: string;
type?: "status" | "datetime" | "text";
render?: (row: Record<string, unknown>) => React.ReactNode;
};
type ApiResource = {
list: (tenantId: string, params?: { page?: number; page_size?: number }) => 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 HospitalityListConfig = {
title: string;
description?: string;
breadcrumb?: string;
resourceKey: string;
bundleFeature?: string | null;
permission?: string;
api: ApiResource;
columns: HospitalityColumnConfig[];
createFields?: HospitalityFieldConfig[];
editFields?: HospitalityFieldConfig[];
filterDeleted?: boolean;
};
function buildSchema(fields: HospitalityFieldConfig[]) {
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: HospitalityColumnConfig, row: Record<string, unknown>) {
if (col.render) return col.render(row);
const val = row[col.key];
if (col.type === "status" && val) return <HospitalityStatusChip status={String(val)} />;
if (col.type === "datetime" && val) {
try {
return new Date(String(val)).toLocaleString("fa-IR");
} catch {
return String(val);
}
}
return val != null ? String(val) : "—";
}
export function createHospitalityListPage(config: HospitalityListConfig) {
return function HospitalityListPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const caps = useHospitalityCapabilities();
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;
const createSchema = buildSchema(createFields);
const editSchema = buildSchema(editFields);
const listQ = useQuery({
queryKey: ["hospitality", tenantId, config.resourceKey, page],
queryFn: () => 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 = () =>
qc.invalidateQueries({ queryKey: ["hospitality", tenantId, config.resourceKey] });
const createM = useMutation({
mutationFn: (d: FieldValues) => config.api.create!(tenantId!, d),
onSuccess: async () => {
toast.success("رکورد ایجاد شد");
setCreateOpen(false);
createForm.reset();
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const updateM = useMutation({
mutationFn: (d: FieldValues) => config.api.update!(tenantId!, String(editRow!.id), d),
onSuccess: async () => {
toast.success("به‌روزرسانی شد");
setEditRow(null);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const deleteM = useMutation({
mutationFn: (id: string) => config.api.remove!(tenantId!, id),
onSuccess: async () => {
toast.success("حذف شد");
setDeleteId(null);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || listQ.isLoading || caps.isLoading) return <HospitalityPageLoader />;
if (listQ.error) return <HospitalityPageError error={listQ.error} onRetry={() => listQ.refetch()} />;
let rows = listQ.data ?? [];
if (config.filterDeleted) rows = rows.filter((r) => !r.is_deleted);
const tableColumns = [
...config.columns.map((c) => ({
key: c.key,
header: c.header,
render: (row: Record<string, unknown>) => renderCell(c, row),
})),
{
key: "actions",
header: "عملیات",
searchable: false as const,
render: (row: Record<string, unknown>) => (
<div className="flex gap-1">
{config.api.update ? (
<Button
variant="ghost"
size="icon"
aria-label="ویرایش"
onClick={() => {
setEditRow(row);
editForm.reset(Object.fromEntries(editFields.map((f) => [f.name, row[f.name] ?? ""])));
}}
>
<Pencil className="h-4 w-4" />
</Button>
) : null}
{config.api.remove ? (
<Button variant="ghost" size="icon" aria-label="حذف" onClick={() => setDeleteId(String(row.id))}>
<Trash2 className="h-4 w-4" />
</Button>
) : null}
</div>
),
},
];
const body = (
<HospitalityTablePage
title={config.title}
description={config.description}
breadcrumb={config.breadcrumb ?? config.title}
columns={tableColumns}
rows={rows}
actions={
config.api.create ? (
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
جدید
</Button>
) : undefined
}
toolbar={
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => exportToCsv(config.title, rows, config.columns.map((c) => c.key))}
>
<Download className="h-4 w-4" />
خروجی
</Button>
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
قبلی
</Button>
<span className="text-xs text-[var(--muted)]">صفحه {page}</span>
<Button
variant="outline"
size="sm"
disabled={rows.length < pageSize}
onClick={() => setPage((p) => p + 1)}
>
بعدی
</Button>
</div>
}
empty={
<EmptyState
title="موردی یافت نشد"
action={
config.api.create ? (
<Button onClick={() => setCreateOpen(true)}>ایجاد اولین رکورد</Button>
) : undefined
}
/>
}
/>
);
const pageContent = config.bundleFeature ? (
<HospitalityBundleGate feature={config.bundleFeature} capabilities={caps.data}>
{body}
</HospitalityBundleGate>
) : (
body
);
return (
<>
{pageContent}
{config.api.create && createFields.length > 0 ? (
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} title={`${config.title} — جدید`}>
<form className="space-y-3" onSubmit={createForm.handleSubmit((d) => createM.mutate(d))}>
{createFields.map((f) => (
<FormField key={f.name} label={f.label}>
{f.type === "select" && f.options ? (
<Select {...createForm.register(f.name)}>
{f.options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</Select>
) : (
<Input type={f.type ?? "text"} {...createForm.register(f.name)} />
)}
</FormField>
))}
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createM.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
) : null}
{editRow && config.api.update ? (
<Dialog open={!!editRow} onClose={() => setEditRow(null)} title={`${config.title} — ویرایش`}>
<form className="space-y-3" onSubmit={editForm.handleSubmit((d) => updateM.mutate(d))}>
{editFields.map((f) => (
<FormField key={f.name} label={f.label}>
<Input {...editForm.register(f.name)} />
</FormField>
))}
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setEditRow(null)}>
انصراف
</Button>
<Button type="submit" disabled={updateM.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
) : null}
<ConfirmDialog
open={!!deleteId}
onClose={() => setDeleteId(null)}
onConfirm={() => deleteId && deleteM.mutate(deleteId)}
title="حذف رکورد"
description="آیا از حذف این رکورد مطمئن هستید؟"
confirmLabel="حذف"
danger
/>
</>
);
};
}