Files
imphnen-frontend-service/libs/ui/src/organisms/datatable/datatable.tsx
T
Hafid NurandGitHub 0c7887268c feat(backoffice): FE integration (data fetch) for dashboard, teams, submission, and user management page (#70)
* feat(backoffice): create a boilerplate page for Hackathon dashboard

- Create an empty page for Hackathon dashboard
- Comment out and hide the existing backoffice sidebar

* feat(backoffice): Create a nested/dropdown sidebar list

- Create a dropdown sidebar list
- Show back the old navigation and group them
- Make the sidebar responsive for mobile view

* test datatable with mock data

* base UI for Hackathon backoffice

TODO:
- Organize table schema for users, teams, and submissions management
- Create API Contract for additional back-end endpoint

* feat(hackathon): draft data table column & API Contract

* update endpoint

* feat(backoffice): update page hackathon user management

- update data table component
- update filtering & pagination
- add modal display to edit and add user
- hide notification icon in backoffice wrapper

* feat(backoffice): add API contract for hackathon users

* feat(backoffice): little adjustment in hackathon users management UI and API contract

* feat(backoffice): update hackathon team management page

- add modal for manage team, add new team, and view project submission
- reorganize the table column and data table

* feat(backoffice): add searchable city filter

- add component for city filter
- apply to user management and team management pages

* feat(backoffice): improve hackathon team modal UI and add API contract

- Add feature to select city in team detail modal using CityFilterSelect component
- Add feature to change team logo and banner
- Add API contract documentation for hackathon teams in backoffice

* feat(backoffice): authentication middleware, error pages, and 404 page

* feat(backoffice): hackathon dashboard integration

* feat(backoffice): users page integration

- Get users data from API
- Set up server-side pagination and match URL params
- Hide filter that doesn't exist in back-end

* feat(backoffice): teams page integration

- Get teams data from API
- Hide filter that doesn't exists in back-end
- Simplify modal according to the back-end

* feat(backoffice): submission page integration

- Fetch submissions data from API
- Move submission modal to hackathon-submissions page
2025-12-10 08:41:30 +07:00

305 lines
9.8 KiB
TypeScript

import {
PaginationState,
SortingState,
useReactTable,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
getFilteredRowModel,
flexRender,
ColumnDef,
Table,
RowData,
TableOptions,
} from '@tanstack/react-table';
import { Pagination } from '../../molecules';
import React from 'react';
import { cn } from '@imphnen-frontend-service/utils';
interface DataTableProps<T extends RowData> {
table?: Table<T>;
data?: T[];
columns?: ColumnDef<T, unknown>[];
pageSize?: number;
className?: string;
// server-side pagination props
manualPagination?: boolean;
pageCount?: number;
currentPage?: number;
onPageChange?: (page: number) => void;
}
export const DataTable = <T extends RowData>({
table,
data = [],
columns = [],
pageSize = 9,
className,
manualPagination = false,
pageCount,
currentPage = 1,
onPageChange,
}: DataTableProps<T>) => {
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize,
});
const [sorting, setSorting] = React.useState<SortingState>([]);
// Update pagination state when pageSize prop changes
React.useEffect(() => {
setPagination((prev) => ({
...prev,
pageSize,
}));
}, [pageSize]);
// Reset pagination when data changes to prevent out-of-bounds errors
React.useEffect(() => {
if (data.length > 0) {
setPagination((prev) => ({
...prev,
pageIndex: 0, // Reset to first page when data changes
}));
}
}, [data.length]);
// Memoize data and columns to prevent unnecessary re-renders
const memoizedData = React.useMemo(() => data, [data]);
const memoizedColumns = React.useMemo(() => columns, [columns]);
// Memoize table configuration to prevent recreation on every render
const tableConfig = React.useMemo(() => {
const config: TableOptions<T> = {
data: memoizedData,
columns: memoizedColumns,
state: {
pagination,
sorting,
},
onPaginationChange: setPagination,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
// server-side pagination config
manualPagination,
pageCount: manualPagination ? pageCount : undefined,
};
return config;
}, [
memoizedData,
memoizedColumns,
pagination,
sorting,
manualPagination,
pageCount,
]);
// Prefer external table instance if provided; otherwise create an internal one
const internalTable = useReactTable(tableConfig);
const t = table ?? internalTable;
// Handle empty data state
const isEmpty = t.getRowModel().rows.length === 0;
return (
<div className={cn('flex flex-col gap-8', className)}>
<div className="w-full overflow-x-auto">
<table className="w-full min-w-full text-base">
<thead className="bg-primary-50 mb-3 text-left text-nowrap">
{t.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
onClick={
header.column.getCanSort()
? header.column.getToggleSortingHandler()
: undefined
}
className={cn(
'py-4 px-5 font-normal first:rounded-l-lg last:rounded-r-lg',
header.column.getCanSort() &&
'cursor-pointer select-none hover:bg-primary-100 transition-colors',
header?.column?.columnDef?.meta?.headerClassName
)}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{header.column.getCanSort() && (
<span className="ml-2 text-xs text-gray-500">
{header.column.getIsSorted() === 'asc' && '▲'}
{header.column.getIsSorted() === 'desc' && '▼'}
{!header.column.getIsSorted() && <span></span>}
</span>
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{isEmpty ? (
<tr>
<td
colSpan={t.getAllColumns().length}
className="py-8 px-5 text-center text-neutral-500"
>
No data available
</td>
</tr>
) : (
t.getRowModel().rows.map((row, rowIndex) => (
<tr
key={row.id}
className={cn(
'hover:bg-primary-50 transition-colors',
rowIndex % 2 === 0 ? 'bg-white' : 'bg-primary-100'
)}
>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className={cn(
'py-3 px-5 first:rounded-l-lg last:rounded-r-lg',
cell?.column?.columnDef?.meta?.cellClassName
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
{manualPagination && onPageChange && pageCount ? (
// Server-side pagination controls with numbered pages
<div className="flex items-center justify-center gap-10">
<button
className="disabled:opacity-50 cursor-pointer"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
aria-label="Previous page"
>
<svg
className="w-4 h-4 text-neutral-800"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
<div className="flex gap-4 items-baseline">
{pageCount <= 8 ? (
// Show all pages if 8 or fewer
Array.from({ length: pageCount }, (_, index) => (
<button
key={index}
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
currentPage === index + 1
? 'bg-primary-500 text-white'
: 'bg-primary-100 hover:bg-primary-200'
}`}
onClick={() => onPageChange(index + 1)}
>
{index + 1}
</button>
))
) : (
// Show ellipsis for many pages
<>
<button
onClick={() => onPageChange(1)}
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
currentPage === 1
? 'bg-primary-500 text-white'
: 'bg-primary-100 hover:bg-primary-200'
}`}
>
1
</button>
{currentPage > 3 && <span>...</span>}
{Array.from(
{ length: 5 },
(_, index) => currentPage - 2 + index
)
.filter((page) => page > 1 && page < pageCount)
.map((page) => (
<button
key={page}
onClick={() => onPageChange(page)}
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
currentPage === page
? 'bg-primary-500 text-white'
: 'bg-primary-100 hover:bg-primary-200'
}`}
>
{page}
</button>
))}
{currentPage < pageCount - 2 && <span>...</span>}
<button
onClick={() => onPageChange(pageCount)}
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
currentPage === pageCount
? 'bg-primary-500 text-white'
: 'bg-primary-100 hover:bg-primary-200'
}`}
>
{pageCount}
</button>
</>
)}
</div>
<button
className="disabled:opacity-50 cursor-pointer"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === pageCount}
aria-label="Next page"
>
<svg
className="w-4 h-4 text-neutral-800"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</button>
</div>
) : (
// Client-side pagination (default)
<Pagination table={t} />
)}
</div>
);
};
export default DataTable;