TorbatYar/frontend/modules/loyalty/design-system/LoyaltyTablePage.tsx
Mortezakoohjani d579d0b142 feat(loyalty): add Loyalty Platform Frontend module
Add frontend/modules/loyalty with types, API client, design system, feature pages and thin App Router routes under app/loyalty/. BFF proxy at app/api/loyalty/. Include loyalty frontend docs.

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

217 lines
6.7 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";
export type LoyaltyTableColumn = {
key: string;
header: string;
className?: string;
render?: (row: Record<string, unknown>) => React.ReactNode;
searchable?: boolean;
sortable?: boolean;
};
export function LoyaltyTablePage({
title,
description,
breadcrumbs,
columns,
rows,
empty,
actions,
toolbar,
searchPlaceholder = "جستجو…",
filterFn,
page,
pageSize,
onPageChange,
selectedIds,
onSelectionChange,
bulkActions,
loading,
}: {
title: string;
description?: string;
breadcrumbs?: React.ReactNode;
columns: LoyaltyTableColumn[];
rows: Record<string, unknown>[];
empty?: React.ReactNode;
actions?: React.ReactNode;
toolbar?: React.ReactNode;
searchPlaceholder?: string;
filterFn?: (row: Record<string, unknown>, query: string) => boolean;
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 filtered = useMemo(() => {
let data = rows;
const q = query.trim().toLowerCase();
if (q) {
data = filterFn
? data.filter((r) => filterFn(r, q))
: data.filter((r) =>
columns.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, columns, 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");
}
};
const colsWithSort = columns;
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>
{breadcrumbs}
<PageHeader title={title} description={description} actions={actions} />
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="relative min-w-[200px] flex-1 max-w-md">
<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="جستجو"
/>
</div>
{toolbar}
{columns.some((c) => c.sortable !== false) ? (
<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>
{columns
.filter((c) => c.sortable !== false && c.key !== "actions")
.map((c) => (
<option key={c.key} value={c.key}>
{c.header}
</option>
))}
</select>
) : null}
</div>
{selectedIds && selectedIds.size > 0 && bulkActions ? (
<div className="mb-3 flex items-center gap-2 rounded-xl border border-[var(--border)] bg-[var(--loyalty-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="انتخاب"
/>
),
},
...colsWithSort,
]
: colsWithSort
}
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 LoyaltyStatGrid({
stats,
}: {
stats: { label: string; value: string | number; hint?: string }[];
}) {
return (
<div className="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>
);
}