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

510 lines
18 KiB
TypeScript
Raw Normal View History

2026-01-19 11:05:40 +05:30
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;