fcsc_ipi_frontend/ipi-survey-platform/src/components/common/Table.jsx

323 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;