itmpwk.ac.id/resources/js/components/data-cards.tsx
Yoga Pangestu 35a64c2045 feat: implement infinite scroll for various admin pages and update pagination handling
- Refactored AdministratorController, LecturerController, and StudentController to utilize Inertia's scroll feature for paginated data.
- Enhanced DataCards and DataTable components to support infinite scrolling, allowing for a smoother user experience when navigating large datasets.
- Updated multiple admin pages (e.g., MaterialIndex, LogIndex, FeedbackIndex) to integrate infinite scroll functionality, improving data loading efficiency.
- Adjusted server table hooks to reset keys appropriately for infinite scroll scenarios.
2026-09-04 13:59:26 +07:00

229 lines
8.7 KiB
TypeScript

import { InfiniteScroll } from '@inertiajs/react';
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
} from 'lucide-react';
import * as React from 'react';
import type { PaginationState } from '@/components/data-table';
import { useDebounce } from '@/components/data-table';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export type { PaginationState } from '@/components/data-table';
interface DataCardsProps<TData> {
data: TData[];
renderCard: (item: TData, index: number) => React.ReactNode;
getRowId?: (item: TData) => string | number;
searchKey?: string;
searchPlaceholder?: string;
emptyText?: string;
toolbar?: React.ReactNode;
pagination: PaginationState;
onPageChange?: (page: number) => void;
onPerPageChange?: (perPage: number) => void;
onSearchChange?: (search: string) => void;
searchValue?: string;
/**
* Enables infinite scroll instead of page-based pagination. `propName`
* must match the Inertia page prop returned via `Inertia::scroll()` on
* the backend so the frontend can track its merge/scroll metadata.
*/
infiniteScroll?: {
propName: string;
buffer?: number;
};
}
export function DataCards<TData>({
data,
renderCard,
getRowId,
searchKey,
searchPlaceholder = 'Ketikkan sesuatu...',
emptyText = 'Tidak ada data.',
toolbar,
pagination,
onPageChange,
onPerPageChange,
onSearchChange,
searchValue,
infiniteScroll,
}: DataCardsProps<TData>) {
const isInfiniteScroll = !!infiniteScroll;
const [localSearch, setLocalSearch] = React.useState(searchValue ?? '');
const [previousSearchValue, setPreviousSearchValue] =
React.useState(searchValue);
if (searchValue !== previousSearchValue) {
setPreviousSearchValue(searchValue);
setLocalSearch(searchValue ?? '');
}
const handleSearchDebounced = useDebounce(
(value: string) => onSearchChange?.(value),
300,
);
function handleSearchChange(value: string) {
setLocalSearch(value);
handleSearchDebounced(value);
}
const currentPage = pagination.current_page;
const totalPages = pagination.last_page;
return (
<>
{(searchKey || toolbar || onPerPageChange) && (
<div className="flex items-center gap-2">
{searchKey && (
<Input
placeholder={searchPlaceholder}
value={localSearch}
onChange={(event) =>
handleSearchChange(event.target.value)
}
className="max-w-sm"
/>
)}
{toolbar}
<div className="ml-auto flex items-center gap-2">
{onPerPageChange && (
<Select
value={String(pagination.per_page)}
onValueChange={(value) =>
onPerPageChange(Number(value))
}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="999999">
Semua
</SelectItem>
</SelectContent>
</Select>
)}
</div>
</div>
)}
{data.length ? (
isInfiniteScroll ? (
<InfiniteScroll
data={infiniteScroll.propName}
as="div"
onlyNext
preserveUrl
buffer={infiniteScroll.buffer ?? 300}
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
next={({ loading, hasMore }) =>
loading ? (
<p className="py-2 text-center text-sm text-muted-foreground">
Memuat data...
</p>
) : hasMore ? null : (
<p className="py-2 text-center text-sm text-muted-foreground">
Semua data telah ditampilkan.
</p>
)
}
>
{data.map((item, index) => (
<Card
key={getRowId ? getRowId(item) : index}
className="overflow-hidden"
>
<CardContent>
{renderCard(item, index)}
</CardContent>
</Card>
))}
</InfiniteScroll>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{data.map((item, index) => (
<Card
key={getRowId ? getRowId(item) : index}
className="overflow-hidden"
>
<CardContent>
{renderCard(item, index)}
</CardContent>
</Card>
))}
</div>
)
) : (
<Card>
<CardContent className="flex h-24 items-center justify-center text-center text-muted-foreground">
{emptyText}
</CardContent>
</Card>
)}
{isInfiniteScroll ? (
<p className="text-center text-sm text-muted-foreground">
Menampilkan {data.length.toLocaleString('id-ID')} dari{' '}
{pagination.total.toLocaleString('id-ID')} data
</p>
) : (
<div className="flex items-center justify-end gap-2">
<span className="text-sm text-muted-foreground">
{`Halaman ${currentPage} dari ${totalPages}`}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange?.(1)}
disabled={currentPage <= 1}
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange?.(currentPage - 1)}
disabled={currentPage <= 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange?.(currentPage + 1)}
disabled={currentPage >= totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange?.(totalPages)}
disabled={currentPage >= totalPages}
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
);
}