dstpabuaran.com/resources/js/components/card-table.tsx

243 lines
8.1 KiB
TypeScript

import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Search,
} from 'lucide-react';
import * as React from 'react';
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 interface PaginationState {
current_page: number;
last_page: number;
per_page: number;
total: number;
}
interface CardRenderContext<TData> {
item: TData;
index: number;
isExpanded: boolean;
onToggleExpand: () => void;
}
interface CardTableProps<TData> {
data: TData[];
getItemKey: (item: TData) => number | string;
renderCard: (ctx: CardRenderContext<TData>) => React.ReactNode;
renderSubContent: (item: TData) => React.ReactNode;
expandedKeys: Set<number | string> | 'all';
onToggleExpand: (key: number | string) => void;
searchPlaceholder?: string;
searchValue?: string;
onSearchChange?: (value: string) => void;
toolbar?: React.ReactNode;
pagination?: PaginationState;
onPageChange?: (page: number) => void;
onPerPageChange?: (perPage: number) => void;
emptyText?: string;
rowClassName?: (item: TData) => string;
}
function useDebounce(callback: (value: string) => void, delay: number) {
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
return React.useCallback(
(value: string) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callback(value);
}, delay);
},
[callback, delay],
);
}
export function CardTable<TData>({
data,
getItemKey,
renderCard,
renderSubContent,
expandedKeys,
onToggleExpand,
searchPlaceholder = 'Ketikkan sesuatu...',
searchValue,
onSearchChange,
toolbar,
pagination,
onPageChange,
onPerPageChange,
emptyText = 'Tidak ada data.',
rowClassName,
}: CardTableProps<TData>) {
const [localSearch, setLocalSearch] = React.useState(searchValue ?? '');
React.useEffect(() => {
setLocalSearch(searchValue ?? '');
}, [searchValue]);
const isServerMode = !!pagination && !!onPageChange;
const handleSearchDebounced = useDebounce(
(value: string) => onSearchChange?.(value),
300,
);
function handleSearchChange(value: string) {
setLocalSearch(value);
if (isServerMode) {
handleSearchDebounced(value);
}
}
function isItemExpanded(key: number | string): boolean {
if (expandedKeys === 'all') {
return true;
}
return expandedKeys.has(key);
}
const totalPages = pagination?.last_page ?? 1;
const currentPage = pagination?.current_page ?? 1;
return (
<>
{(onSearchChange || toolbar || isServerMode) && (
<div className="flex items-center gap-2">
{onSearchChange && (
<div className="relative max-w-sm flex-1">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={searchPlaceholder}
value={localSearch}
onChange={(e) =>
handleSearchChange(e.target.value)
}
className="pl-9"
/>
</div>
)}
{toolbar}
<div className="ml-auto flex items-center gap-2">
{isServerMode && onPerPageChange && (
<Select
value={String(pagination?.per_page ?? 25)}
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>
)}
<div className="flex flex-col gap-3">
{data.length === 0 ? (
<Card>
<CardContent className="flex h-24 items-center justify-center text-muted-foreground">
{emptyText}
</CardContent>
</Card>
) : (
data.map((item, index) => {
const key = getItemKey(item);
const isExpanded = isItemExpanded(key);
return (
<div key={key} className={`flex flex-col ${rowClassName?.(item) ?? ''}`}>
{renderCard({
item,
index,
isExpanded,
onToggleExpand: () => onToggleExpand(key),
})}
{isExpanded && (
<div className="rounded-b-lg border border-t-0 bg-muted/30 p-4">
{renderSubContent(item)}
</div>
)}
</div>
);
})
)}
</div>
{isServerMode && (
<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>
)}
</>
);
}