510 lines
18 KiB
TypeScript
510 lines
18 KiB
TypeScript
import * as React from "react";
|
|||
|
|
import CustomButton from "./CustomButton";
|
||
|
|
import CustomInput from "./CustomInput";
|
||
|
|
import { Upload, ChevronLeft, ChevronRight } from "lucide-react";
|
||
|
|
import { useTranslation } from "react-i18next";
|
||
|
|
|
||
|
|
export function cn(...parts: Array<string | false | null | undefined>) {
|
||
|
|
return parts.filter(Boolean).join(" ");
|
||
|
|
}
|
||
|
|
|
||
|
|
export type Primitive =
|
||
|
|
| string
|
||
|
|
| number
|
||
|
|
| boolean
|
||
|
|
| null
|
||
|
|
| undefined
|
||
|
|
| Date
|
||
|
|
| Record<string, unknown>;
|
||
|
|
|
||
|
|
export type ColumnDef<T> = {
|
||
|
|
key: keyof T | string;
|
||
|
|
header: React.ReactNode;
|
||
|
|
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[];
|
||
|
|
exportFileName?: string;
|
||
|
|
className?: string;
|
||
|
|
getRowId?: (row: T, index: number) => string | number;
|
||
|
|
filterControls?: React.ReactNode;
|
||
|
|
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
|
||
|
|
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],
|
||
|
|
exportFileName = "export.csv",
|
||
|
|
className,
|
||
|
|
getRowId,
|
||
|
|
filterControls,
|
||
|
|
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(defaultPageSize);
|
||
|
|
const [internalPage, setInternalPage] = React.useState(1);
|
||
|
|
|
||
|
|
const search = manualFiltering && controlledSearch !== undefined ? controlledSearch : internalSearch;
|
||
|
|
const pageSize = manualPagination && controlledPageSize !== undefined ? controlledPageSize : internalPageSize;
|
||
|
|
const page = manualPagination && controlledPage !== undefined ? controlledPage : internalPage;
|
||
|
|
|
||
|
|
React.useEffect(() => {
|
||
|
|
if (!manualPagination) {
|
||
|
|
setInternalPage(1);
|
||
|
|
}
|
||
|
|
}, [internalSearch, internalPageSize, manualPagination]);
|
||
|
|
|
||
|
|
const searchableColumns = React.useMemo(
|
||
|
|
() => columns.filter((c) => c.searchable !== false),
|
||
|
|
[columns]
|
||
|
|
);
|
||
|
|
|
||
|
|
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
|
||
|
|
.map((c) =>
|
||
|
|
toCSVValue(
|
||
|
|
c.exportHeader ??
|
||
|
|
(typeof c.header === "string" ? c.header : "")
|
||
|
|
)
|
||
|
|
)
|
||
|
|
.join(",");
|
||
|
|
const lines = pageRows.map((row) =>
|
||
|
|
columns
|
||
|
|
.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);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
|
||
|
|
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="flex items-center gap-2">
|
||
|
|
{filterControls}
|
||
|
|
<CustomButton
|
||
|
|
variant="outlined"
|
||
|
|
size="sm"
|
||
|
|
onClick={exportCurrentView}
|
||
|
|
leftIcon={<Upload size={16} />}
|
||
|
|
>
|
||
|
|
{t('actions.export')}
|
||
|
|
</CustomButton>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<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)]">
|
||
|
|
{columns.map((c, idx) => (
|
||
|
|
<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={columns.length}
|
||
|
|
className="px-6 py-24 text-center text-[var(--text-secondary)]"
|
||
|
|
>
|
||
|
|
<div className="flex flex-col items-center justify-center">
|
||
|
|
<svg className="animate-spin h-8 w-8 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||
|
|
</svg>
|
||
|
|
<p>{t('common.loading')}</p>
|
||
|
|
</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
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{columns.map((c, ci) => {
|
||
|
|
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={columns.length}
|
||
|
|
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);
|
||
|
|
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;
|