TorbatYar/frontend/modules/crm/design-system/CrmTablePage.tsx
Mortezakoohjani e140908034 Ship enterprise CRM frontend with real API wiring and thin app routes.
Connect all CRM pages to the BFF and CRM service, add module docs, and mark CRM available in the SuperApp catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 20:58:17 +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 CrmTableColumn = {
key: string;
header: string;
className?: string;
render?: (row: Record<string, unknown>) => React.ReactNode;
searchable?: boolean;
sortable?: boolean;
};
export function CrmTablePage({
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: CrmTableColumn[];
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(--crm-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 CrmStatGrid({
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>
);
}