From 19397d3b5b67fbbb4441f984654301a2ddfa5c05 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Fri, 4 Sep 2026 10:58:15 +0700 Subject: [PATCH] feat: implement DataCards component and ViewToggle; enhance material display options and pagination handling --- .../CourseClass/CourseClassController.php | 2 +- resources/js/components/data-cards.tsx | 174 ++++++++++++++++++ resources/js/components/data-table.tsx | 2 +- resources/js/components/view-toggle.tsx | 31 ++++ .../admin/academic-classes/materials/card.tsx | 81 ++++++++ .../academic-classes/materials/index.tsx | 66 +++++-- 6 files changed, 337 insertions(+), 19 deletions(-) create mode 100644 resources/js/components/data-cards.tsx create mode 100644 resources/js/components/view-toggle.tsx create mode 100644 resources/js/pages/admin/academic-classes/materials/card.tsx diff --git a/app/Http/Controllers/Admin/Manage/CourseClass/CourseClassController.php b/app/Http/Controllers/Admin/Manage/CourseClass/CourseClassController.php index c3504ee..7d4a5f5 100644 --- a/app/Http/Controllers/Admin/Manage/CourseClass/CourseClassController.php +++ b/app/Http/Controllers/Admin/Manage/CourseClass/CourseClassController.php @@ -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']), ]); } diff --git a/resources/js/components/data-cards.tsx b/resources/js/components/data-cards.tsx new file mode 100644 index 0000000..b47ea15 --- /dev/null +++ b/resources/js/components/data-cards.tsx @@ -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 { + 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({ + data, + renderCard, + getRowId, + searchKey, + searchPlaceholder = 'Ketikkan sesuatu...', + emptyText = 'Tidak ada data.', + toolbar, + pagination, + onPageChange, + onPerPageChange, + onSearchChange, + searchValue, +}: DataCardsProps) { + 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) && ( +
+ {searchKey && ( + + handleSearchChange(event.target.value) + } + className="max-w-sm" + /> + )} + {toolbar} +
+ {onPerPageChange && ( + + )} +
+
+ )} + + {data.length ? ( +
+ {data.map((item, index) => ( + + {renderCard(item, index)} + + ))} +
+ ) : ( + + + {emptyText} + + + )} + +
+ + {`Halaman ${currentPage} dari ${totalPages}`} + +
+ + + + +
+
+ + ); +} diff --git a/resources/js/components/data-table.tsx b/resources/js/components/data-table.tsx index 91bfb71..8cdec4e 100644 --- a/resources/js/components/data-table.tsx +++ b/resources/js/components/data-table.tsx @@ -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 | null>(null); return React.useCallback( diff --git a/resources/js/components/view-toggle.tsx b/resources/js/components/view-toggle.tsx new file mode 100644 index 0000000..21f538c --- /dev/null +++ b/resources/js/components/view-toggle.tsx @@ -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 ( + { + if (next) { + onChange(next as ViewMode); + } + }} + > + + + + + + + + ); +} diff --git a/resources/js/pages/admin/academic-classes/materials/card.tsx b/resources/js/pages/admin/academic-classes/materials/card.tsx new file mode 100644 index 0000000..ba2c187 --- /dev/null +++ b/resources/js/pages/admin/academic-classes/materials/card.tsx @@ -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 ( +
+
+
+ {material.meeting_number && ( + + Pertemuan {material.meeting_number} + + )} +
+ {(canUpdate || canDelete) && ( + , + show: canUpdate, + onClick: () => handleEdit(material), + }, + { + label: 'Hapus', + icon: ( + + ), + show: canDelete, + onClick: () => handleDeleteClick(material), + }, + ]} + /> + )} +
+ +
+

{material.title}

+ {material.description && ( +

+ {material.description} +

+ )} +
+ +

+ {courseClass + ? `${courseClass.course?.code ?? ''} ${courseClass.course?.name ?? ''}` + : '-'} +

+ + {material.files.length > 0 && ( +
+ {material.files.map((file) => ( + + ))} +
+ )} +
+ ); + }; +} diff --git a/resources/js/pages/admin/academic-classes/materials/index.tsx b/resources/js/pages/admin/academic-classes/materials/index.tsx index 4e7b51a..54667a4 100644 --- a/resources/js/pages/admin/academic-classes/materials/index.tsx +++ b/resources/js/pages/admin/academic-classes/materials/index.tsx @@ -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(null); const [deleting, setDeleting] = useState(null); + const [view, setView] = useState('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 = ( + <> + + + + ); + return ( <> @@ -262,23 +285,32 @@ export default function MaterialIndex({ courseClasses={courseClasses} /> - - } - /> + {view === 'table' ? ( + + ) : ( + material.id} + searchKey="title" + pagination={pagination} + onPageChange={handlePageChange} + onPerPageChange={handlePerPageChange} + onSearchChange={handleSearchChange} + searchValue={search} + toolbar={toolbar} + /> + )}