feat: implement DataCards component and ViewToggle; enhance material display options and pagination handling
This commit is contained in:
parent
acee293b36
commit
19397d3b5b
@ -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']),
|
||||
]);
|
||||
}
|
||||
|
||||
174
resources/js/components/data-cards.tsx
Normal file
174
resources/js/components/data-cards.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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(
|
||||
|
||||
31
resources/js/components/view-toggle.tsx
Normal file
31
resources/js/components/view-toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
resources/js/pages/admin/academic-classes/materials/card.tsx
Normal file
81
resources/js/pages/admin/academic-classes/materials/card.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
}
|
||||
@ -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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user