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>
255 lines
8.4 KiB
TypeScript
255 lines
8.4 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo, useState } from "react";
|
||
import { PageHeader, DataTable, EmptyState, Input, Pagination, Skeleton } from "@/components/ds";
|
||
import { Search } from "lucide-react";
|
||
import { HospitalityBreadcrumbs } from "@/modules/hospitality/components/HospitalityBreadcrumbs";
|
||
|
||
export type HospitalityTableColumn = {
|
||
key: string;
|
||
header: string;
|
||
className?: string;
|
||
render?: (row: Record<string, unknown>) => React.ReactNode;
|
||
searchable?: boolean;
|
||
sortable?: boolean;
|
||
defaultVisible?: boolean;
|
||
};
|
||
|
||
export function HospitalityTablePage({
|
||
title,
|
||
description,
|
||
breadcrumb,
|
||
columns,
|
||
rows,
|
||
empty,
|
||
actions,
|
||
searchPlaceholder = "جستجو…",
|
||
filterFn,
|
||
toolbar,
|
||
page,
|
||
pageSize,
|
||
onPageChange,
|
||
selectedIds,
|
||
onSelectionChange,
|
||
bulkActions,
|
||
loading,
|
||
}: {
|
||
title: string;
|
||
description?: string;
|
||
breadcrumb?: string;
|
||
columns: HospitalityTableColumn[];
|
||
rows: Record<string, unknown>[];
|
||
empty?: React.ReactNode;
|
||
actions?: React.ReactNode;
|
||
searchPlaceholder?: string;
|
||
filterFn?: (row: Record<string, unknown>, query: string) => boolean;
|
||
toolbar?: React.ReactNode;
|
||
page?: number;
|
||
pageSize?: number;
|
||
onPageChange?: (page: number) => void;
|
||
selectedIds?: Set<string>;
|
||
onSelectionChange?: (ids: Set<string>) => void;
|
||
bulkActions?: React.ReactNode;
|
||
loading?: boolean;
|
||
}) {
|
||
const [query, setQuery] = useState("");
|
||
const [sortKey, setSortKey] = useState<string | null>(null);
|
||
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
||
const [hiddenCols, setHiddenCols] = useState<Set<string>>(() => {
|
||
const hidden = new Set<string>();
|
||
columns.forEach((c) => {
|
||
if (c.defaultVisible === false) hidden.add(c.key);
|
||
});
|
||
return hidden;
|
||
});
|
||
|
||
const visibleColumns = useMemo(
|
||
() => columns.filter((c) => !hiddenCols.has(c.key)),
|
||
[columns, hiddenCols]
|
||
);
|
||
|
||
const filtered = useMemo(() => {
|
||
let data = rows;
|
||
const q = query.trim().toLowerCase();
|
||
if (q) {
|
||
data = filterFn
|
||
? data.filter((r) => filterFn(r, q))
|
||
: data.filter((r) =>
|
||
visibleColumns.some((c) => {
|
||
if (c.searchable === false) return false;
|
||
const val = r[c.key];
|
||
return val != null && String(val).toLowerCase().includes(q);
|
||
})
|
||
);
|
||
}
|
||
if (sortKey) {
|
||
data = [...data].sort((a, b) => {
|
||
const av = a[sortKey];
|
||
const bv = b[sortKey];
|
||
const cmp = String(av ?? "").localeCompare(String(bv ?? ""), "fa");
|
||
return sortDir === "asc" ? cmp : -cmp;
|
||
});
|
||
}
|
||
return data;
|
||
}, [rows, query, visibleColumns, filterFn, sortKey, sortDir]);
|
||
|
||
const paged = useMemo(() => {
|
||
if (!pageSize || !page) return filtered;
|
||
const start = (page - 1) * pageSize;
|
||
return filtered.slice(start, start + pageSize);
|
||
}, [filtered, page, pageSize]);
|
||
|
||
const displayRows = pageSize && page ? paged : filtered;
|
||
|
||
const toggleSort = (key: string) => {
|
||
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||
else {
|
||
setSortKey(key);
|
||
setSortDir("asc");
|
||
}
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="space-y-4">
|
||
<Skeleton className="h-10 w-64" />
|
||
<Skeleton className="h-10 w-full max-w-md" />
|
||
<Skeleton className="h-64 w-full" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
{breadcrumb ? <HospitalityBreadcrumbs current={breadcrumb} /> : null}
|
||
<PageHeader title={title} description={description} actions={actions} />
|
||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||
<div className="relative min-w-[200px] max-w-md flex-1">
|
||
<Search className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--muted)]" />
|
||
<Input
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
placeholder={searchPlaceholder}
|
||
className="pr-10"
|
||
aria-label="جستجو"
|
||
data-hospitality-search
|
||
/>
|
||
</div>
|
||
{toolbar}
|
||
<select
|
||
className="rounded-lg border border-[var(--border)] bg-[var(--surface)] px-3 py-2 text-sm"
|
||
value={sortKey ?? ""}
|
||
onChange={(e) => {
|
||
const key = e.target.value;
|
||
if (!key) setSortKey(null);
|
||
else toggleSort(key);
|
||
}}
|
||
aria-label="مرتبسازی"
|
||
>
|
||
<option value="">مرتبسازی</option>
|
||
{visibleColumns
|
||
.filter((c) => c.sortable !== false && c.key !== "actions")
|
||
.map((c) => (
|
||
<option key={c.key} value={c.key}>
|
||
{c.header}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<details className="relative">
|
||
<summary className="cursor-pointer list-none rounded-lg border border-[var(--border)] bg-[var(--surface)] px-3 py-2 text-sm">
|
||
ستونها
|
||
</summary>
|
||
<div className="absolute left-0 z-20 mt-1 min-w-[180px] rounded-xl border border-[var(--border)] bg-[var(--surface)] p-2 shadow-lg">
|
||
{columns
|
||
.filter((c) => c.key !== "actions")
|
||
.map((c) => (
|
||
<label key={c.key} className="flex cursor-pointer items-center gap-2 px-2 py-1 text-sm">
|
||
<input
|
||
type="checkbox"
|
||
checked={!hiddenCols.has(c.key)}
|
||
onChange={(e) => {
|
||
setHiddenCols((prev) => {
|
||
const next = new Set(prev);
|
||
if (e.target.checked) next.delete(c.key);
|
||
else next.add(c.key);
|
||
return next;
|
||
});
|
||
}}
|
||
/>
|
||
{c.header}
|
||
</label>
|
||
))}
|
||
</div>
|
||
</details>
|
||
</div>
|
||
{selectedIds && selectedIds.size > 0 && bulkActions ? (
|
||
<div className="mb-3 flex items-center gap-2 rounded-xl border border-[var(--border)] bg-[var(--hospitality-accent-soft)] px-4 py-2 text-sm">
|
||
<span>{selectedIds.size} مورد انتخاب شده</span>
|
||
{bulkActions}
|
||
</div>
|
||
) : null}
|
||
<DataTable
|
||
columns={
|
||
onSelectionChange
|
||
? [
|
||
{
|
||
key: "_select",
|
||
header: "",
|
||
render: (row) => (
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedIds?.has(String(row.id)) ?? false}
|
||
onChange={(e) => {
|
||
const id = String(row.id);
|
||
const next = new Set(selectedIds);
|
||
if (e.target.checked) next.add(id);
|
||
else next.delete(id);
|
||
onSelectionChange(next);
|
||
}}
|
||
aria-label="انتخاب"
|
||
onClick={(e) => e.stopPropagation()}
|
||
/>
|
||
),
|
||
},
|
||
...visibleColumns,
|
||
]
|
||
: visibleColumns
|
||
}
|
||
rows={displayRows}
|
||
empty={empty ?? <EmptyState title="موردی یافت نشد" description="رکورد جدید ایجاد کنید." />}
|
||
/>
|
||
{pageSize && page && onPageChange ? (
|
||
<div className="mt-4 flex justify-center">
|
||
<Pagination
|
||
page={page}
|
||
pageSize={pageSize}
|
||
total={filtered.length}
|
||
onPageChange={onPageChange}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function HospitalityStatGrid({
|
||
stats,
|
||
}: {
|
||
stats: { label: string; value: string | number; hint?: string }[];
|
||
}) {
|
||
return (
|
||
<div className="mb-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||
{stats.map((s) => (
|
||
<div
|
||
key={s.label}
|
||
className="rounded-2xl border border-[var(--border)] bg-[var(--surface)] p-5 shadow-[var(--shadow-sm)] transition-shadow hover:shadow-[var(--shadow-md)]"
|
||
>
|
||
<p className="text-sm text-[var(--muted)]">{s.label}</p>
|
||
<p className="mt-1 text-2xl font-semibold text-secondary">{s.value}</p>
|
||
{s.hint ? <p className="mt-1 text-xs text-[var(--muted)]">{s.hint}</p> : null}
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|