Introduces modules/hospitality with 54 routes, BFF proxy, capability-gated nav, CRUD factory, and architecture validation docs. Co-authored-by: Cursor <cursoragent@cursor.com>
313 lines
11 KiB
TypeScript
313 lines
11 KiB
TypeScript
"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
|
||
/>
|
||
</>
|
||
);
|
||
};
|
||
}
|