Files
saas_frontend/src/components/custom/CustomTable.tsx
T

827 lines
28 KiB
TypeScript
Raw Normal View History

2026-01-19 11:05:40 +05:30
import * as React from "react";
import { createPortal } from "react-dom";
2026-01-19 11:05:40 +05:30
import CustomButton from "./CustomButton";
import CustomInput from "./CustomInput";
import { Upload, ChevronLeft, ChevronRight, Columns3, Check } from "lucide-react";
2026-01-19 11:05:40 +05:30
import { useTranslation } from "react-i18next";
import {
persistTablePageSize,
resolveStoredTablePageSize,
} from "../../lib/tablePageSize";
2026-04-25 08:23:16 +03:00
import Loader from "./CustomLoader";
2026-01-19 11:05:40 +05:30
function cn(...parts: Array<string | false | null | undefined>) {
2026-01-19 11:05:40 +05:30
return parts.filter(Boolean).join(" ");
}
export type Primitive =
| string
| number
| boolean
| null
| undefined
| Date
| Record<string, unknown>;
export type ColumnDef<T> = {
id?: string;
2026-01-19 11:05:40 +05:30
key: keyof T | string;
header: React.ReactNode;
visibilityLabel?: string;
2026-01-19 11:05:40 +05:30
exportHeader?: string;
render?: (row: T) => React.ReactNode;
searchable?: boolean;
cellClassName?: string;
};
export type DataTableProps<T extends Record<string, unknown>> = {
data: T[];
columns: Array<ColumnDef<T>>;
defaultPageSize?: number;
pageSizeOptions?: number[];
exportEnabled?: boolean;
2026-01-19 11:05:40 +05:30
exportFileName?: string;
className?: string;
getRowId?: (row: T, index: number) => string | number;
filterControls?: React.ReactNode;
columnVisibilityEnabled?: boolean;
columnVisibilityStorageKey?: string;
pageSizeStorageKey?: string;
2026-01-19 11:05:40 +05:30
enableSearchDropdown?: boolean;
buildSuggestionLabel?: (row: T) => string;
onSuggestionSelect?: (row: T) => void;
maxSuggestions?: number;
highlightClassName?: string;
manualPagination?: boolean;
manualFiltering?: boolean;
totalRows?: number;
page?: number;
pageSize?: number;
search?: string;
onPageChange?: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
onSearchChange?: (search: string) => void;
searchInputRef?: React.RefObject<HTMLInputElement | null>;
maxHeight?: string;
isLoading?: boolean;
};
function toCSVValue(v: unknown) {
if (v == null) return "";
const text =
v instanceof Date
? v.toISOString()
: typeof v === "object"
? JSON.stringify(v)
: String(v);
const needsWrap = /[",\n]/.test(text);
return needsWrap ? `"${text.replace(/"/g, '""')}"` : text;
}
function downloadCSV(fileName: string, rows: string[]) {
const blob = new Blob([rows.join("\n")], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
}
function getColumnId<T>(column: ColumnDef<T>) {
return column.id ?? String(column.key);
}
function getColumnVisibilityLabel<T>(
column: ColumnDef<T>,
fallbackLabel: string
) {
if (column.visibilityLabel) return column.visibilityLabel;
if (typeof column.header === "string") return column.header;
return fallbackLabel;
}
2026-01-19 11:05:40 +05:30
export function DataTable<T extends Record<string, unknown>>(
props: DataTableProps<T>
) {
const { t } = useTranslation('common');
const {
data,
columns,
defaultPageSize = 10,
pageSizeOptions = [5, 10, 20, 50],
exportEnabled = false,
2026-01-19 11:05:40 +05:30
exportFileName = "export.csv",
className,
getRowId,
filterControls,
columnVisibilityEnabled = false,
columnVisibilityStorageKey,
pageSizeStorageKey,
2026-01-19 11:05:40 +05:30
enableSearchDropdown = false,
buildSuggestionLabel,
onSuggestionSelect,
maxSuggestions = 8,
highlightClassName = "ring-2 ring-indigo-400",
manualPagination = false,
manualFiltering = false,
totalRows,
page: controlledPage,
pageSize: controlledPageSize,
search: controlledSearch,
onPageChange,
onPageSizeChange,
onSearchChange,
searchInputRef,
maxHeight = "70vh", // Default max height
isLoading,
} = props;
const [internalSearch, setInternalSearch] = React.useState("");
const [internalPageSize, setInternalPageSize] = React.useState(() =>
resolveStoredTablePageSize({
storageKey: pageSizeStorageKey,
pageSizeOptions,
defaultPageSize,
})
);
2026-01-19 11:05:40 +05:30
const [internalPage, setInternalPage] = React.useState(1);
const [visibleColumnIds, setVisibleColumnIds] = React.useState<string[] | null>(
null
);
const [isColumnsMenuOpen, setIsColumnsMenuOpen] = React.useState(false);
const [columnsMenuPos, setColumnsMenuPos] = React.useState<{
top: number;
left: number;
} | null>(null);
const [draftVisibleColumnIds, setDraftVisibleColumnIds] = React.useState<string[]>([]);
const columnsMenuRef = React.useRef<HTMLDivElement | null>(null);
const columnsButtonRef = React.useRef<HTMLDivElement | null>(null);
2026-01-19 11:05:40 +05:30
const search = manualFiltering && controlledSearch !== undefined ? controlledSearch : internalSearch;
const pageSize = manualPagination && controlledPageSize !== undefined ? controlledPageSize : internalPageSize;
const page = manualPagination && controlledPage !== undefined ? controlledPage : internalPage;
const normalizedColumns = React.useMemo(
() =>
columns.map((column, index) => ({
...column,
_columnId: getColumnId(column),
_fallbackLabel: `${t("actions.columns", { defaultValue: "Columns" })} ${index + 1}`,
})),
[columns, t]
);
React.useEffect(() => {
if (!columnVisibilityEnabled) {
setVisibleColumnIds(null);
return;
}
const allColumnIds = normalizedColumns.map((column) => column._columnId);
if (typeof window === "undefined" || !columnVisibilityStorageKey) {
setVisibleColumnIds((current) => current ?? allColumnIds);
return;
}
try {
const rawValue = window.localStorage.getItem(columnVisibilityStorageKey);
if (!rawValue) {
setVisibleColumnIds(allColumnIds);
return;
}
const parsedValue = JSON.parse(rawValue);
if (!Array.isArray(parsedValue)) {
setVisibleColumnIds(allColumnIds);
return;
}
const sanitizedIds = parsedValue.filter(
(value): value is string =>
typeof value === "string" && allColumnIds.includes(value)
);
setVisibleColumnIds(sanitizedIds.length > 0 ? sanitizedIds : allColumnIds);
} catch {
setVisibleColumnIds(allColumnIds);
}
}, [columnVisibilityEnabled, columnVisibilityStorageKey, normalizedColumns]);
React.useEffect(() => {
if (!columnVisibilityEnabled || !columnVisibilityStorageKey || !visibleColumnIds) {
return;
}
if (typeof window === "undefined") return;
window.localStorage.setItem(
columnVisibilityStorageKey,
JSON.stringify(visibleColumnIds)
);
}, [columnVisibilityEnabled, columnVisibilityStorageKey, visibleColumnIds]);
React.useEffect(() => {
if (!columnVisibilityEnabled || !isColumnsMenuOpen) return;
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
if (
columnsMenuRef.current?.contains(target) ||
columnsButtonRef.current?.contains(target)
) {
return;
}
setIsColumnsMenuOpen(false);
setColumnsMenuPos(null);
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [columnVisibilityEnabled, isColumnsMenuOpen]);
const resolvedVisibleColumnIds = React.useMemo(() => {
if (!columnVisibilityEnabled) {
return normalizedColumns.map((column) => column._columnId);
}
const allColumnIds = normalizedColumns.map((column) => column._columnId);
const safeVisibleIds =
visibleColumnIds?.filter((id) => allColumnIds.includes(id)) ?? allColumnIds;
return safeVisibleIds.length > 0 ? safeVisibleIds : allColumnIds;
}, [columnVisibilityEnabled, normalizedColumns, visibleColumnIds]);
const visibleColumns = React.useMemo(
() =>
normalizedColumns.filter((column) =>
resolvedVisibleColumnIds.includes(column._columnId)
),
[normalizedColumns, resolvedVisibleColumnIds]
);
2026-01-19 11:05:40 +05:30
React.useEffect(() => {
if (!manualPagination) {
setInternalPage(1);
}
}, [internalSearch, internalPageSize, manualPagination]);
React.useEffect(() => {
if (manualPagination || controlledPageSize !== undefined) {
return;
}
setInternalPageSize(
resolveStoredTablePageSize({
storageKey: pageSizeStorageKey,
pageSizeOptions,
defaultPageSize,
})
);
}, [
controlledPageSize,
defaultPageSize,
manualPagination,
pageSizeOptions,
pageSizeStorageKey,
]);
2026-01-19 11:05:40 +05:30
const searchableColumns = React.useMemo(
() => visibleColumns.filter((c) => c.searchable !== false),
[visibleColumns]
2026-01-19 11:05:40 +05:30
);
const getCellValue = React.useCallback(
(row: T, key: ColumnDef<T>["key"]) =>
(row as Record<string, unknown>)[String(key)],
[]
);
const normalizedText = (v: unknown) => {
if (v instanceof Date) return v.toISOString();
if (v == null) return "";
return String(v);
};
const filtered = React.useMemo(() => {
if (manualFiltering) return data;
const query = search.trim().toLowerCase();
return data.filter((row) => {
if (!query) return true;
return searchableColumns.some((c) => {
const text = normalizedText(getCellValue(row, c.key));
return text.toLowerCase().includes(query);
});
});
}, [data, getCellValue, search, searchableColumns, manualFiltering]);
const total = manualPagination && totalRows !== undefined ? totalRows : filtered.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const clampedPage = Math.min(page, totalPages);
const start = (clampedPage - 1) * pageSize;
const pageRows = manualPagination ? data : filtered.slice(start, start + pageSize);
const exportCurrentView = () => {
const header = columns
.filter((c) => resolvedVisibleColumnIds.includes(getColumnId(c)))
2026-01-19 11:05:40 +05:30
.map((c) =>
toCSVValue(
c.exportHeader ??
(typeof c.header === "string" ? c.header : "")
)
)
.join(",");
const lines = pageRows.map((row) =>
visibleColumns
2026-01-19 11:05:40 +05:30
.map((c) => {
const value = c.render ? c.render(row) : getCellValue(row, c.key);
if (typeof value === "string" || typeof value === "number")
return toCSVValue(value);
return toCSVValue(getCellValue(row, c.key));
})
.join(",")
);
downloadCSV(exportFileName, [header, ...lines]);
};
const containerRef = React.useRef<HTMLDivElement | null>(null);
const [showSuggestions, setShowSuggestions] = React.useState(false);
const [activeIndex, setActiveIndex] = React.useState(-1);
const [highlightedId, setHighlightedId] = React.useState<string | number | null>(null);
const defaultLabelBuilder = React.useCallback(
(row: T) => {
const parts = searchableColumns
.map((c) => normalizedText(getCellValue(row, c.key)))
.filter(Boolean);
return parts.join(" • ");
},
[getCellValue, searchableColumns]
);
const suggestionLabel = buildSuggestionLabel ?? defaultLabelBuilder;
const suggestions = React.useMemo(() => {
if (!enableSearchDropdown) return [];
const q = search.trim().toLowerCase();
if (!q) return [];
const scored = data
.map((row, idx) => {
const label = suggestionLabel(row);
const lower = label.toLowerCase();
if (!lower.includes(q)) return null;
const starts = lower.startsWith(q) ? 0 : 1;
return { row, idx, label, score: starts };
})
.filter(Boolean) as Array<{
row: T;
idx: number;
label: string;
score: number;
}>;
scored.sort((a, b) => a.score - b.score || a.label.localeCompare(b.label));
return scored.slice(0, maxSuggestions);
}, [enableSearchDropdown, search, data, maxSuggestions, suggestionLabel]);
React.useEffect(() => {
if (!enableSearchDropdown) return;
const onDocClick = (e: MouseEvent) => {
if (!containerRef.current?.contains(e.target as Node)) {
setShowSuggestions(false);
setActiveIndex(-1);
}
};
document.addEventListener("mousedown", onDocClick);
return () => document.removeEventListener("mousedown", onDocClick);
}, [enableSearchDropdown]);
const jumpToRow = React.useCallback(
(absoluteIndex: number, row: T) => {
const targetPage = Math.max(1, Math.ceil((absoluteIndex + 1) / pageSize));
if (manualPagination && onPageChange) {
onPageChange(targetPage);
} else {
setInternalPage(targetPage);
}
const id = getRowId?.(row, absoluteIndex) ?? String(absoluteIndex);
setHighlightedId(id);
window.setTimeout(() => setHighlightedId(null), 1600);
},
[getRowId, pageSize, manualPagination, onPageChange]
);
const selectSuggestion = React.useCallback(
(s: { row: T; idx: number; label: string }) => {
if (manualFiltering && onSearchChange) {
onSearchChange(s.label);
} else {
setInternalSearch(s.label);
}
setShowSuggestions(false);
setActiveIndex(-1);
jumpToRow(s.idx, s.row);
onSuggestionSelect?.(s.row);
},
[jumpToRow, onSuggestionSelect, manualFiltering, onSearchChange]
);
const onSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!enableSearchDropdown || !showSuggestions) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, suggestions.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter" && activeIndex >= 0) {
e.preventDefault();
const chosen = suggestions[activeIndex];
selectSuggestion({ row: chosen.row, idx: chosen.idx, label: chosen.label });
} else if (e.key === "Escape") {
setShowSuggestions(false);
setActiveIndex(-1);
}
};
const toggleColumnVisibility = (columnId: string) => {
if (!columnVisibilityEnabled) return;
setDraftVisibleColumnIds((current) => {
const allColumnIds = normalizedColumns.map((column) => column._columnId);
const currentIds = current.length > 0 ? current : allColumnIds;
const isVisible = currentIds.includes(columnId);
if (isVisible) {
if (currentIds.length === 1) {
return currentIds;
}
return currentIds.filter((id) => id !== columnId);
}
return allColumnIds.filter(
(id) => id === columnId || currentIds.includes(id)
);
});
};
const openColumnsMenu = () => {
if (!columnVisibilityEnabled || !columnsButtonRef.current) return;
const rect = columnsButtonRef.current.getBoundingClientRect();
const popoverWidth = Math.min(420, window.innerWidth - 32);
const preferredLeft = rect.right - popoverWidth;
const clampedLeft = Math.min(
Math.max(16, preferredLeft),
Math.max(16, window.innerWidth - popoverWidth - 16)
);
setColumnsMenuPos({ top: rect.bottom + 8, left: clampedLeft });
setDraftVisibleColumnIds(resolvedVisibleColumnIds);
setIsColumnsMenuOpen(true);
};
const closeColumnsMenu = () => {
setIsColumnsMenuOpen(false);
setColumnsMenuPos(null);
};
const handleColumnsMenuToggle = () => {
if (isColumnsMenuOpen) {
closeColumnsMenu();
return;
}
openColumnsMenu();
};
const handleClearColumns = () => {
const allColumnIds = normalizedColumns.map((column) => column._columnId);
setDraftVisibleColumnIds(allColumnIds);
setVisibleColumnIds(allColumnIds);
closeColumnsMenu();
};
const handleApplyColumns = () => {
const nextVisibleIds =
draftVisibleColumnIds.length > 0
? draftVisibleColumnIds
: normalizedColumns.map((column) => column._columnId);
setVisibleColumnIds(nextVisibleIds);
closeColumnsMenu();
};
2026-01-19 11:05:40 +05:30
return (
<div
className={cn(
"rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] shadow-md",
className
)}
ref={containerRef}
>
<div className="flex items-center justify-between p-4 bg-[var(--card-bg)] border-b border-[var(--card-border)] rounded-t-lg">
<div className="relative w-full max-w-sm">
<CustomInput
label=""
value={search}
onChange={(e) => {
const newValue = e.target.value;
if (manualFiltering && onSearchChange) {
onSearchChange(newValue);
} else {
setInternalSearch(newValue);
}
if (enableSearchDropdown) setShowSuggestions(true);
}}
onKeyDown={onSearchKeyDown}
onFocus={() => {
if (enableSearchDropdown && search.trim()) setShowSuggestions(true);
}}
placeholder={t('actions.search') + "..."}
className="mb-0 text-[var(--text-primary)]"
ref={searchInputRef}
/>
{enableSearchDropdown &&
showSuggestions &&
suggestions.length > 0 && (
<div className="absolute z-20 mt-1 w-full overflow-hidden rounded-md border border-[var(--card-border)] bg-[var(--card-bg)] shadow-lg">
<ul className="max-h-72 overflow-auto py-1 text-sm">
{suggestions.map((s, i) => (
<li
key={(getRowId?.(s.row, s.idx) ?? s.label) as React.Key}
className={cn(
"cursor-pointer px-3 py-2 hover:bg-[var(--table-row-hover)]",
i === activeIndex && "bg-[var(--table-row-hover)]"
)}
onMouseEnter={() => setActiveIndex(i)}
onMouseLeave={() => setActiveIndex(-1)}
onClick={() =>
selectSuggestion({
row: s.row,
idx: s.idx,
label: s.label,
})
}
>
<div className="truncate text-[var(--text-primary)]">{s.label}</div>
</li>
))}
</ul>
</div>
)}
</div>
<div className="relative flex items-center gap-2">
2026-01-19 11:05:40 +05:30
{filterControls}
{columnVisibilityEnabled ? (
<div className="relative" ref={columnsButtonRef}>
<CustomButton
variant="outlined"
size="sm"
onClick={handleColumnsMenuToggle}
leftIcon={<Columns3 size={16} />}
>
{t("actions.columns", { defaultValue: "Columns" })}
</CustomButton>
</div>
) : null}
{exportEnabled ? (
<CustomButton
variant="outlined"
size="sm"
onClick={exportCurrentView}
leftIcon={<Upload size={16} />}
>
{t('actions.export')}
</CustomButton>
) : null}
2026-01-19 11:05:40 +05:30
</div>
</div>
{isColumnsMenuOpen && columnsMenuPos
? createPortal(
<div
ref={columnsMenuRef}
style={{
position: "fixed",
top: columnsMenuPos.top,
left: columnsMenuPos.left,
zIndex: 9999,
}}
className="w-[420px] max-w-[calc(100vw-32px)] overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-[0_24px_60px_rgba(15,23,42,0.18)]"
>
<div className="border-b border-slate-200 px-4 py-4">
<h3 className="text-lg font-semibold text-slate-900">
{t("actions.columns", { defaultValue: "Columns" })}
</h3>
</div>
<div className="max-h-[260px] overflow-y-auto py-2">
{normalizedColumns.map((column) => {
const columnId = column._columnId;
const isChecked = draftVisibleColumnIds.includes(columnId);
const isLastVisible =
isChecked && draftVisibleColumnIds.length === 1;
return (
<button
key={columnId}
type="button"
onClick={() => toggleColumnVisibility(columnId)}
disabled={isLastVisible}
className="flex w-full items-start gap-3 px-4 py-2 text-left text-sm text-slate-700 transition-colors hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-transparent"
>
<span
className={`mt-0.5 flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded border transition-colors ${
isChecked
? "border-blue-500 bg-blue-500 text-white"
: "border-slate-300 bg-white text-transparent"
}`}
>
<Check size={12} strokeWidth={3} />
</span>
<span className="whitespace-normal break-words leading-5 text-slate-900">
{getColumnVisibilityLabel(column, column._fallbackLabel)}
</span>
</button>
);
})}
</div>
<div className="flex items-center justify-between border-t border-slate-200 px-4 py-3">
<button
type="button"
onClick={handleClearColumns}
className="text-sm font-medium text-slate-500 transition hover:text-slate-700"
>
{t("actions.clear", { defaultValue: "Clear" })}
</button>
<CustomButton
variant="primary"
onClick={handleApplyColumns}
className="!h-9 !rounded-xl !px-4 !text-sm"
>
{t("actions.apply", { defaultValue: "Apply" })}
</CustomButton>
</div>
</div>,
document.body
)
: null}
2026-01-19 11:05:40 +05:30
<div
className="max-w-full overflow-x-auto overflow-y-auto relative [&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-[#1D2A4B] [&::-webkit-scrollbar-thumb]:rounded-full hover:[&::-webkit-scrollbar-thumb]:bg-[#1D2A4B]/80 [&::-webkit-scrollbar:vertical]:hidden"
style={{ maxHeight }}
>
<table className="min-w-max w-full text-sm">
<thead className="bg-[var(--table-header-bg)] sticky top-0 z-10 shadow-sm">
<tr className="text-left text-xs uppercase tracking-wider text-[var(--text-secondary)]">
{visibleColumns.map((c, idx) => (
2026-01-19 11:05:40 +05:30
<th
key={String(c.key) + idx}
className={cn(
"whitespace-nowrap px-6 py-3 font-medium bg-[var(--table-header-bg)] text-[var(--text-secondary)]",
c.cellClassName
)}
>
{c.header}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-[var(--table-border)] text-[var(--text-primary)]">
{isLoading ? (
<tr>
<td
colSpan={visibleColumns.length}
2026-01-19 11:05:40 +05:30
className="px-6 py-24 text-center text-[var(--text-secondary)]"
>
2026-04-25 08:23:16 +03:00
<div className="flex flex-col items-center justify-center min-h-[100px]">
<Loader />
2026-01-19 11:05:40 +05:30
</div>
</td>
</tr>
) : pageRows.length > 0 ? (
pageRows.map((row, i) => {
const absoluteIndex = start + i;
const id = getRowId?.(row, absoluteIndex) ?? `${absoluteIndex}`;
const isHighlighted = highlightedId === id;
return (
<tr
key={id}
className={cn(
"bg-[var(--card-bg)] hover:bg-[var(--table-row-hover)] transition-colors",
isHighlighted && highlightClassName
)}
>
{visibleColumns.map((c, ci) => {
2026-01-19 11:05:40 +05:30
const content = c.render
? c.render(row)
: (getCellValue(row, c.key) as React.ReactNode);
return (
<td
key={String(c.key) + ci}
className={cn(
"whitespace-nowrap px-6 py-4 text-[var(--text-primary)]",
c.cellClassName
)}
>
{content ?? ""}
</td>
);
})}
</tr>
);
})
) : (
<tr>
<td
colSpan={visibleColumns.length}
2026-01-19 11:05:40 +05:30
className="px-6 py-12 text-center text-[var(--text-secondary)]"
>
{t('common.noData')}
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="flex items-center justify-between p-4 bg-[var(--card-bg)] border-t border-[var(--card-border)] rounded-b-lg">
<div className="text-sm text-[var(--text-secondary)]">
{t('common.showing')}{" "}
<span className="font-medium text-[var(--text-primary)]">{total === 0 ? 0 : start + 1}</span> -{" "}
<span className="font-medium text-[var(--text-primary)]">{start + pageRows.length}</span> {t('common.of')}{" "}
<span className="font-medium text-[var(--text-primary)]">{total}</span> {t('common.entries')}
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-[var(--text-secondary)]">{t('common.rowsPerPage')}:</span>
<select
value={pageSize}
onChange={(e) => {
const newSize = Number(e.target.value);
persistTablePageSize(pageSizeStorageKey, newSize, pageSizeOptions);
2026-01-19 11:05:40 +05:30
if (manualPagination && onPageSizeChange) {
onPageSizeChange(newSize);
} else {
setInternalPageSize(newSize);
}
}}
className="rounded-md border border-[var(--card-border)] text-sm py-1.5 px-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-[var(--card-bg)] text-[var(--text-primary)]"
>
{pageSizeOptions.map((ps) => (
<option key={ps} value={ps}>
{ps}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => {
const newPage = Math.max(1, page - 1);
if (manualPagination && onPageChange) {
onPageChange(newPage);
} else {
setInternalPage((p: number) => Math.max(1, p - 1));
}
}}
disabled={clampedPage === 1}
className="flex items-center justify-center h-9 w-9 text-sm border border-[var(--card-border)] rounded-md hover:bg-[var(--table-row-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-[var(--text-primary)]"
>
<ChevronLeft size={16} />
</button>
<div className="text-sm text-[var(--text-secondary)]">
{t('common.page')} <span className="font-medium text-[var(--text-primary)]">{clampedPage}</span> {t('common.of')}{" "}
<span className="font-medium text-[var(--text-primary)]">{totalPages}</span>
</div>
<button
onClick={() => {
const newPage = Math.min(totalPages, page + 1);
if (manualPagination && onPageChange) {
onPageChange(newPage);
} else {
setInternalPage((p: number) => Math.min(totalPages, p + 1));
}
}}
disabled={clampedPage === totalPages}
className="flex items-center justify-center h-9 w-9 text-sm border border-[var(--card-border)] rounded-md hover:bg-[var(--table-row-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-[var(--text-primary)]"
>
<ChevronRight size={16} />
</button>
</div>
</div>
</div>
</div>
);
}
export default DataTable;