TorbatYar/frontend/modules/hospitality/design-system/HospitalityTablePage.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

255 lines
8.4 KiB
TypeScript
Raw 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 { 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>
);
}