593 lines
22 KiB
TypeScript
593 lines
22 KiB
TypeScript
"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;
|
||
emptyHint?: string;
|
||
};
|
||
|
||
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="موردی یافت نشد"
|
||
description={
|
||
config.emptyHint ||
|
||
(config.readOnly
|
||
? "دادهای از API برنگشته است. فیلتر را پاک کنید یا بعداً دوباره تلاش کنید."
|
||
: "هنوز رکوردی نیست — اولین مورد را از دکمه ایجاد بسازید.")
|
||
}
|
||
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
|
||
/>
|
||
</>
|
||
);
|
||
};
|
||
}
|