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(),
|
'courses' => $this->courseService->getAllForSelect(),
|
||||||
'lecturers' => $this->lecturerService->getAllForSelect(),
|
'lecturers' => $this->lecturerService->getAllForSelect(),
|
||||||
'academicTerms' => $this->academicTermService->getAllForSelect(),
|
'academicTerms' => $this->academicTermService->getAllForSelect(),
|
||||||
'courseClassAssignments' => $this->service->getAllForSelect(),
|
'courseClassAssignments' => $this->service->getAllForSelect($request->user()),
|
||||||
'filters' => $request->only(['academic_term_id', 'method']),
|
'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);
|
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
return React.useCallback(
|
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 { Head, router } from '@inertiajs/react';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { DataCards } from '@/components/data-cards';
|
||||||
import type { PaginationState } from '@/components/data-table';
|
import type { PaginationState } from '@/components/data-table';
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||||
@ -25,6 +26,8 @@ import {
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
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 { usePermissions } from '@/hooks/use-permissions';
|
||||||
import { useServerTable } from '@/hooks/use-server-table';
|
import { useServerTable } from '@/hooks/use-server-table';
|
||||||
import {
|
import {
|
||||||
@ -34,6 +37,7 @@ import {
|
|||||||
update,
|
update,
|
||||||
} from '@/routes/admin/academic-classes/materials';
|
} from '@/routes/admin/academic-classes/materials';
|
||||||
import type { Material } from '@/types/material';
|
import type { Material } from '@/types/material';
|
||||||
|
import { createMaterialCard } from './card';
|
||||||
import { createMaterialColumns } from './columns';
|
import { createMaterialColumns } from './columns';
|
||||||
|
|
||||||
type CourseClassOption = {
|
type CourseClassOption = {
|
||||||
@ -150,6 +154,7 @@ export default function MaterialIndex({
|
|||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<Material | null>(null);
|
const [editing, setEditing] = useState<Material | null>(null);
|
||||||
const [deleting, setDeleting] = useState<Material | null>(null);
|
const [deleting, setDeleting] = useState<Material | null>(null);
|
||||||
|
const [view, setView] = useState<ViewMode>('table');
|
||||||
const { hasPermission } = usePermissions();
|
const { hasPermission } = usePermissions();
|
||||||
const canCreate = hasPermission('create-materials');
|
const canCreate = hasPermission('create-materials');
|
||||||
const canUpdate = hasPermission('update-materials');
|
const canUpdate = hasPermission('update-materials');
|
||||||
@ -200,6 +205,24 @@ export default function MaterialIndex({
|
|||||||
canDelete,
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="Materi" />
|
<Head title="Materi" />
|
||||||
@ -262,23 +285,32 @@ export default function MaterialIndex({
|
|||||||
courseClasses={courseClasses}
|
courseClasses={courseClasses}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DataTable
|
{view === 'table' ? (
|
||||||
columns={columns}
|
<DataTable
|
||||||
data={materials.data}
|
columns={columns}
|
||||||
searchKey="title"
|
data={materials.data}
|
||||||
pagination={pagination}
|
searchKey="title"
|
||||||
onPageChange={handlePageChange}
|
pagination={pagination}
|
||||||
onPerPageChange={handlePerPageChange}
|
onPageChange={handlePageChange}
|
||||||
onSearchChange={handleSearchChange}
|
onPerPageChange={handlePerPageChange}
|
||||||
searchValue={search}
|
onSearchChange={handleSearchChange}
|
||||||
toolbar={
|
searchValue={search}
|
||||||
<FilterDialog
|
toolbar={toolbar}
|
||||||
fields={filterFields}
|
/>
|
||||||
activeFilters={filters}
|
) : (
|
||||||
onApply={applyFilters}
|
<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
|
<DeleteConfirmDialog
|
||||||
target={deleting}
|
target={deleting}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user