Ship the full Communication portal (real SMS API, feature-locked future channels), BFF proxy, docs, snapshot, and phase handover. Co-authored-by: Cursor <cursoragent@cursor.com>
153 lines
4.6 KiB
TypeScript
153 lines
4.6 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 { CommunicationBreadcrumbs } from "@/modules/communication/components/CommunicationBreadcrumbs";
|
||
|
||
export type CommunicationTableColumn = {
|
||
key: string;
|
||
header: string;
|
||
className?: string;
|
||
render?: (row: Record<string, unknown>) => React.ReactNode;
|
||
searchable?: boolean;
|
||
sortable?: boolean;
|
||
defaultVisible?: boolean;
|
||
};
|
||
|
||
export function CommunicationTablePage({
|
||
title,
|
||
description,
|
||
breadcrumb,
|
||
columns,
|
||
rows,
|
||
empty,
|
||
actions,
|
||
searchPlaceholder = "جستجو…",
|
||
filterFn,
|
||
toolbar,
|
||
page,
|
||
pageSize,
|
||
onPageChange,
|
||
selectedIds,
|
||
onSelectionChange,
|
||
bulkActions,
|
||
loading,
|
||
}: {
|
||
title: string;
|
||
description?: string;
|
||
breadcrumb?: string;
|
||
columns: CommunicationTableColumn[];
|
||
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 visibleColumns = columns;
|
||
|
||
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 totalPages = pageSize ? Math.max(1, Math.ceil(filtered.length / pageSize)) : 1;
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="space-y-4">
|
||
<Skeleton className="h-8 w-64" />
|
||
<Skeleton className="h-64 w-full" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{breadcrumb ? <CommunicationBreadcrumbs current={breadcrumb} /> : null}
|
||
<PageHeader title={title} description={description} actions={actions} />
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||
<div className="relative max-w-sm flex-1">
|
||
<Search className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--muted)]" />
|
||
<Input
|
||
className="pr-9"
|
||
placeholder={searchPlaceholder}
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
/>
|
||
</div>
|
||
{toolbar}
|
||
</div>
|
||
{selectedIds && selectedIds.size > 0 && bulkActions ? (
|
||
<div className="flex items-center gap-2 rounded-xl border border-[var(--border)] bg-[var(--communication-accent-soft)] px-4 py-2 text-sm">
|
||
{selectedIds.size} مورد انتخاب شده
|
||
{bulkActions}
|
||
</div>
|
||
) : null}
|
||
{paged.length === 0 ? (
|
||
empty ?? <EmptyState />
|
||
) : (
|
||
<>
|
||
<DataTable
|
||
columns={visibleColumns.map((c) => ({
|
||
key: c.key,
|
||
header: c.header,
|
||
className: c.className,
|
||
render: c.render
|
||
? (row: Record<string, unknown>) => c.render!(row)
|
||
: undefined,
|
||
}))}
|
||
rows={paged}
|
||
/>
|
||
{pageSize && page && onPageChange ? (
|
||
<Pagination
|
||
page={page}
|
||
pageSize={pageSize}
|
||
total={filtered.length}
|
||
onPageChange={onPageChange}
|
||
/>
|
||
) : null}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|