323 lines
12 KiB
JavaScript
323 lines
12 KiB
JavaScript
import React from 'react';
|
||
import CaretLeftIcon from '../../assets/images/CaretLeft.svg';
|
||
import CaretRightIcon from '../../assets/images/CaretRight.svg';
|
||
import CaretDoubleLeftIcon from '../../assets/images/CaretDoubleLeft.svg';
|
||
import CaretDoubleRightIcon from '../../assets/images/CaretDoubleRight.svg';
|
||
|
||
// Generic, reusable table
|
||
// Props:
|
||
// - headers: string[]
|
||
// - rows: any[][] (array of arrays matching header order)
|
||
// - beforeHeader: ReactNode (optional toolbar row above table header)
|
||
// - className: string (optional)
|
||
// - headerClassName: string (optional)
|
||
// - separated: boolean (optional) -> adds vertical gaps between rows
|
||
// - rowGapClass: string (optional) -> tailwind border-spacing classes, default: 'border-spacing-y-2'
|
||
// - getRowClass: (rowIndex) => string (optional)
|
||
// - getCellClass: (rowIndex, colIndex) => string (optional)
|
||
// - renderCell: (value, rowIndex, colIndex) => ReactNode (optional)
|
||
// - pagination: {
|
||
// currentPage?: number,
|
||
// onPageChange?: (page: number) => void,
|
||
// pageSize?: number,
|
||
// totalItems?: number,
|
||
// pageSizeOptions?: number[],
|
||
// onPageSizeChange?: (size: number) => void,
|
||
// }
|
||
const Table = ({
|
||
headers = [],
|
||
rows = [],
|
||
beforeHeader = null,
|
||
className = '',
|
||
headerClassName = '',
|
||
separated = false,
|
||
rowGapClass = 'border-spacing-y-2',
|
||
getRowClass,
|
||
getCellClass,
|
||
renderCell,
|
||
pagination,
|
||
columnWidths = [],
|
||
disableHorizontalScroll = false,
|
||
}) => {
|
||
const enablePagination = Boolean(pagination);
|
||
const pageSize = enablePagination ? Math.max(1, pagination?.pageSize || 10) : rows.length || 1;
|
||
const totalItems = enablePagination ? pagination?.totalItems ?? rows.length : rows.length;
|
||
const totalPages = enablePagination ? Math.max(1, Math.ceil(totalItems / pageSize)) : 1;
|
||
const isControlled = enablePagination && typeof pagination?.currentPage === 'number' && typeof pagination?.onPageChange === 'function';
|
||
const [internalPage, setInternalPage] = React.useState(1);
|
||
const pageSizeOptions = enablePagination ? pagination?.pageSizeOptions ?? [] : [];
|
||
|
||
const handlePageSizeChange = React.useCallback(
|
||
(event) => {
|
||
if (!enablePagination) return;
|
||
const nextSize = Number(event.target.value);
|
||
if (Number.isNaN(nextSize) || nextSize <= 0) return;
|
||
pagination?.onPageSizeChange?.(nextSize);
|
||
},
|
||
[enablePagination, pagination]
|
||
);
|
||
|
||
React.useEffect(() => {
|
||
if (!enablePagination || isControlled) return;
|
||
const maxPage = Math.max(1, Math.ceil(rows.length / pageSize));
|
||
setInternalPage((prev) => {
|
||
if (prev > maxPage) return maxPage;
|
||
if (prev < 1) return 1;
|
||
return prev;
|
||
});
|
||
}, [enablePagination, isControlled, rows.length, pageSize]);
|
||
|
||
const currentPage = enablePagination
|
||
? isControlled
|
||
? pagination.currentPage || 1
|
||
: internalPage
|
||
: 1;
|
||
|
||
const safeCurrentPage = enablePagination ? Math.min(Math.max(currentPage, 1), totalPages) : 1;
|
||
const startIndex = enablePagination ? (safeCurrentPage - 1) * pageSize : 0;
|
||
const endIndex = enablePagination ? startIndex + pageSize : rows.length;
|
||
const rangeStart = totalItems === 0 ? 0 : startIndex + 1;
|
||
const rangeEnd = enablePagination ? Math.min(endIndex, totalItems) : rows.length;
|
||
|
||
const displayedRows = React.useMemo(() => {
|
||
if (!enablePagination) return rows;
|
||
return rows.slice(startIndex, endIndex);
|
||
}, [enablePagination, rows, startIndex, endIndex]);
|
||
|
||
const goToPage = React.useCallback(
|
||
(pageNumber) => {
|
||
if (!enablePagination) return;
|
||
const next = Math.min(Math.max(pageNumber, 1), totalPages);
|
||
if (isControlled) {
|
||
pagination.onPageChange?.(next);
|
||
} else {
|
||
setInternalPage(next);
|
||
}
|
||
},
|
||
[enablePagination, isControlled, pagination, totalPages]
|
||
);
|
||
|
||
const hasPrevious = enablePagination ? safeCurrentPage > 1 : false;
|
||
const hasNext = enablePagination ? safeCurrentPage < totalPages : false;
|
||
|
||
const pageItems = React.useMemo(() => {
|
||
if (!enablePagination) return [];
|
||
if (totalPages <= 7) {
|
||
return Array.from({ length: totalPages }, (_, index) => index + 1);
|
||
}
|
||
|
||
const items = [1];
|
||
const left = safeCurrentPage - 1;
|
||
const right = safeCurrentPage + 1;
|
||
|
||
if (left > 2) {
|
||
items.push('ellipsis-left');
|
||
}
|
||
|
||
const start = Math.max(2, left);
|
||
const end = Math.min(totalPages - 1, right);
|
||
for (let page = start; page <= end; page += 1) {
|
||
items.push(page);
|
||
}
|
||
|
||
if (right < totalPages - 1) {
|
||
items.push('ellipsis-right');
|
||
}
|
||
|
||
items.push(totalPages);
|
||
return items;
|
||
}, [enablePagination, safeCurrentPage, totalPages]);
|
||
|
||
const arrowButtonClass =
|
||
'h-8 w-8 flex items-center justify-center text-[#92722A] disabled:text-[#C3C6CB] disabled:cursor-not-allowed transition-colors hover:text-[#B68A35]';
|
||
const navButtonClass =
|
||
'h-9 px-2 text-sm text-[#232528] disabled:text-[#C3C6CB] disabled:cursor-not-allowed transition-colors flex items-center gap-2 hover:text-[#92722A]';
|
||
|
||
return (
|
||
<div className={`overflow-hidden rounded-lg bg-white ${className}`}>
|
||
{beforeHeader}
|
||
<div
|
||
className={`${disableHorizontalScroll ? 'overflow-x-hidden' : 'overflow-x-auto'} px-6 pt-2 pb-6`}
|
||
style={disableHorizontalScroll ? undefined : { scrollbarWidth: 'thin' }}
|
||
>
|
||
<table
|
||
className={`min-w-[720px] w-full table-fixed rounded-2xl px-4 py-4${
|
||
separated ? `border-separate ${rowGapClass}` : 'divide-y'
|
||
} border border-[#C3C6CB] rounded-2xl divide-[#C3C6CB]`}
|
||
>
|
||
<thead className={headerClassName || 'bg-[#F2ECCF] rounded-2xl'}>
|
||
<tr>
|
||
{headers.map((h, i) => {
|
||
const widthValue = columnWidths[i];
|
||
const widthStyle = widthValue
|
||
? {
|
||
width: typeof widthValue === 'number' ? `${widthValue}px` : widthValue,
|
||
minWidth: typeof widthValue === 'number' ? `${widthValue}px` : widthValue,
|
||
}
|
||
: undefined;
|
||
return (
|
||
<th
|
||
key={i}
|
||
className={`px-6 h-12 text-left text-xs font-medium text-[#1B1D21] align-middle ${
|
||
i === 0 ? 'first:rounded-tl-md' : ''
|
||
} ${i === headers.length - 1 ? 'last:rounded-tr-md' : ''}`}
|
||
style={widthStyle}
|
||
>
|
||
{h}
|
||
</th>
|
||
);
|
||
})}
|
||
</tr>
|
||
</thead>
|
||
<tbody className={`bg-white ${separated ? '' : 'divide-y divide-[#C3C6CB]'}`}>
|
||
{displayedRows.map((row, ri) => (
|
||
<tr
|
||
key={ri}
|
||
className={`${separated ? 'bg-white' : ''} ${getRowClass ? getRowClass(ri) : ''}`}
|
||
>
|
||
{row.map((cell, ci) => {
|
||
const widthValue = columnWidths[ci];
|
||
const widthStyle = widthValue
|
||
? {
|
||
width: typeof widthValue === 'number' ? `${widthValue}px` : widthValue,
|
||
minWidth: typeof widthValue === 'number' ? `${widthValue}px` : widthValue,
|
||
}
|
||
: undefined;
|
||
return (
|
||
<td
|
||
key={ci}
|
||
className={`px-6 h-12 text-sm text-[#232528] border-b border-[#C3C6CB] align-middle ${getCellClass ? getCellClass(ri, ci) : ''}`}
|
||
style={widthStyle}
|
||
>
|
||
{renderCell ? renderCell(cell, ri, ci) : cell}
|
||
</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{enablePagination && (
|
||
<div className="px-6 pb-6">
|
||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||
<div className="flex items-center gap-3 text-sm text-[#4B5563]">
|
||
<button
|
||
type="button"
|
||
className={arrowButtonClass}
|
||
onClick={() => goToPage(safeCurrentPage - 1)}
|
||
disabled={!hasPrevious}
|
||
aria-label="Previous page"
|
||
>
|
||
<img src={CaretLeftIcon} alt="Previous" className="h-4 w-4" />
|
||
</button>
|
||
<div className="flex items-center gap-2 text-[#232528]">
|
||
<span className="font-medium">{rangeStart}</span>
|
||
<span className="text-[#6B7280]">–</span>
|
||
<span className="font-medium">{rangeEnd}</span>
|
||
<span className="text-[#6B7280]">of</span>
|
||
<span className="font-medium">{totalItems}</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={arrowButtonClass}
|
||
onClick={() => goToPage(safeCurrentPage + 1)}
|
||
disabled={!hasNext}
|
||
aria-label="Next page"
|
||
>
|
||
<img src={CaretRightIcon} alt="Next" className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-3 text-sm text-[#4B5563]">
|
||
{pageSizeOptions.length > 0 && pagination?.onPageSizeChange && (
|
||
<label className="flex items-center gap-2 text-sm text-[#232528]">
|
||
<span>Rows per page</span>
|
||
<select
|
||
value={pageSize}
|
||
onChange={handlePageSizeChange}
|
||
className="h-9 rounded-md border border-[#C3C6CB] bg-white px-3 text-sm text-[#232528] focus:border-[#92722A] focus:outline-none"
|
||
>
|
||
{pageSizeOptions.map((option) => (
|
||
<option key={option} value={option}>
|
||
{option}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
)}
|
||
|
||
<button
|
||
type="button"
|
||
className={navButtonClass}
|
||
onClick={() => goToPage(1)}
|
||
disabled={!hasPrevious}
|
||
>
|
||
<img src={CaretDoubleLeftIcon} alt="First" className="h-4 w-4" />
|
||
<span>First</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={navButtonClass}
|
||
onClick={() => goToPage(safeCurrentPage - 1)}
|
||
disabled={!hasPrevious}
|
||
>
|
||
<img src={CaretLeftIcon} alt="Previous" className="h-4 w-4" />
|
||
<span>Previous</span>
|
||
</button>
|
||
|
||
{pageItems.map((item, index) => {
|
||
if (typeof item === 'string') {
|
||
return (
|
||
<span key={item + index} className="px-2 text-[#9CA3AF]">
|
||
…
|
||
</span>
|
||
);
|
||
}
|
||
const isActive = item === safeCurrentPage;
|
||
return (
|
||
<button
|
||
key={item}
|
||
type="button"
|
||
className={`h-9 min-w-[36px] rounded-full px-3 text-sm font-medium transition-colors ${
|
||
isActive
|
||
? 'bg-[#92722A] text-white'
|
||
: 'text-[#232528] hover:text-[#92722A]'
|
||
}`}
|
||
onClick={() => goToPage(item)}
|
||
>
|
||
{item}
|
||
</button>
|
||
);
|
||
})}
|
||
|
||
<button
|
||
type="button"
|
||
className={navButtonClass}
|
||
onClick={() => goToPage(safeCurrentPage + 1)}
|
||
disabled={!hasNext}
|
||
>
|
||
<span>Next</span>
|
||
<img src={CaretRightIcon} alt="Next" className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={navButtonClass}
|
||
onClick={() => goToPage(totalPages)}
|
||
disabled={!hasNext}
|
||
>
|
||
<span>Last</span>
|
||
<img src={CaretDoubleRightIcon} alt="Last" className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default Table;
|
||
|