feat: implement infinite scroll for various admin pages and update pagination handling

- Refactored AdministratorController, LecturerController, and StudentController to utilize Inertia's scroll feature for paginated data.
- Enhanced DataCards and DataTable components to support infinite scrolling, allowing for a smoother user experience when navigating large datasets.
- Updated multiple admin pages (e.g., MaterialIndex, LogIndex, FeedbackIndex) to integrate infinite scroll functionality, improving data loading efficiency.
- Adjusted server table hooks to reset keys appropriately for infinite scroll scenarios.
This commit is contained in:
Yoga Pangestu 2026-09-04 13:59:26 +07:00
parent 33f354ad94
commit 35a64c2045
32 changed files with 374 additions and 373 deletions

View File

@ -22,11 +22,11 @@ public function __construct(
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/academic-classes/materials/index', [
'materials' => $this->service->paginated(
'materials' => Inertia::scroll(fn () => $this->service->paginated(
$request->user(),
...$request->validatedWithDefaults(),
courseClassId: $request->validated('course_class_id'),
),
)),
'courseClasses' => $this->courseClassService->getAllForSelect($request->user()),
'filters' => $request->only(['course_class_id']),
]);

View File

@ -22,11 +22,11 @@ public function index(PaginatedRequest $request): Response
return Inertia::render('admin/developer/logs/index', [
'entries' => $file
? $this->service->paginated(
? Inertia::scroll(fn () => $this->service->paginated(
$file,
...$request->validatedWithDefaults(),
level: $request->validated('level'),
)
))
: null,
'files' => $files,
'selectedFile' => $file,

View File

@ -25,12 +25,12 @@ public function __construct(
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/feedback/index', [
'feedbacks' => $this->service->paginated(
'feedbacks' => Inertia::scroll(fn () => $this->service->paginated(
$request->user(),
...$request->validatedWithDefaults(),
type: $request->validated('type'),
status: $request->validated('status'),
),
)),
'types' => FeedbackType::options(),
'statuses' => FeedbackStatus::options(),
'filters' => $request->only(['type', 'status']),

View File

@ -38,12 +38,12 @@ public function index(PaginatedRequest $request): Response
$summaryTermId = $requestedTermId ?? $this->academicTermService->getActive()?->id;
return Inertia::render('admin/finances/tuition-invoices/index', [
'invoices' => $this->service->paginated(
'invoices' => Inertia::scroll(fn () => $this->service->paginated(
...$request->validatedWithDefaults(),
academicTermId: $requestedTermId,
status: $request->validated('status'),
paymentMethod: $request->validated('payment_method'),
),
)),
'summary' => $this->service->summary(
search: $request->validated('search') ?? '',
academicTermId: $summaryTermId,

View File

@ -20,10 +20,10 @@ public function __construct(
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/manage/announcements/index', [
'announcements' => $this->service->paginated(
'announcements' => Inertia::scroll(fn () => $this->service->paginated(
$request->user(),
...$request->validatedWithDefaults(),
),
)),
]);
}

View File

@ -27,11 +27,11 @@ public function __construct(
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/manage/course-classes/index', [
'courseClasses' => $this->service->paginated(
'courseClasses' => Inertia::scroll(fn () => $this->service->paginated(
...$request->validatedWithDefaults(),
academicTermId: $request->validated('academic_term_id'),
method: $request->validated('method'),
),
)),
'courses' => $this->courseService->getAllForSelect(),
'lecturers' => $this->lecturerService->getAllForSelect(),
'academicTerms' => $this->academicTermService->getAllForSelect(),

View File

@ -35,11 +35,11 @@ public function index(PaginatedRequest $request): Response|RedirectResponse
return Inertia::render('admin/manage/course-registrations/index', [
'departments' => $this->service->departmentSummary(),
'registrations' => $this->service->paginated(
'registrations' => Inertia::scroll(fn () => $this->service->paginated(
$user,
...$request->validatedWithDefaults(),
departmentId: $request->validated('department_id'),
),
)),
'filters' => $request->only(['department_id']),
]);
}

View File

@ -21,11 +21,11 @@ public function __construct(
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/master/academic-terms/index', [
'academicTerms' => $this->service->paginated(
'academicTerms' => Inertia::scroll(fn () => $this->service->paginated(
...$request->validatedWithDefaults(),
semester: $request->validated('semester'),
isActive: $request->filled('is_active') ? $request->boolean('is_active') : null,
),
)),
'filters' => $request->only(['semester', 'is_active']),
]);
}

View File

@ -22,12 +22,12 @@ public function __construct(
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/master/courses/index', [
'courses' => $this->service->paginated(
'courses' => Inertia::scroll(fn () => $this->service->paginated(
$request->user(),
...$request->validatedWithDefaults(),
departmentId: $request->validated('department_id'),
semesterNumber: $request->validated('semester_number'),
),
)),
'departments' => $this->departmentService->getAllForSelect(),
'semesterNumbers' => $this->service->getSemesterNumbers(),
'filters' => $request->only(['department_id', 'semester_number']),

View File

@ -22,7 +22,7 @@ public function __construct(
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/master/departments/index', [
'departments' => $this->service->paginated(...$request->validatedWithDefaults()),
'departments' => Inertia::scroll(fn () => $this->service->paginated(...$request->validatedWithDefaults())),
'lecturers' => $this->lecturerService->getAllForSelect(),
]);
}

View File

@ -24,11 +24,11 @@ public function __construct(
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/services/academic-advising-logs/index', [
'logs' => $this->service->paginated(
'logs' => Inertia::scroll(fn () => $this->service->paginated(
$request->user(),
...$request->validatedWithDefaults(),
lecturerId: $request->validated('lecturer_id'),
),
)),
'students' => $this->studentService->getAllForSelect(),
'lecturers' => $this->lecturerService->getAllForSelect(),
'filters' => $request->only(['lecturer_id']),

View File

@ -22,11 +22,11 @@ public function __construct(
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/services/letter-requests/index', [
'letterRequests' => $this->service->paginated(
'letterRequests' => Inertia::scroll(fn () => $this->service->paginated(
$request->user(),
...$request->validatedWithDefaults(),
status: $request->validated('status'),
),
)),
'filters' => $request->only(['status']),
]);
}

View File

@ -21,10 +21,10 @@ public function __construct(
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/users/administrators/index', [
'administrators' => $this->service->paginated(
'administrators' => Inertia::scroll(fn () => $this->service->paginated(
...$request->validatedWithDefaults(),
gender: $request->validated('gender'),
),
)),
'filters' => $request->only(['gender']),
]);
}

View File

@ -26,11 +26,11 @@ public function __construct(
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/users/lecturers/index', [
'lecturers' => $this->service->paginated(
'lecturers' => Inertia::scroll(fn () => $this->service->paginated(
...$request->validatedWithDefaults(),
gender: $request->validated('gender'),
departmentId: $request->validated('department_id'),
),
)),
'departments' => $this->departmentService->getAllForSelect(),
'filters' => $request->only(['gender', 'department_id']),
]);

View File

@ -30,14 +30,14 @@ public function __construct(
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/users/students/index', [
'students' => $this->service->paginated(
'students' => Inertia::scroll(fn () => $this->service->paginated(
$request->user(),
...$request->validatedWithDefaults(),
gender: $request->validated('gender'),
departmentId: $request->validated('department_id'),
status: $request->validated('status'),
enrollmentYear: $request->validated('enrollment_year'),
),
)),
'departments' => $this->departmentService->getAllForSelect(),
'statuses' => StudentStatus::options(),
'enrollmentYears' => $this->service->getEnrollmentYears(),

View File

@ -1,3 +1,4 @@
import { InfiniteScroll } from '@inertiajs/react';
import {
ChevronLeft,
ChevronRight,
@ -29,10 +30,19 @@ interface DataCardsProps<TData> {
emptyText?: string;
toolbar?: React.ReactNode;
pagination: PaginationState;
onPageChange: (page: number) => void;
onPageChange?: (page: number) => void;
onPerPageChange?: (perPage: number) => void;
onSearchChange?: (search: string) => void;
searchValue?: string;
/**
* Enables infinite scroll instead of page-based pagination. `propName`
* must match the Inertia page prop returned via `Inertia::scroll()` on
* the backend so the frontend can track its merge/scroll metadata.
*/
infiniteScroll?: {
propName: string;
buffer?: number;
};
}
export function DataCards<TData>({
@ -48,7 +58,9 @@ export function DataCards<TData>({
onPerPageChange,
onSearchChange,
searchValue,
infiniteScroll,
}: DataCardsProps<TData>) {
const isInfiniteScroll = !!infiniteScroll;
const [localSearch, setLocalSearch] = React.useState(searchValue ?? '');
const [previousSearchValue, setPreviousSearchValue] =
React.useState(searchValue);
@ -112,16 +124,51 @@ export function DataCards<TData>({
)}
{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>
isInfiniteScroll ? (
<InfiniteScroll
data={infiniteScroll.propName}
as="div"
onlyNext
preserveUrl
buffer={infiniteScroll.buffer ?? 300}
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
next={({ loading, hasMore }) =>
loading ? (
<p className="py-2 text-center text-sm text-muted-foreground">
Memuat data...
</p>
) : hasMore ? null : (
<p className="py-2 text-center text-sm text-muted-foreground">
Semua data telah ditampilkan.
</p>
)
}
>
{data.map((item, index) => (
<Card
key={getRowId ? getRowId(item) : index}
className="overflow-hidden"
>
<CardContent>
{renderCard(item, index)}
</CardContent>
</Card>
))}
</InfiniteScroll>
) : (
<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">
@ -130,45 +177,52 @@ export function DataCards<TData>({
</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>
{isInfiniteScroll ? (
<p className="text-center text-sm text-muted-foreground">
Menampilkan {data.length.toLocaleString('id-ID')} dari{' '}
{pagination.total.toLocaleString('id-ID')} data
</p>
) : (
<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>
</div>
)}
</>
);
}

View File

@ -12,6 +12,7 @@ import {
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { InfiniteScroll } from '@inertiajs/react';
import type { ColumnDef, ExpandedState, Row } from '@tanstack/react-table';
import {
flexRender,
@ -71,6 +72,15 @@ interface DataTableProps<TData, TValue> {
onPerPageChange?: (perPage: number) => void;
onSearchChange?: (search: string) => void;
searchValue?: string;
/**
* Enables infinite scroll instead of page-based pagination. `propName`
* must match the Inertia page prop returned via `Inertia::scroll()` on
* the backend so the frontend can track its merge/scroll metadata.
*/
infiniteScroll?: {
propName: string;
buffer?: number;
};
}
const DragHandleContext = React.createContext<{
@ -163,7 +173,10 @@ export function DataTable<TData, TValue>({
onPerPageChange,
onSearchChange,
searchValue,
infiniteScroll,
}: DataTableProps<TData, TValue>) {
const tbodyRef = React.useRef<HTMLTableSectionElement>(null);
const [expanded, setExpanded] = React.useState<ExpandedState>(() => {
if (!defaultExpanded || !data.length) {
return {};
@ -183,7 +196,9 @@ export function DataTable<TData, TValue>({
setLocalSearch(searchValue ?? '');
}, [searchValue]);
const isServerMode = !!pagination && !!onPageChange;
const isInfiniteScroll = !!infiniteScroll;
const hasServerPager = !isInfiniteScroll && !!pagination && !!onPageChange;
const hasServerSearch = !!onSearchChange;
const isSortable = !!onReorder && !!getRowId;
const handleSearchDebounced = useDebounce(
@ -200,7 +215,7 @@ export function DataTable<TData, TValue>({
header: () => <span className="block text-center">No</span>,
cell: ({ row }) => (
<span className="block text-center">
{isServerMode
{hasServerPager
? (currentPage - 1) * (pagination?.per_page ?? 25) +
row.index +
1
@ -275,14 +290,14 @@ export function DataTable<TData, TValue>({
function handleSearchChange(value: string) {
setLocalSearch(value);
if (isServerMode) {
if (hasServerSearch) {
handleSearchDebounced(value);
}
}
return (
<>
{(searchKey || toolbar || isServerMode) && (
{(searchKey || toolbar || hasServerPager || isInfiniteScroll) && (
<div className="flex items-center gap-2">
{searchKey && (
<Input
@ -296,7 +311,7 @@ export function DataTable<TData, TValue>({
)}
{toolbar}
<div className="ml-auto flex items-center gap-2">
{isServerMode && onPerPageChange && (
{hasServerPager && onPerPageChange && (
<Select
value={String(pagination?.per_page ?? 25)}
onValueChange={(value) =>
@ -349,7 +364,7 @@ export function DataTable<TData, TValue>({
</TableRow>
))}
</TableHeader>
<TableBody>
<TableBody ref={tbodyRef}>
{table.getRowModel().rows?.length ? (
isSortable ? (
<DndContext
@ -407,8 +422,7 @@ export function DataTable<TData, TValue>({
</DndContext>
) : (
(() => {
let previousGroup: string | null =
null;
let previousGroup: string | null = null;
return table
.getRowModel()
@ -445,34 +459,32 @@ export function DataTable<TData, TValue>({
>
{row
.getVisibleCells()
.map(
(cell) => (
<TableCell
key={
cell.id
}
className={
(
cell
.column
.columnDef
.meta as {
className?: string;
}
)
?.className
}
>
{flexRender(
.map((cell) => (
<TableCell
key={
cell.id
}
className={
(
cell
.column
.columnDef
.cell,
cell.getContext(),
)}
</TableCell>
),
)}
.meta as {
className?: string;
}
)
?.className
}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
{renderSubRow &&
row.getIsExpanded() && (
@ -512,88 +524,124 @@ export function DataTable<TData, TValue>({
</CardContent>
</Card>
<div className="flex items-center justify-end gap-2">
<span className="text-sm text-muted-foreground">
{isServerMode
? `Halaman ${currentPage} dari ${totalPages}`
: `Halaman ${table.getState().pagination.pageIndex + 1} dari ${table.getPageCount()}`}
</span>
<div className="flex items-center gap-1">
{isServerMode ? (
<>
<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>
</>
) : (
<>
<Button
variant="outline"
size="sm"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
table.setPageIndex(table.getPageCount() - 1)
}
disabled={!table.getCanNextPage()}
>
<ChevronsRight className="h-4 w-4" />
</Button>
</>
)}
{isInfiniteScroll ? (
<div className="flex flex-col items-center gap-1">
<span className="text-sm text-muted-foreground">
Menampilkan {data.length.toLocaleString('id-ID')} dari{' '}
{(pagination?.total ?? data.length).toLocaleString(
'id-ID',
)}{' '}
data
</span>
<InfiniteScroll
data={infiniteScroll.propName}
itemsElement={tbodyRef}
onlyNext
preserveUrl
buffer={infiniteScroll.buffer ?? 300}
next={({ loading, hasMore }) =>
loading ? (
<span className="block py-2 text-center text-sm text-muted-foreground">
Memuat data...
</span>
) : hasMore ? null : (
<span className="block py-2 text-center text-sm text-muted-foreground">
Semua data telah ditampilkan.
</span>
)
}
/>
</div>
</div>
) : (
<div className="flex items-center justify-end gap-2">
<span className="text-sm text-muted-foreground">
{hasServerPager
? `Halaman ${currentPage} dari ${totalPages}`
: `Halaman ${table.getState().pagination.pageIndex + 1} dari ${table.getPageCount()}`}
</span>
<div className="flex items-center gap-1">
{hasServerPager ? (
<>
<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>
</>
) : (
<>
<Button
variant="outline"
size="sm"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
table.setPageIndex(
table.getPageCount() - 1,
)
}
disabled={!table.getCanNextPage()}
>
<ChevronsRight className="h-4 w-4" />
</Button>
</>
)}
</div>
</div>
)}
</>
);
}

View File

@ -120,7 +120,10 @@ function CourseClassField({
<ComboboxLabel>{group.value}</ComboboxLabel>
<ComboboxCollection>
{(option: CourseClassOption) => (
<ComboboxItem key={option.id} value={option}>
<ComboboxItem
key={option.id}
value={option}
>
{courseClassLabel(option)}
</ComboboxItem>
)}
@ -176,16 +179,11 @@ export default function MaterialIndex({
total: materials.total,
};
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
const { search, handleSearchChange, applyFilters } = useServerTable({
route: () => materialIndex.url(),
pagination,
filters,
resetKeys: ['materials'],
});
function handleDelete() {
@ -291,10 +289,9 @@ export default function MaterialIndex({
data={materials.data}
searchKey="title"
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
infiniteScroll={{ propName: 'materials' }}
toolbar={toolbar}
/>
) : (
@ -304,10 +301,9 @@ export default function MaterialIndex({
getRowId={(material) => material.id}
searchKey="title"
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
infiniteScroll={{ propName: 'materials' }}
toolbar={toolbar}
/>
)}

View File

@ -83,23 +83,18 @@ export default function LogIndex({
total: entries?.total ?? 0,
};
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
const { search, handleSearchChange, applyFilters } = useServerTable({
route: () => logsIndex.url(),
pagination,
filters,
resetKeys: ['entries'],
});
function handleFileChange(file: string) {
router.get(
logsIndex.url(),
{ file, level: filters.level },
{ preserveState: true, replace: true },
{ preserveState: true, replace: true, reset: ['entries'] },
);
}
@ -154,13 +149,12 @@ export default function LogIndex({
columns={columns}
data={entries.data}
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
searchKey="message"
searchPlaceholder="Cari pesan log..."
emptyText="Tidak ada entri log yang cocok."
infiniteScroll={{ propName: 'entries' }}
toolbar={
<FilterDialog
fields={filterFields}

View File

@ -91,16 +91,11 @@ export default function FeedbackIndex({
total: feedbacks.total,
};
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
const { search, handleSearchChange, applyFilters } = useServerTable({
route: () => feedbackIndex.url(),
pagination,
filters,
resetKeys: ['feedbacks'],
});
function handleDelete() {
@ -189,11 +184,10 @@ export default function FeedbackIndex({
columns={columns}
data={feedbacks.data}
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
searchKey="subject"
infiniteScroll={{ propName: 'feedbacks' }}
toolbar={
<FilterDialog
fields={filterFields}

View File

@ -185,16 +185,11 @@ export default function TuitionInvoiceIndex({
total: invoices.total,
};
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
const { search, handleSearchChange, applyFilters } = useServerTable({
route: () => tuitionInvoiceIndex.url(),
pagination,
filters,
resetKeys: ['invoices'],
});
function handleDelete() {
@ -295,11 +290,10 @@ export default function TuitionInvoiceIndex({
columns={columns}
data={invoices.data}
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
searchKey="student"
infiniteScroll={{ propName: 'invoices' }}
toolbar={
<FilterDialog
fields={filterFields}
@ -404,7 +398,8 @@ function CreateForm({
!student.invoiced_term_ids?.includes(Number(academicTermId)),
)
: [];
const studentGroups = groupStudentsByDepartmentAndSemester(availableStudents);
const studentGroups =
groupStudentsByDepartmentAndSemester(availableStudents);
function reset() {
setAcademicTermId('');

View File

@ -46,10 +46,7 @@ type Props = {
highlight?: number;
};
export default function AnnouncementIndex({
announcements,
highlight,
}: Props) {
export default function AnnouncementIndex({ announcements, highlight }: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Announcement | null>(null);
const [deleting, setDeleting] = useState<Announcement | null>(null);
@ -66,11 +63,11 @@ export default function AnnouncementIndex({
total: announcements.total,
};
const { search, handlePageChange, handlePerPageChange, handleSearchChange } =
useServerTable({
route: () => announcementIndex.url(),
pagination,
});
const { search, handleSearchChange } = useServerTable({
route: () => announcementIndex.url(),
pagination,
resetKeys: ['announcements'],
});
function handleDelete() {
if (!deleting) {
@ -148,11 +145,10 @@ export default function AnnouncementIndex({
columns={columns}
data={announcements.data}
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
searchKey="title"
infiniteScroll={{ propName: 'announcements' }}
/>
<DeleteConfirmDialog
@ -222,15 +218,11 @@ function ViewDetailDialog({
<Label className="text-muted-foreground">
Judul
</Label>
<p className="font-medium">
{announcement.title}
</p>
<p className="font-medium">{announcement.title}</p>
</div>
<div className="grid gap-1">
<Label className="text-muted-foreground">
Isi
</Label>
<Label className="text-muted-foreground">Isi</Label>
<p className="whitespace-pre-line">
{announcement.content}
</p>
@ -279,8 +271,7 @@ function AnnouncementFields({
</div>
<div className="grid gap-2">
<Label>
Ditujukan Untuk{' '}
<span className="text-destructive">*</span>
Ditujukan Untuk <span className="text-destructive">*</span>
</Label>
<div className="flex flex-wrap gap-4">
{AnnouncementTargetRoles.map((role) => (
@ -354,10 +345,7 @@ function EditForm({
{({ errors }) =>
editing && (
<div className="grid gap-4">
<AnnouncementFields
errors={errors}
editing={editing}
/>
<AnnouncementFields errors={errors} editing={editing} />
</div>
)
}

View File

@ -168,16 +168,11 @@ export default function CourseClassIndex({
total: courseClasses.total,
};
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
const { search, handleSearchChange, applyFilters } = useServerTable({
route: () => courseClassIndex.url(),
pagination,
filters,
resetKeys: ['courseClasses'],
});
function handleDelete() {
@ -286,10 +281,9 @@ export default function CourseClassIndex({
data={courseClasses.data}
searchKey="course"
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
infiniteScroll={{ propName: 'courseClasses' }}
groupBy={(courseClass) =>
`${courseClass.academic_term ? formatAcademicTermLabel(courseClass.academic_term) : 'Tanpa Periode'} — Semester ${courseClass.course?.semester_number ?? 'Tidak ditentukan'}`
}
@ -752,8 +746,7 @@ function EditForm({
key={c.id}
value={c}
>
{c.code} -{' '}
{c.name}
{c.code} - {c.name}
</ComboboxItem>
)}
</ComboboxCollection>
@ -834,11 +827,10 @@ function DuplicateForm({
<div className="grid gap-4">
<p className="text-sm text-muted-foreground">
Menyalin semua kelas mata kuliah (mata kuliah, dosen
pengampu, metode) dari periode sumber ke periode
tujuan. Periode tujuan hanya bisa dipilih dari
semester yang sama (Ganjil ke Ganjil, Genap ke Genap).
Mata kuliah yang sudah punya kelas di periode tujuan
akan dilewati.
pengampu, metode) dari periode sumber ke periode tujuan.
Periode tujuan hanya bisa dipilih dari semester yang
sama (Ganjil ke Ganjil, Genap ke Genap). Mata kuliah
yang sudah punya kelas di periode tujuan akan dilewati.
</p>
<div className="grid gap-2">
<Label>
@ -871,9 +863,7 @@ function DuplicateForm({
))}
</SelectContent>
</Select>
<InputError
message={errors.source_academic_term_id}
/>
<InputError message={errors.source_academic_term_id} />
</div>
<div className="grid gap-2">
<Label>
@ -910,9 +900,7 @@ function DuplicateForm({
))}
</SelectContent>
</Select>
<InputError
message={errors.target_academic_term_id}
/>
<InputError message={errors.target_academic_term_id} />
</div>
</div>
)}

View File

@ -49,16 +49,11 @@ export default function CourseRegistrationIndex({
total: registrations.total,
};
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
const { search, handleSearchChange, applyFilters } = useServerTable({
route: () => index.url(),
pagination,
filters,
resetKeys: ['registrations'],
});
const columns = createCourseRegistrationColumns();
@ -74,11 +69,10 @@ export default function CourseRegistrationIndex({
columns={columns}
data={registrations.data}
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
searchKey="student"
infiniteScroll={{ propName: 'registrations' }}
groupBy={(row) =>
row.student?.department?.name ?? 'Tanpa Jurusan'
}

View File

@ -83,16 +83,11 @@ export default function AcademicTermIndex({
total: academicTerms.total,
};
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
const { search, handleSearchChange, applyFilters } = useServerTable({
route: () => academicTermIndex.url(),
pagination,
filters,
resetKeys: ['academicTerms'],
});
function handleDelete() {
@ -184,10 +179,9 @@ export default function AcademicTermIndex({
data={academicTerms.data}
searchKey="academic_year"
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
infiniteScroll={{ propName: 'academicTerms' }}
toolbar={
<FilterDialog
fields={filterFields}

View File

@ -91,16 +91,11 @@ export default function CourseIndex({
total: courses.total,
};
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
const { search, handleSearchChange, applyFilters } = useServerTable({
route: () => courseIndex.url(),
pagination,
filters,
resetKeys: ['courses'],
});
function handleDelete() {
@ -187,10 +182,9 @@ export default function CourseIndex({
data={courses.data}
searchKey="name"
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
infiniteScroll={{ propName: 'courses' }}
groupBy={(course) =>
`${course.department?.name ?? 'Tanpa Jurusan'} - Semester ${course.semester_number ?? 'Tidak ditentukan'}`
}

View File

@ -84,14 +84,10 @@ export default function DepartmentIndex({
total: departments.total,
};
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
} = useServerTable({
const { search, handleSearchChange } = useServerTable({
route: () => departmentIndex.url(),
pagination,
resetKeys: ['departments'],
});
function handleDelete() {
@ -179,10 +175,9 @@ export default function DepartmentIndex({
data={departments.data}
searchKey="name"
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
infiniteScroll={{ propName: 'departments' }}
/>
<DeleteConfirmDialog
@ -245,8 +240,8 @@ function LeadershipHistoryDialog({
>
<div>
<p className="font-medium">
{entry.lecturer?.user?.profile
?.full_name ?? 'N/A'}
{entry.lecturer?.user?.profile?.full_name ??
'N/A'}
</p>
<p className="text-sm text-muted-foreground">
{format(
@ -300,9 +295,7 @@ function KaprodiFields({
<div className="grid gap-2">
<Label>
Kaprodi{' '}
{editing && (
<span className="text-destructive">*</span>
)}
{editing && <span className="text-destructive">*</span>}
</Label>
<input
type="hidden"
@ -358,9 +351,7 @@ function KaprodiFields({
<input
type="hidden"
name="leadership_started_at"
value={
startedAt ? format(startedAt, 'yyyy-MM-dd') : ''
}
value={startedAt ? format(startedAt, 'yyyy-MM-dd') : ''}
/>
<DatePicker value={startedAt} onChange={setStartedAt} />
<InputError message={errors.leadership_started_at} />

View File

@ -105,16 +105,11 @@ export default function AcademicAdvisingLogIndex({
total: logs.total,
};
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
const { search, handleSearchChange, applyFilters } = useServerTable({
route: () => academicAdvisingLogIndex.url(),
pagination,
filters,
resetKeys: ['logs'],
});
function handleDelete() {
@ -198,11 +193,10 @@ export default function AcademicAdvisingLogIndex({
columns={columns}
data={logs.data}
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
searchKey="student"
infiniteScroll={{ propName: 'logs' }}
toolbar={
<FilterDialog
fields={filterFields}
@ -277,8 +271,8 @@ function ViewDetailDialog({
<ul className="grid gap-1">
{log.students.map((student) => (
<li key={student.id}>
{student.user?.profile
?.full_name ?? 'N/A'}{' '}
{student.user?.profile?.full_name ??
'N/A'}{' '}
&middot; {student.student_number}
</li>
))}
@ -338,8 +332,7 @@ function AdvisingLogFields({
<>
<div className="grid gap-2">
<Label>
Dosen{' '}
<span className="text-destructive">*</span>
Dosen <span className="text-destructive">*</span>
</Label>
<input
type="hidden"
@ -391,8 +384,7 @@ function AdvisingLogFields({
)
}
>
{selectedStudents.length ===
availableStudents.length &&
{selectedStudents.length === availableStudents.length &&
availableStudents.length > 0
? 'Batalkan Semua'
: 'Pilih Semua'}

View File

@ -75,16 +75,11 @@ export default function LetterRequestIndex({
total: letterRequests.total,
};
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
const { search, handleSearchChange, applyFilters } = useServerTable({
route: () => letterRequestIndex.url(),
pagination,
filters,
resetKeys: ['letterRequests'],
});
function handleDelete() {
@ -189,11 +184,10 @@ export default function LetterRequestIndex({
columns={columns}
data={letterRequests.data}
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
searchKey="student"
infiniteScroll={{ propName: 'letterRequests' }}
toolbar={
<FilterDialog
fields={filterFields}
@ -343,7 +337,10 @@ function EditForm({
{({ errors }) =>
editing && (
<div className="grid gap-4">
<LetterRequestFields errors={errors} editing={editing} />
<LetterRequestFields
errors={errors}
editing={editing}
/>
</div>
)
}

View File

@ -59,16 +59,11 @@ export default function AdministratorIndex({ administrators, filters }: Props) {
total: administrators.total,
};
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
const { search, handleSearchChange, applyFilters } = useServerTable({
route: () => administratorsIndex.url(),
pagination,
filters,
resetKeys: ['administrators'],
});
function handleDelete() {
@ -140,10 +135,9 @@ export default function AdministratorIndex({ administrators, filters }: Props) {
data={administrators.data}
searchKey="name"
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
infiniteScroll={{ propName: 'administrators' }}
toolbar={
<FilterDialog
fields={filterFields}

View File

@ -82,16 +82,11 @@ export default function LecturerIndex({
total: lecturers.total,
};
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
const { search, handleSearchChange, applyFilters } = useServerTable({
route: () => lecturersIndex.url(),
pagination,
filters,
resetKeys: ['lecturers'],
});
function handleDelete() {
@ -177,10 +172,9 @@ export default function LecturerIndex({
data={lecturers.data}
searchKey="name"
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
infiniteScroll={{ propName: 'lecturers' }}
toolbar={
<FilterDialog
fields={filterFields}

View File

@ -109,16 +109,11 @@ export default function StudentIndex({
total: students.total,
};
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
const { search, handleSearchChange, applyFilters } = useServerTable({
route: () => studentsIndex.url(),
pagination,
filters,
resetKeys: ['students'],
});
function handleDelete() {
@ -226,10 +221,9 @@ export default function StudentIndex({
data={students.data}
searchKey="name"
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
infiniteScroll={{ propName: 'students' }}
toolbar={
<FilterDialog
fields={filterFields}