feat: implement DataCards component and ViewToggle; enhance material display options and pagination handling

This commit is contained in:
Yoga Pangestu 2026-09-04 10:58:15 +07:00
parent acee293b36
commit 19397d3b5b
6 changed files with 337 additions and 19 deletions

View File

@ -35,7 +35,7 @@ public function index(PaginatedRequest $request): Response
'courses' => $this->courseService->getAllForSelect(),
'lecturers' => $this->lecturerService->getAllForSelect(),
'academicTerms' => $this->academicTermService->getAllForSelect(),
'courseClassAssignments' => $this->service->getAllForSelect(),
'courseClassAssignments' => $this->service->getAllForSelect($request->user()),
'filters' => $request->only(['academic_term_id', 'method']),
]);
}

View File

@ -0,0 +1,174 @@
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;
}
export function DataCards<TData>({
data,
renderCard,
getRowId,
searchKey,
searchPlaceholder = 'Ketikkan sesuatu...',
emptyText = 'Tidak ada data.',
toolbar,
pagination,
onPageChange,
onPerPageChange,
onSearchChange,
searchValue,
}: DataCardsProps<TData>) {
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 ? (
<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>
)}
<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>
</>
);
}

View File

@ -129,7 +129,7 @@ function SortableTableRow({
);
}
function useDebounce(callback: (value: string) => void, delay: number) {
export function useDebounce(callback: (value: string) => void, delay: number) {
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
return React.useCallback(

View File

@ -0,0 +1,31 @@
import { LayoutGrid, LayoutList } from 'lucide-react';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
export type ViewMode = 'table' | 'card';
type ViewToggleProps = {
value: ViewMode;
onChange: (value: ViewMode) => void;
};
export function ViewToggle({ value, onChange }: ViewToggleProps) {
return (
<ToggleGroup
type="single"
variant="outline"
value={value}
onValueChange={(next) => {
if (next) {
onChange(next as ViewMode);
}
}}
>
<ToggleGroupItem value="table" aria-label="Tampilan tabel">
<LayoutList className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem value="card" aria-label="Tampilan kartu">
<LayoutGrid className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
);
}

View File

@ -0,0 +1,81 @@
import { Pencil, Trash2 } from 'lucide-react';
import { AttachmentPreviewDialog } from '@/components/attachment-preview-dialog';
import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge';
import type { Material } from '@/types/material';
type CreateCardParams = {
handleEdit: (material: Material) => void;
handleDeleteClick: (material: Material) => void;
canUpdate: boolean;
canDelete: boolean;
};
export function createMaterialCard(params: CreateCardParams) {
const { handleEdit, handleDeleteClick, canUpdate, canDelete } = params;
return function MaterialCard(material: Material) {
const courseClass = material.course_class;
return (
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
{material.meeting_number && (
<Badge variant="secondary">
Pertemuan {material.meeting_number}
</Badge>
)}
</div>
{(canUpdate || canDelete) && (
<RowActions
actions={[
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
show: canUpdate,
onClick: () => handleEdit(material),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
show: canDelete,
onClick: () => handleDeleteClick(material),
},
]}
/>
)}
</div>
<div>
<p className="font-medium">{material.title}</p>
{material.description && (
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{material.description}
</p>
)}
</div>
<p className="text-sm text-muted-foreground">
{courseClass
? `${courseClass.course?.code ?? ''} ${courseClass.course?.name ?? ''}`
: '-'}
</p>
{material.files.length > 0 && (
<div className="flex flex-col gap-1">
{material.files.map((file) => (
<AttachmentPreviewDialog
key={file.id}
fileUrl={file.url}
fileName={file.name}
/>
))}
</div>
)}
</div>
);
};
}

View File

@ -1,6 +1,7 @@
import { Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useState } from 'react';
import { DataCards } from '@/components/data-cards';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
@ -25,6 +26,8 @@ import {
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import type { ViewMode } from '@/components/view-toggle';
import { ViewToggle } from '@/components/view-toggle';
import { usePermissions } from '@/hooks/use-permissions';
import { useServerTable } from '@/hooks/use-server-table';
import {
@ -34,6 +37,7 @@ import {
update,
} from '@/routes/admin/academic-classes/materials';
import type { Material } from '@/types/material';
import { createMaterialCard } from './card';
import { createMaterialColumns } from './columns';
type CourseClassOption = {
@ -150,6 +154,7 @@ export default function MaterialIndex({
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Material | null>(null);
const [deleting, setDeleting] = useState<Material | null>(null);
const [view, setView] = useState<ViewMode>('table');
const { hasPermission } = usePermissions();
const canCreate = hasPermission('create-materials');
const canUpdate = hasPermission('update-materials');
@ -200,6 +205,24 @@ export default function MaterialIndex({
canDelete,
});
const renderMaterialCard = createMaterialCard({
handleEdit: (material) => setEditing(material),
handleDeleteClick: (material) => setDeleting(material),
canUpdate,
canDelete,
});
const toolbar = (
<>
<FilterDialog
fields={filterFields}
activeFilters={filters}
onApply={applyFilters}
/>
<ViewToggle value={view} onChange={setView} />
</>
);
return (
<>
<Head title="Materi" />
@ -262,23 +285,32 @@ export default function MaterialIndex({
courseClasses={courseClasses}
/>
<DataTable
columns={columns}
data={materials.data}
searchKey="title"
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
toolbar={
<FilterDialog
fields={filterFields}
activeFilters={filters}
onApply={applyFilters}
/>
}
/>
{view === 'table' ? (
<DataTable
columns={columns}
data={materials.data}
searchKey="title"
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
toolbar={toolbar}
/>
) : (
<DataCards
data={materials.data}
renderCard={renderMaterialCard}
getRowId={(material) => material.id}
searchKey="title"
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
toolbar={toolbar}
/>
)}
<DeleteConfirmDialog
target={deleting}