add: page template for aspect management
This commit is contained in:
parent
d883af0f6e
commit
d97d71fe95
360
apps/frontend/src/components/PageTemplate.tsx
Normal file
360
apps/frontend/src/components/PageTemplate.tsx
Normal file
|
|
@ -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<T extends Record<string, unknown>> = {
|
||||||
|
data: Array<T>;
|
||||||
|
_metadata: {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
perPage: number;
|
||||||
|
totalItems: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
//ref: https://x.com/TkDodo/status/1491451513264574501
|
||||||
|
type Props<
|
||||||
|
TQueryKey extends QueryKey,
|
||||||
|
TQueryFnData extends Record<string, unknown>,
|
||||||
|
TError,
|
||||||
|
TData extends Record<string, unknown> = TQueryFnData,
|
||||||
|
> = {
|
||||||
|
title: string;
|
||||||
|
createButton?: string | true | React.ReactNode;
|
||||||
|
modals?: React.ReactNode[];
|
||||||
|
queryOptions: (
|
||||||
|
page: number,
|
||||||
|
limit: number,
|
||||||
|
q?: string
|
||||||
|
) => UseQueryOptions<
|
||||||
|
PaginatedResponse<TQueryFnData>,
|
||||||
|
TError,
|
||||||
|
PaginatedResponse<TData>,
|
||||||
|
TQueryKey
|
||||||
|
>;
|
||||||
|
columnDefs: ColumnDef<any>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<any, any, any>["createButton"] = true
|
||||||
|
) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const addQuery = () => {
|
||||||
|
navigate({ to: `${window.location.pathname}`, search: { create: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (property === true) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className="gap-2"
|
||||||
|
variant={"outline"}
|
||||||
|
onClick={addQuery}
|
||||||
|
>
|
||||||
|
Tambah Data
|
||||||
|
<TbPlus />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
} else if (typeof property === "string") {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className="gap-2"
|
||||||
|
variant={"outline"}
|
||||||
|
onClick={addQuery}
|
||||||
|
>
|
||||||
|
{property}
|
||||||
|
<TbPlus />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
} 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(<PaginationEllipsis key="start-ellipsis" />);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add page numbers
|
||||||
|
for (let i = startPage; i <= endPage; i++) {
|
||||||
|
items.push(
|
||||||
|
<Button className='cursor-pointer' key={i} onClick={() => onChange(i)} variant={currentPage == i ? "outline" : "ghost"}>
|
||||||
|
{i}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add ellipsis after
|
||||||
|
if (endPage < totalPages - 1) {
|
||||||
|
items.push(<PaginationEllipsis key="end-ellipsis" />);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add last page
|
||||||
|
if (endPage < totalPages) {
|
||||||
|
items.push(
|
||||||
|
<Button className='cursor-pointer' key={totalPages} onClick={() => onChange(totalPages)} variant={"ghost"}>
|
||||||
|
{totalPages}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (currentPage > 2) {
|
||||||
|
items.unshift(
|
||||||
|
<Button className='cursor-pointer' key={1} onClick={() => onChange(1)} variant={"ghost"}>
|
||||||
|
1
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent className="flex flex-col items-center gap-4 md:flex-row">
|
||||||
|
<PaginationItem className="w-full md:w-auto">
|
||||||
|
<Button
|
||||||
|
onClick={() => onChange(Math.max(1, currentPage - 1))}
|
||||||
|
disabled={currentPage - 1 == 0 ? true : false}
|
||||||
|
className="w-full gap-2 md:w-auto"
|
||||||
|
variant={"ghost"}
|
||||||
|
>
|
||||||
|
<HiChevronLeft />
|
||||||
|
Sebelumnya
|
||||||
|
</Button>
|
||||||
|
</PaginationItem>
|
||||||
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
|
{getPaginationItems().map((item) => (
|
||||||
|
<PaginationItem key={item.key}>
|
||||||
|
{item}
|
||||||
|
</PaginationItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<PaginationItem className="w-full md:w-auto">
|
||||||
|
<Button
|
||||||
|
onClick={() => onChange(Math.min(totalPages, currentPage + 1))}
|
||||||
|
disabled={currentPage == totalPages ? true : false}
|
||||||
|
className="w-full gap-2 md:w-auto"
|
||||||
|
variant={"ghost"}
|
||||||
|
>
|
||||||
|
Selanjutnya
|
||||||
|
<HiChevronRight />
|
||||||
|
</Button>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, unknown>,
|
||||||
|
TError,
|
||||||
|
TData extends Record<string, unknown> = TQueryFnData,
|
||||||
|
>(props: Props<TQueryKey, TQueryFnData, TError, TData>) {
|
||||||
|
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) => (
|
||||||
|
<span>
|
||||||
|
{props.getValue() as ReactNode}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<div className="flex flex-col space-y-4">
|
||||||
|
<p className="text-2xl font-bold">{props.title}</p>
|
||||||
|
<Card className="p-4 border-hidden">
|
||||||
|
{/* Table Functionality */}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{/* Search and Create Button */}
|
||||||
|
<div className="flex flex-col md:flex-row lg:flex-row pb-4 justify-between gap-4">
|
||||||
|
<div className="relative w-full">
|
||||||
|
<TbSearch className="absolute top-1/2 left-3 transform -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
id="search"
|
||||||
|
name="search"
|
||||||
|
className="w-full max-w-xs pl-10"
|
||||||
|
value={filterOptions.q}
|
||||||
|
onChange={(e) => handleSearchQueryChange(e.target.value)}
|
||||||
|
placeholder="Pencarian..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
{createCreateButton(props.createButton)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<DashboardTable table={table} />
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{query.data && (
|
||||||
|
<div className="pt-4 flex flex-col md:flex-row lg:flex-row items-center justify-between gap-2">
|
||||||
|
<div className="flex flex-row lg:flex-col items-center w-fit gap-2">
|
||||||
|
<span className="block text-sm font-medium text-muted-foreground whitespace-nowrap">Per Halaman</span>
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setFilterOptions((prev) => ({
|
||||||
|
page: prev.page,
|
||||||
|
limit: parseInt(value ?? "10"),
|
||||||
|
q: prev.q,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
defaultValue="10"
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-fit p-4 gap-4">
|
||||||
|
<SelectValue placeholder="Per Page" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="5">5</SelectItem>
|
||||||
|
<SelectItem value="10">10</SelectItem>
|
||||||
|
<SelectItem value="50">50</SelectItem>
|
||||||
|
<SelectItem value="100">100</SelectItem>
|
||||||
|
<SelectItem value="500">500</SelectItem>
|
||||||
|
<SelectItem value="1000">1000</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<CustomPagination
|
||||||
|
currentPage={filterOptions.page + 1}
|
||||||
|
totalPages={query.data._metadata.totalPages}
|
||||||
|
onChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
|
Menampilkan {query.data.data.length} dari {query.data._metadata.totalItems}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{props.modals?.map((modal, index) => (
|
||||||
|
<React.Fragment key={index}>{modal}</React.Fragment>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user