2026-01-19 11:05:40 +05:30
|
|
|
import * as React from "react";
|
2026-04-20 11:08:55 +05:30
|
|
|
import { createPortal } from "react-dom";
|
2026-01-19 11:05:40 +05:30
|
|
|
import CustomButton from "./CustomButton";
|
|
|
|
|
import CustomInput from "./CustomInput";
|
2026-04-20 11:08:55 +05:30
|
|
|
import { Upload, ChevronLeft, ChevronRight, Columns3, Check } from "lucide-react";
|
2026-01-19 11:05:40 +05:30
|
|
|
import { useTranslation } from "react-i18next";
|
2026-04-20 11:08:55 +05:30
|
|
|
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
|
|
|
|
2026-04-20 11:08:55 +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> = {
|
2026-04-20 11:08:55 +05:30
|
|
|
id?: string;
|
2026-01-19 11:05:40 +05:30
|
|
|
key: keyof T | string;
|
|
|
|
|
header: React.ReactNode;
|
2026-04-20 11:08:55 +05:30
|
|
|
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[];
|
2026-04-20 11:08:55 +05:30
|
|
|
exportEnabled?: boolean;
|
2026-01-19 11:05:40 +05:30
|
|
|
exportFileName?: string;
|
|
|
|
|
className?: string;
|
|
|
|
|
getRowId?: (row: T, index: number) => string | number;
|
|
|
|
|
filterControls?: React.ReactNode;
|
2026-04-20 11:08:55 +05:30
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 11:08:55 +05:30
|
|
|
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],
|
2026-04-20 11:08:55 +05:30
|
|
|
exportEnabled = false,
|
2026-01-19 11:05:40 +05:30
|
|
|
exportFileName = "export.csv",
|
|
|
|
|
className,
|
|
|
|
|
getRowId,
|
|
|
|
|
filterControls,
|
2026-04-20 11:08:55 +05:30
|
|
|
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("");
|
2026-04-20 11:08:55 +05:30
|
|
|
const [internalPageSize, setInternalPageSize] = React.useState(() =>
|
|
|
|
|
resolveStoredTablePageSize({
|
|
|
|
|
storageKey: pageSizeStorageKey,
|
|
|
|
|
pageSizeOptions,
|
|
|
|
|
defaultPageSize,
|
|
|
|
|
})
|
|
|
|
|
);
|
2026-01-19 11:05:40 +05:30
|
|
|
const [internalPage, setInternalPage] = React.useState(1);
|
2026-04-20 11:08:55 +05:30
|
|
|
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;
|
|
|
|
|
|
2026-04-20 11:08:55 +05:30
|
|
|
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]);
|
|
|
|
|
|
2026-04-20 11:08:55 +05:30
|
|
|
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(
|
2026-04-20 11:08:55 +05:30
|
|
|
() => 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
|
2026-04-20 11:08:55 +05:30
|
|
|
.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) =>
|
2026-04-20 11:08:55 +05:30
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-20 11:08:55 +05:30
|
|
|
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>
|
|
|
|
|
|
2026-04-20 11:08:55 +05:30
|
|
|
<div className="relative flex items-center gap-2">
|
2026-01-19 11:05:40 +05:30
|
|
|
{filterControls}
|
2026-04-20 11:08:55 +05:30
|
|
|
{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>
|
|
|
|
|
|
2026-04-20 11:08:55 +05:30
|
|
|
{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)]">
|
2026-04-20 11:08:55 +05:30
|
|
|
{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
|
2026-04-20 11:08:55 +05:30
|
|
|
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
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-04-20 11:08:55 +05:30
|
|
|
{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
|
2026-04-20 11:08:55 +05:30
|
|
|
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);
|
2026-04-20 11:08:55 +05:30
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 11:08:55 +05:30
|
|
|
export default DataTable;
|