TorbatYar/frontend/modules/hospitality/components/HospitalityListCrudPage.tsx
Mortezakoohjani 31a0a34945 feat(loyalty): add complete Loyalty Platform Frontend module
Scaffold frontend/modules/loyalty with types, API client, design system, feature pages, and thin App Router routes. Wire all screens to backend Loyalty service via BFF proxy. Add loyalty docs and update progress.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 10:46:45 +03:30

586 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, useEffect, 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,
Printer,
Copy,
Eye,
} from "lucide-react";
import Link from "next/link";
import { z } from "zod";
import {
Button,
Dialog,
Drawer,
Input,
EmptyState,
FormField,
ConfirmDialog,
Select,
Textarea,
Badge,
} from "@/components/ds";
import {
HospitalityTablePage,
HospitalityStatGrid,
} 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 { useHospitalityVenues } from "@/modules/hospitality/hooks/useHospitalityVenues";
import { HospitalityStatusChip } from "@/modules/hospitality/design-system";
import { HospitalityBundleGate } from "@/modules/hospitality/components/HospitalityBundleGate";
import { countByStatus } from "@/modules/hospitality/constants/fieldHelpers";
export type HospitalityFieldConfig = {
name: string;
label: string;
type?: "text" | "number" | "email" | "tel" | "textarea" | "select" | "venue" | "datetime-local";
options?: { value: string; label: string }[];
required?: boolean;
createOnly?: boolean;
editOnly?: boolean;
placeholder?: string;
};
export type HospitalityColumnConfig = {
key: string;
header: string;
type?: "status" | "datetime" | "text" | "money";
render?: (row: Record<string, unknown>) => React.ReactNode;
searchable?: boolean;
sortable?: boolean;
defaultVisible?: boolean;
};
export type HospitalityRowAction = {
label: string;
onClick: (tenantId: string, row: Record<string, unknown>) => Promise<void>;
variant?: "default" | "outline" | "danger";
};
type ApiResource = {
list: (tenantId: string, params?: { page?: number; page_size?: number }) => 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>;
upsert?: (tenantId: string, body: Record<string, unknown>) => 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;
listFilter?: (row: Record<string, unknown>) => boolean;
rowActions?: HospitalityRowAction[];
refreshIntervalMs?: number;
statsKeys?: { total?: string; active?: string };
quickActions?: { label: string; href: string }[];
exportColumns?: string[];
readOnly?: boolean;
useUpsert?: boolean;
};
function buildSchema(fields: HospitalityFieldConfig[]) {
const shape: Record<string, z.ZodTypeAny> = {};
for (const f of fields) {
if (f.type === "number") {
shape[f.name] = f.required ? z.coerce.number() : z.coerce.number().optional();
} else {
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);
}
}
if (col.type === "money" && val != null) {
return `${Number(val).toLocaleString("fa-IR")} ${row.currency_code ?? "IRR"}`;
}
return val != null ? String(val) : "—";
}
function renderField(
field: HospitalityFieldConfig,
register: ReturnType<typeof useForm>["register"],
mode: "create" | "edit",
venues: 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 === "venue") {
return (
<FormField key={field.name} label={field.label}>
<Select {...register(field.name)}>
<option value="">انتخاب مکان</option>
{venues.map((v) => (
<option key={String(v.id)} value={String(v.id)}>
{String(v.name)} ({String(v.code)})
</option>
))}
</Select>
</FormField>
);
}
return (
<FormField key={field.name} label={field.label}>
<Input
type={field.type === "datetime-local" ? "datetime-local" : field.type ?? "text"}
{...register(field.name)}
placeholder={field.placeholder}
/>
</FormField>
);
}
function filterStorageKey(resourceKey: string) {
return `hospitality-saved-filter-${resourceKey}`;
}
export function createHospitalityListPage(config: HospitalityListConfig) {
return function HospitalityListPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const caps = useHospitalityCapabilities();
const venuesQ = useHospitalityVenues();
const [createOpen, setCreateOpen] = useState(false);
const [editRow, setEditRow] = useState<Record<string, unknown> | null>(null);
const [detailRow, setDetailRow] = useState<Record<string, unknown> | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [statusFilter, setStatusFilter] = useState("");
const [showDeleted, setShowDeleted] = useState(false);
const pageSize = 20;
const createFields = config.createFields ?? [];
const editFields = config.editFields ?? createFields;
const createSchema = buildSchema(createFields);
const editSchema = buildSchema(editFields);
useEffect(() => {
if (typeof window === "undefined") return;
const saved = localStorage.getItem(filterStorageKey(config.resourceKey));
if (saved) setStatusFilter(saved);
}, [config.resourceKey]);
const saveFilter = useCallback(
(value: string) => {
setStatusFilter(value);
if (typeof window !== "undefined") {
localStorage.setItem(filterStorageKey(config.resourceKey), value);
}
},
[config.resourceKey]
);
const listQ = useQuery({
queryKey: ["hospitality", tenantId, config.resourceKey, page],
queryFn: () => config.api.list(tenantId!, { page, page_size: 500 }),
enabled: !!tenantId,
refetchInterval: config.refreshIntervalMs,
});
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) => {
const body = { ...d };
if (config.useUpsert && config.api.upsert) return config.api.upsert(tenantId!, body);
return config.api.create!(tenantId!, body);
},
onSuccess: async () => {
toast.success(config.useUpsert ? "تنظیمات ذخیره شد" : "رکورد ایجاد شد");
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),
});
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "n" && config.api.create && !config.readOnly) {
e.preventDefault();
setCreateOpen(true);
}
if ((e.ctrlKey || e.metaKey) && e.key === "r") {
e.preventDefault();
listQ.refetch();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [config.api.create, config.readOnly, listQ]);
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.listFilter) rows = rows.filter(config.listFilter);
if (config.filterDeleted && !showDeleted) rows = rows.filter((r) => !r.is_deleted);
if (statusFilter) rows = rows.filter((r) => String(r.status ?? "") === statusFilter);
const stats = countByStatus(rows);
const venues = venuesQ.data ?? [];
const exportCols =
config.exportColumns ?? config.columns.map((c) => c.key).filter((k) => k !== "actions");
const duplicateRow = (row: Record<string, unknown>) => {
createForm.reset(
Object.fromEntries(
createFields.map((f) => [f.name, row[f.name] != null ? String(row[f.name]) : ""])
)
);
setCreateOpen(true);
};
const tableColumns = [
...config.columns.map((c) => ({
key: c.key,
header: c.header,
searchable: c.searchable,
sortable: c.sortable,
defaultVisible: c.defaultVisible,
render: (row: Record<string, unknown>) => renderCell(c, row),
})),
{
key: "actions",
header: "عملیات",
searchable: false as const,
sortable: false as const,
render: (row: Record<string, unknown>) => (
<div className="flex flex-wrap gap-1">
<Button variant="ghost" size="icon" aria-label="جزئیات" onClick={() => setDetailRow(row)}>
<Eye className="h-4 w-4" />
</Button>
{config.api.create && !config.readOnly ? (
<Button variant="ghost" size="icon" aria-label="کپی" onClick={() => duplicateRow(row)}>
<Copy className="h-4 w-4" />
</Button>
) : null}
{config.api.update && !config.readOnly ? (
<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 && !row.is_deleted && !config.readOnly ? (
<Button variant="ghost" size="icon" aria-label="حذف" onClick={() => setDeleteId(String(row.id))}>
<Trash2 className="h-4 w-4" />
</Button>
) : null}
{row.is_deleted ? (
<Badge tone="warning">حذفشده</Badge>
) : null}
{config.rowActions?.map((action) => (
<Button
key={action.label}
variant={action.variant ?? "outline"}
size="sm"
onClick={async () => {
try {
await action.onClick(tenantId!, row);
toast.success(action.label);
await invalidate();
} catch (e) {
toast.error(e instanceof Error ? e.message : "خطا");
}
}}
>
{action.label}
</Button>
))}
</div>
),
},
];
const body = (
<>
<HospitalityStatGrid
stats={[
{ label: "کل رکوردها", value: stats.total, hint: config.title },
{ label: "فعال", value: stats.active },
{ label: "پیش‌نویس", value: stats.draft },
{
label: "صفحه",
value: page,
hint: `${Math.ceil(rows.length / pageSize) || 1} صفحه`,
},
]}
/>
{config.quickActions?.length ? (
<div className="mb-4 flex flex-wrap gap-2">
{config.quickActions.map((a) => (
<Link key={a.href} href={a.href}>
<Button variant="outline" size="sm">
{a.label}
</Button>
</Link>
))}
</div>
) : null}
<HospitalityTablePage
title={config.title}
description={config.description}
breadcrumb={config.breadcrumb ?? config.title}
columns={tableColumns}
rows={rows}
page={page}
pageSize={pageSize}
onPageChange={setPage}
selectedIds={config.api.remove && !config.readOnly ? selected : undefined}
onSelectionChange={config.api.remove && !config.readOnly ? setSelected : undefined}
bulkActions={
config.api.remove && selected.size > 0 ? (
<Button
variant="outline"
size="sm"
onClick={async () => {
for (const id of selected) {
await config.api.remove!(tenantId!, id);
}
toast.success("حذف گروهی انجام شد");
setSelected(new Set());
await invalidate();
}}
>
حذف انتخابشدهها
</Button>
) : undefined
}
actions={
<div className="flex flex-wrap gap-2">
{!config.readOnly && (config.api.create || config.api.upsert) && createFields.length > 0 ? (
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
{config.useUpsert ? "افزودن/ویرایش" : "جدید"}
</Button>
) : null}
</div>
}
toolbar={
<div className="flex flex-wrap items-center gap-2">
<select
className="rounded-lg border border-[var(--border)] bg-[var(--surface)] px-3 py-2 text-sm"
value={statusFilter}
onChange={(e) => saveFilter(e.target.value)}
aria-label="فیلتر وضعیت"
>
<option value="">همه وضعیتها</option>
<option value="draft">پیشنویس</option>
<option value="active">فعال</option>
<option value="inactive">غیرفعال</option>
<option value="published">منتشر شده</option>
<option value="open">باز</option>
<option value="completed">تکمیل</option>
</select>
{config.filterDeleted ? (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={showDeleted}
onChange={(e) => setShowDeleted(e.target.checked)}
/>
نمایش حذفشدهها
</label>
) : null}
<Button variant="outline" size="sm" onClick={() => listQ.refetch()} aria-label="بروزرسانی">
<RefreshCw className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => exportToCsv(config.title, rows, exportCols)}
>
<Download className="h-4 w-4" />
CSV
</Button>
<Button variant="outline" size="sm" onClick={() => window.print()}>
<Printer className="h-4 w-4" />
</Button>
</div>
}
empty={
<EmptyState
title="موردی یافت نشد"
action={
config.api.create && !config.readOnly ? (
<Button onClick={() => setCreateOpen(true)}>ایجاد اولین رکورد</Button>
) : undefined
}
/>
}
/>
</>
);
const pageContent = config.bundleFeature ? (
<HospitalityBundleGate feature={config.bundleFeature} capabilities={caps.data}>
{body}
</HospitalityBundleGate>
) : (
body
);
return (
<>
{pageContent}
{!config.readOnly && (config.api.create || config.api.upsert) && createFields.length > 0 ? (
<Dialog
open={createOpen}
onClose={() => setCreateOpen(false)}
title={`${config.title}${config.useUpsert ? "ذخیره" : "جدید"}`}
>
<form className="space-y-3" onSubmit={createForm.handleSubmit((d) => createM.mutate(d))}>
{createFields.map((f) => renderField(f, createForm.register, "create", venues))}
<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 && !config.readOnly ? (
<Drawer open={!!editRow} onClose={() => setEditRow(null)} title={`${config.title} — ویرایش`}>
<form className="space-y-3" onSubmit={editForm.handleSubmit((d) => updateM.mutate(d))}>
{editFields.map((f) => renderField(f, editForm.register, "edit", venues))}
<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>
</Drawer>
) : null}
{detailRow ? (
<Drawer open={!!detailRow} onClose={() => setDetailRow(null)} title="جزئیات رکورد">
<div className="space-y-3 text-sm">
{config.columns.map((c) => (
<div key={c.key} className="flex justify-between gap-4 border-b border-[var(--border)] pb-2">
<span className="text-[var(--muted)]">{c.header}</span>
<span className="font-medium">{renderCell(c, detailRow)}</span>
</div>
))}
<div className="mt-4 rounded-xl bg-[var(--surface-muted)] p-3 text-xs text-[var(--muted)]">
<p>ایجاد: {detailRow.created_at ? new Date(String(detailRow.created_at)).toLocaleString("fa-IR") : "—"}</p>
<p>بهروزرسانی: {detailRow.updated_at ? new Date(String(detailRow.updated_at)).toLocaleString("fa-IR") : "—"}</p>
<p>ایجادکننده: {String(detailRow.created_by ?? "—")}</p>
</div>
</div>
</Drawer>
) : null}
<ConfirmDialog
open={!!deleteId}
onClose={() => setDeleteId(null)}
onConfirm={() => deleteId && deleteM.mutate(deleteId)}
title="حذف رکورد"
description="آیا از حذف این رکورد مطمئن هستید؟"
confirmLabel="حذف"
danger
/>
</>
);
};
}