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>
217 lines
6.7 KiB
TypeScript
217 lines
6.7 KiB
TypeScript
"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>
|
||
);
|
||
}
|