diff --git a/apps/frontend/src/components/PageTemplate.tsx b/apps/frontend/src/components/PageTemplate.tsx new file mode 100644 index 0000000..b0cf287 --- /dev/null +++ b/apps/frontend/src/components/PageTemplate.tsx @@ -0,0 +1,360 @@ +/* eslint-disable no-mixed-spaces-and-tabs */ +import React, { ReactNode, useState } from "react"; +import { TbPlus, TbSearch } from "react-icons/tb"; +import DashboardTable from "./DashboardTable"; +import { + ColumnDef, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { + QueryKey, + UseQueryOptions, + keepPreviousData, + useQuery, +} from "@tanstack/react-query"; +import { useDebouncedValue } from "@mantine/hooks"; +import { Button } from "@/shadcn/components/ui/button"; +import { useNavigate } from "@tanstack/react-router"; +import { Card } from "@/shadcn/components/ui/card"; +import { Input } from "@/shadcn/components/ui/input"; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, +} from "@/shadcn/components/ui/pagination"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shadcn/components/ui/select"; +import { HiChevronLeft, HiChevronRight } from "react-icons/hi"; + +type PaginatedResponse> = { + data: Array; + _metadata: { + currentPage: number; + totalPages: number; + perPage: number; + totalItems: number; + }; +}; + +//ref: https://x.com/TkDodo/status/1491451513264574501 +type Props< + TQueryKey extends QueryKey, + TQueryFnData extends Record, + TError, + TData extends Record = TQueryFnData, +> = { + title: string; + createButton?: string | true | React.ReactNode; + modals?: React.ReactNode[]; + queryOptions: ( + page: number, + limit: number, + q?: string + ) => UseQueryOptions< + PaginatedResponse, + TError, + PaginatedResponse, + TQueryKey + >; + columnDefs: ColumnDef[]; +}; + +/** + * Creates a "Create New" button or returns the provided React node. + * + * @param property - The property that determines the type of button to create. It can be a boolean, string, or React node. + * @returns The create button element. + */ +const createCreateButton = ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + property: Props["createButton"] = true +) => { + const navigate = useNavigate(); + + const addQuery = () => { + navigate({ to: `${window.location.pathname}`, search: { create: true } }); + } + + if (property === true) { + return ( + + ); + } else if (typeof property === "string") { + return ( + + ); + } else { + return property; + } +}; + +/** + * Pagination component for handling page navigation. + * + * @param props - The properties object. + * @returns The rendered Pagination component. + */ +const CustomPagination = ({ + currentPage, + totalPages, + onChange, +}: { + currentPage: number; + totalPages: number; + onChange: (page: number) => void; +}) => { + const getPaginationItems = () => { + let items = []; + + // Determine start and end pages + let startPage = + currentPage == totalPages && currentPage > 3 ? + Math.max(1, currentPage - 2) : + Math.max(1, currentPage - 1); + let endPage = + currentPage == 1 ? + Math.min(totalPages, currentPage + 2) : + Math.min(totalPages, currentPage + 1); + + // Add ellipsis if needed + if (startPage > 2) { + items.push(); + } + + // Add page numbers + for (let i = startPage; i <= endPage; i++) { + items.push( + + ); + } + + // Add ellipsis after + if (endPage < totalPages - 1) { + items.push(); + } + + // Add last page + if (endPage < totalPages) { + items.push( + + ); + } + if (currentPage > 2) { + items.unshift( + + ); + } + + return items; + }; + + return ( + + + + + +
+ {getPaginationItems().map((item) => ( + + {item} + + ))} +
+ + + +
+
+ ); +}; + +/** + * PageTemplate component for displaying a paginated table with search and filter functionality. + + * @param props - The properties object. + * @returns The rendered PageTemplate component. + */ +export default function PageTemplate< + TQueryKey extends QueryKey, + TQueryFnData extends Record, + TError, + TData extends Record = TQueryFnData, +>(props: Props) { + const [filterOptions, setFilterOptions] = useState({ + page: 0, + limit: 10, + q: "", + }); + + const [debouncedSearchQuery] = useDebouncedValue(filterOptions.q, 500); + + const query = useQuery({ + ...(typeof props.queryOptions === "function" + ? props.queryOptions( + filterOptions.page, + filterOptions.limit, + debouncedSearchQuery + ) + : props.queryOptions), + placeholderData: keepPreviousData, + }); + + const table = useReactTable({ + data: query.data?.data ?? [], + columns: props.columnDefs, + getCoreRowModel: getCoreRowModel(), + defaultColumn: { + cell: (props) => ( + + {props.getValue() as ReactNode} + + ), + }, + }); + + /** + * Handles the change in search query input with debounce. + * + * @param value - The new search query value. + */ + const handleSearchQueryChange = (value: string) => { + setFilterOptions((prev) => ({ + page: 0, + limit: prev.limit, + q: value, + })); + }; + + /** + * Handles the change in page number. + * + * @param page - The new page number. + */ + const handlePageChange = (page: number) => { + setFilterOptions((prev) => ({ + page: page - 1, // Adjust for zero-based index + limit: prev.limit, + q: prev.q, + })); + }; + + return ( +
+

{props.title}

+ + {/* Table Functionality */} +
+ {/* Search and Create Button */} +
+
+ + handleSearchQueryChange(e.target.value)} + placeholder="Pencarian..." + /> +
+
+ {createCreateButton(props.createButton)} +
+
+ + {/* Table */} + + + {/* Pagination */} + {query.data && ( +
+
+ Per Halaman + +
+ +
+ + Menampilkan {query.data.data.length} dari {query.data._metadata.totalItems} + +
+
+ )} +
+ + {props.modals?.map((modal, index) => ( + {modal} + ))} +
+
+ ); +}