feat: add filter functionality to various admin pages #45

Merged
pangestu merged 1 commits from feat/add-filter-funcionality into dev 2026-08-26 01:32:53 +08:00
46 changed files with 928 additions and 77 deletions

View File

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

View File

@ -28,11 +28,16 @@ public function __construct(
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/academic-classes/course-registrations/index', [
'registrations' => $this->service->paginated(...$request->validatedWithDefaults()),
'registrations' => $this->service->paginated(
...$request->validatedWithDefaults(),
status: $request->validated('status'),
academicTermId: $request->validated('academic_term_id'),
),
'students' => $this->studentService->getAllForSelect(),
'academicTerms' => $this->academicTermService->getAllForSelect(),
'courseClasses' => $this->courseClassService->getAllForSelect(),
'lecturers' => $this->lecturerService->getAllForSelect(),
'filters' => $request->only(['status', 'academic_term_id']),
]);
}

View File

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

View File

@ -22,8 +22,12 @@ public function __construct(
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/announcements/index', [
'announcements' => $this->service->paginated(...$request->validatedWithDefaults()),
'announcements' => $this->service->paginated(
...$request->validatedWithDefaults(),
departmentId: $request->validated('department_id'),
),
'departments' => $this->departmentService->getAllForSelect(),
'filters' => $request->only(['department_id']),
]);
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Admin;
use App\Enums\FeedbackStatus;
use App\Enums\FeedbackType;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\FeedbackRequest;
@ -22,8 +23,15 @@ public function __construct(
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/feedback/index', [
'feedbacks' => $this->service->paginated($request->user(), ...$request->validatedWithDefaults()),
'feedbacks' => $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

@ -24,9 +24,13 @@ public function __construct(
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/finances/tuition-invoices/index', [
'invoices' => $this->service->paginated(...$request->validatedWithDefaults()),
'invoices' => $this->service->paginated(
...$request->validatedWithDefaults(),
academicTermId: $request->validated('academic_term_id'),
),
'students' => $this->studentService->getAllForSelect(),
'academicTerms' => $this->academicTermService->getAllForSelect(),
'filters' => $request->only(['academic_term_id']),
]);
}

View File

@ -26,10 +26,15 @@ public function __construct(
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/manage/course-classes/index', [
'courseClasses' => $this->service->paginated(...$request->validatedWithDefaults()),
'courseClasses' => $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(),
'filters' => $request->only(['academic_term_id', 'method']),
]);
}

View File

@ -22,8 +22,12 @@ public function __construct(
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/manage/courses/index', [
'courses' => $this->service->paginated(...$request->validatedWithDefaults()),
'courses' => $this->service->paginated(
...$request->validatedWithDefaults(),
departmentId: $request->validated('department_id'),
),
'departments' => $this->departmentService->getAllForSelect(),
'filters' => $request->only(['department_id']),
]);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -22,7 +22,13 @@ public function __construct(
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/users/lecturers/index', [
'lecturers' => $this->service->paginated(...$request->validatedWithDefaults()),
'lecturers' => $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

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Admin\Users;
use App\Enums\StudentStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Users\StudentRequest;
use App\Http\Requests\PaginatedRequest;
@ -24,7 +25,15 @@ public function __construct(
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/users/students/index', [
'students' => $this->service->paginated(...$request->validatedWithDefaults()),
'students' => $this->service->paginated(
...$request->validatedWithDefaults(),
gender: $request->validated('gender'),
departmentId: $request->validated('department_id'),
status: $request->validated('status'),
),
'departments' => $this->departmentService->getAllForSelect(),
'statuses' => StudentStatus::options(),
'filters' => $request->only(['gender', 'department_id', 'status']),
]);
}

View File

@ -2,7 +2,11 @@
namespace App\Http\Requests;
use App\Enums\ClassMethod;
use App\Enums\Gender;
use App\Enums\Semester;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class PaginatedRequest extends FormRequest
{
@ -19,6 +23,16 @@ public function rules(): array
'sort' => ['nullable', 'string'],
'direction' => ['nullable', 'string', 'in:asc,desc'],
'highlight' => ['nullable', 'integer'],
'gender' => ['nullable', 'string', Rule::in(Gender::values())],
'department_id' => ['nullable', 'integer'],
'status' => ['nullable', 'string'],
'semester' => ['nullable', 'string', Rule::in(Semester::values())],
'is_active' => ['nullable', Rule::in(['true', 'false'])],
'academic_term_id' => ['nullable', 'integer'],
'method' => ['nullable', 'string', Rule::in(ClassMethod::values())],
'course_class_id' => ['nullable', 'integer'],
'lecturer_id' => ['nullable', 'integer'],
'type' => ['nullable', 'string'],
];
}

View File

@ -8,13 +8,14 @@
class AssignmentService
{
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', ?int $courseClassId = null): LengthAwarePaginator
{
return Assignment::query()
->select(['id', 'course_class_id', 'title', 'description', 'deadline'])
->withCount('submissions')
->with('courseClass.course:id,code,name')
->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%"))
->when($courseClassId, fn ($q) => $q->where('course_class_id', $courseClassId))
->orderBy($sort, $direction)
->paginate($perPage);
}

View File

@ -9,7 +9,7 @@
class CourseRegistrationService
{
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', ?string $status = null, ?int $academicTermId = null): LengthAwarePaginator
{
return CourseRegistration::query()
->select(['id', 'student_id', 'academic_term_id', 'course_class_id', 'status', 'approved_by', 'created_at'])
@ -22,6 +22,8 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
])
->when($search, fn ($q) => $q->whereHas('student', fn ($q) => $q->where('student_number', 'like', "%{$search}%")
->orWhereHas('user.profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))))
->when($status, fn ($q) => $q->where('status', $status))
->when($academicTermId, fn ($q) => $q->where('academic_term_id', $academicTermId))
->orderBy($sort, $direction)
->paginate($perPage);
}

View File

@ -8,12 +8,13 @@
class MaterialService
{
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', ?int $courseClassId = null): LengthAwarePaginator
{
return Material::query()
->select(['id', 'course_class_id', 'title', 'description', 'meeting_number'])
->with('courseClass.course:id,code,name')
->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%"))
->when($courseClassId, fn ($q) => $q->where('course_class_id', $courseClassId))
->orderBy($sort, $direction)
->paginate($perPage);
}

View File

@ -7,12 +7,13 @@
class AnnouncementService
{
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', ?int $departmentId = null): LengthAwarePaginator
{
return Announcement::query()
->select(['id', 'title', 'content', 'department_id', 'enrollment_year', 'created_by', 'created_at'])
->with(['department:id,name', 'creator.profile'])
->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%"))
->when($departmentId, fn ($q) => $q->where('department_id', $departmentId))
->orderBy($sort, $direction)
->paginate($perPage);
}

View File

@ -40,11 +40,13 @@ public function __construct()
$this->sanitizer = new HtmlSanitizer($config);
}
public function paginated(User $user, int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator
public function paginated(User $user, int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', ?string $type = null, ?string $status = null): LengthAwarePaginator
{
return Feedback::query()
->where('user_id', $user->id)
->when($search, fn ($q) => $q->where('subject', 'like', "%{$search}%"))
->when($type, fn ($q) => $q->where('type', $type))
->when($status, fn ($q) => $q->where('status', $status))
->orderBy($sort, $direction)
->paginate($perPage);
}

View File

@ -7,7 +7,7 @@
class TuitionInvoiceService
{
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', ?int $academicTermId = null): LengthAwarePaginator
{
return TuitionInvoice::query()
->select(['id', 'student_id', 'academic_term_id', 'amount_due', 'due_date'])
@ -16,6 +16,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->with(['student.user.profile', 'student.department', 'academicTerm:id,name,semester,start_date,end_date'])
->when($search, fn ($q) => $q->whereHas('student', fn ($q) => $q->where('student_number', 'like', "%{$search}%")
->orWhereHas('user.profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))))
->when($academicTermId, fn ($q) => $q->where('academic_term_id', $academicTermId))
->orderBy($sort, $direction)
->paginate($perPage);
}

View File

@ -15,7 +15,7 @@ public function getAllForSelect(): Collection
->get();
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', ?int $academicTermId = null, ?string $method = null): LengthAwarePaginator
{
return CourseClass::query()
->select(['id', 'course_id', 'lecturer_id', 'academic_term_id', 'class_name', 'method'])
@ -23,6 +23,8 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->with(['course:id,code,name,department_id', 'lecturer.user.profile', 'academicTerm:id,name,semester,start_date,end_date'])
->when($search, fn ($q) => $q->where('class_name', 'like', "%{$search}%")
->orWhereHas('course', fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('code', 'like', "%{$search}%")))
->when($academicTermId, fn ($q) => $q->where('academic_term_id', $academicTermId))
->when($method, fn ($q) => $q->where('method', $method))
->orderBy($sort, $direction)
->paginate($perPage);
}

View File

@ -13,12 +13,13 @@ public function getAllForSelect(): Collection
return Course::select(['id', 'code', 'name', 'department_id'])->get();
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', ?int $departmentId = null): LengthAwarePaginator
{
return Course::query()
->select(['id', 'code', 'name', 'credits', 'department_id', 'semester_number'])
->with('department:id,name')
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('code', 'like', "%{$search}%"))
->when($departmentId, fn ($q) => $q->where('department_id', $departmentId))
->orderBy($sort, $direction)
->paginate($perPage);
}

View File

@ -13,11 +13,13 @@ public function getAllForSelect(): Collection
return AcademicTerm::select(['id', 'name', 'semester', 'start_date', 'end_date', 'is_active'])->latest()->get();
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', ?string $semester = null, ?bool $isActive = null): LengthAwarePaginator
{
return AcademicTerm::query()
->select(['id', 'name', 'semester', 'start_date', 'end_date', 'is_active'])
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%"))
->when($semester, fn ($q) => $q->where('semester', $semester))
->when($isActive !== null, fn ($q) => $q->where('is_active', $isActive))
->orderBy($sort, $direction)
->paginate($perPage);
}

View File

@ -7,7 +7,7 @@
class AcademicAdvisingLogService
{
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', ?int $lecturerId = null): LengthAwarePaginator
{
return AcademicAdvisingLog::query()
->select(['id', 'student_id', 'lecturer_id', 'topic', 'notes', 'session_date', 'created_at'])
@ -19,6 +19,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->when($search, fn ($q) => $q->where('topic', 'like', "%{$search}%")
->orWhereHas('student', fn ($q) => $q->where('student_number', 'like', "%{$search}%")
->orWhereHas('user.profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))))
->when($lecturerId, fn ($q) => $q->where('lecturer_id', $lecturerId))
->orderBy($sort, $direction)
->paginate($perPage);
}

View File

@ -9,7 +9,7 @@
class LetterRequestService
{
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', ?string $status = null): LengthAwarePaginator
{
return LetterRequest::query()
->select(['id', 'student_id', 'letter_type', 'purpose', 'status', 'processed_by', 'submitted_at', 'completed_at'])
@ -17,6 +17,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->when($search, fn ($q) => $q->where('letter_type', 'like', "%{$search}%")
->orWhereHas('student', fn ($q) => $q->where('student_number', 'like', "%{$search}%")
->orWhereHas('user.profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))))
->when($status, fn ($q) => $q->where('status', $status))
->orderBy($sort, $direction)
->paginate($perPage);
}

View File

@ -9,7 +9,7 @@
class AdministratorService
{
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', ?string $gender = null): LengthAwarePaginator
{
return User::with(['profile', 'roles'])
->whereHas('roles', fn ($q) => $q->whereIn('name', ['staff-admin', 'staff-keuangan']))
@ -18,6 +18,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->orWhere('email', 'like', "%{$search}%")
->orWhereHas('profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"));
}))
->when($gender, fn ($q) => $q->whereHas('profile', fn ($q) => $q->where('gender', $gender)))
->orderBy($sort, $direction)
->paginate($perPage);
}

View File

@ -16,7 +16,7 @@ public function getAllForSelect(): Collection
return Lecturer::with('user.profile')->get();
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', ?string $gender = null, ?int $departmentId = null): LengthAwarePaginator
{
return User::with(['profile', 'lecturer.department'])
->whereHas('roles', fn ($q) => $q->where('name', 'dosen'))
@ -26,6 +26,8 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->orWhereHas('profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))
->orWhereHas('lecturer', fn ($q) => $q->where('lecturer_number', 'like', "%{$search}%"));
}))
->when($gender, fn ($q) => $q->whereHas('profile', fn ($q) => $q->where('gender', $gender)))
->when($departmentId, fn ($q) => $q->whereHas('lecturer', fn ($q) => $q->where('department_id', $departmentId)))
->orderBy($sort, $direction)
->paginate($perPage);
}

View File

@ -16,7 +16,7 @@ public function getAllForSelect(): Collection
return Student::with(['user.profile', 'department'])->get();
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', ?string $gender = null, ?int $departmentId = null, ?string $status = null): LengthAwarePaginator
{
return User::with(['profile', 'student.department'])
->whereHas('roles', fn ($q) => $q->where('name', 'mahasiswa'))
@ -26,6 +26,9 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->orWhereHas('profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))
->orWhereHas('student', fn ($q) => $q->where('student_number', 'like', "%{$search}%"));
}))
->when($gender, fn ($q) => $q->whereHas('profile', fn ($q) => $q->where('gender', $gender)))
->when($departmentId, fn ($q) => $q->whereHas('student', fn ($q) => $q->where('department_id', $departmentId)))
->when($status, fn ($q) => $q->whereHas('student', fn ($q) => $q->where('status', $status)))
->orderBy($sort, $direction)
->paginate($perPage);
}

View File

@ -205,7 +205,88 @@ ## 5. Service ✅
---
## 6. Wayfinder ✅
## 6. Filter dropdown di halaman listing ✅
Tombol filter (ikon corong di samping search box) memakai dialog, bukan
popover — komponennya `resources/js/components/filter-dialog.tsx`
(`<FilterDialog fields={...} activeFilters={filters} onApply={applyFilters} />`
dilewatkan lewat prop `toolbar` milik `<DataTable>`, otomatis nongol di
sebelah kanan search box).
Sudah diterapkan di semua halaman listing yang punya field layak difilter:
| Halaman | Field filter |
| --------------------------- | -------------------------------- |
| Administrator/Dosen/Mahasiswa | Jenis Kelamin (+ Jurusan, Status untuk Dosen/Mahasiswa) |
| Periode Akademik | Semester, Status Aktif |
| Registrasi KRS | Status, Periode Akademik |
| Materi, Tugas | Kelas |
| Mata Kuliah | Jurusan |
| Kelas Mata Kuliah | Periode Akademik, Metode |
| Pengumuman | Jurusan |
| Tagihan | Periode Akademik |
| Surat Permohonan | Status |
| Bimbingan Akademik | Dosen |
| Kritik dan Saran | Jenis, Status |
Halaman tanpa field yang layak difilter (mis. Jurusan/Master — tabelnya kecil,
tidak butuh filter) sengaja tidak diberi `FilterDialog`.
**Perilaku UI (jangan diubah tanpa alasan kuat):**
- **Langsung diterapkan** — begitu satu field di dalam dialog dipilih
(`onValueChange`), filter langsung jalan (navigasi Inertia), tidak ada
tombol "Terapkan" terpisah yang harus diklik dulu.
- **Field filter disusun 2 kolom per baris** (`grid grid-cols-2 gap-4`) kalau
field-nya lebih dari satu; kalau cuma 1 field, 1 kolom saja (`grid gap-4`)
— jangan sisakan slot kosong di grid.
- **Tombol "Hapus Filter" ada DI LUAR dialog**, sejajar di samping tombol
ikon filter (bukan di footer dialog), berupa tombol teks (`variant="ghost"`
+ label "Hapus Filter"), dan cuma muncul kalau ada filter yang aktif.
**Backend:**
1. Field filter (mis. `gender`, `department_id`, `status`) didaftarkan di
`App\Http\Requests\PaginatedRequest::rules()` sebagai `nullable` — request
ini dipakai bersama oleh semua halaman listing, jadi field filter yang
sifatnya umum (dipakai lebih dari satu fitur) taruh di sini alih-alih
bikin FormRequest baru per halaman.
2. `Service::paginated()` menerima parameter filter tambahan sebagai
parameter bernama opsional di akhir signature (setelah
`$perPage/$search/$sort/$direction`), mis.
`paginated(..., ?string $gender = null, ?int $departmentId = null)`, lalu
diterapkan dengan `->when($gender, fn ($q) => ...)`.
3. Controller memanggilnya secara **eksplisit per parameter** — jangan
nge-spread seluruh `$request->validated()` mentah-mentah ke
`paginated()`, karena tidak semua Service menerima semua field filter
(bisa error "Unknown named parameter"):
```php
'students' => $this->service->paginated(
...$request->validatedWithDefaults(),
gender: $request->validated('gender'),
departmentId: $request->validated('department_id'),
status: $request->validated('status'),
),
'filters' => $request->only(['gender', 'department_id', 'status']),
```
**Frontend:**
1. `Props.filters` menampung nilai filter yang sedang aktif (dari query
string, dikirim controller lewat `$request->only([...])`).
2. `useServerTable({..., filters})` — prop `filters` diteruskan apa adanya
dari `Props.filters` (bukan `useState` terpisah, karena Inertia sudah
selalu mengirim prop terbaru setiap navigasi).
3. Definisikan `filterFields: FilterField[]` (key, label, options) sesuai
data yang tersedia di halaman itu (mis. `departments` dari prop untuk
Select jurusan), lalu render
`<FilterDialog fields={filterFields} activeFilters={filters} onApply={applyFilters} />`
sebagai `toolbar` di `<DataTable>`.
---
## 7. Wayfinder ✅
Selalu jalankan generate dengan flag form variant, supaya `<Form
{...Controller.method.form()}>` tidak error saat runtime:

View File

@ -0,0 +1,136 @@
import { Filter, X } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export type FilterOption = {
value: string;
label: string;
};
export type FilterField = {
key: string;
label: string;
placeholder?: string;
options: FilterOption[];
};
type FilterDialogProps = {
fields: FilterField[];
activeFilters: Record<string, string | undefined>;
onApply: (filters: Record<string, string>) => void;
};
export function FilterDialog({
fields,
activeFilters,
onApply,
}: FilterDialogProps) {
const [open, setOpen] = useState(false);
const activeCount = fields.filter(
(field) => activeFilters[field.key],
).length;
function handleChange(key: string, value: string) {
const next: Record<string, string> = {};
fields.forEach((field) => {
const current =
field.key === key ? value : activeFilters[field.key];
if (current && current !== 'all') {
next[field.key] = current;
}
});
onApply(next);
}
function handleClear() {
onApply({});
}
return (
<div className="flex items-center gap-2">
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="relative">
<Filter className="h-4 w-4" />
{activeCount > 0 && (
<span className="absolute -top-1.5 -right-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground">
{activeCount}
</span>
)}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Filter</DialogTitle>
</DialogHeader>
<div
className={
fields.length > 1
? 'grid grid-cols-2 gap-4 py-4'
: 'grid gap-4 py-4'
}
>
{fields.map((field) => (
<div className="grid gap-2" key={field.key}>
<Label>{field.label}</Label>
<Select
value={activeFilters[field.key] ?? 'all'}
onValueChange={(value) =>
handleChange(field.key, value)
}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={
field.placeholder ?? 'Semua'
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
Semua
</SelectItem>
{field.options.map((option) => (
<SelectItem
key={option.value}
value={option.value}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
</DialogContent>
</Dialog>
{activeCount > 0 && (
<Button variant="ghost" size="sm" onClick={handleClear}>
<X className="h-4 w-4" />
Hapus Filter
</Button>
)}
</div>
);
}

View File

@ -1,6 +1,6 @@
import type { PaginationState } from '@/components/data-table';
import { router } from '@inertiajs/react';
import { useCallback, useState } from 'react';
import type { PaginationState } from '@/components/data-table';
type UseServerTableOptions = {
route: () => string;
@ -80,11 +80,26 @@ export function useServerTable({
route(),
filterWithParams
? {
...newFilters,
page: 1,
per_page: pagination.per_page,
search,
}
...newFilters,
page: 1,
per_page: pagination.per_page,
search,
}
: newFilters,
{ preserveState: true, replace: true },
);
}
function applyFilters(newFilters: Record<string, string | undefined>) {
router.get(
route(),
filterWithParams
? {
...newFilters,
page: 1,
per_page: pagination.per_page,
search,
}
: newFilters,
{ preserveState: true, replace: true },
);
@ -95,10 +110,10 @@ export function useServerTable({
route(),
filterWithParams
? {
page: 1,
per_page: pagination.per_page,
search,
}
page: 1,
per_page: pagination.per_page,
search,
}
: {},
{ preserveState: true, replace: true },
);
@ -113,6 +128,7 @@ export function useServerTable({
handlePerPageChange,
handleSearchChange,
applyFilter,
applyFilters,
clearFilters,
};
}

View File

@ -6,6 +6,8 @@ import { DataTable } from '@/components/data-table';
import { DateTimeField } from '@/components/datetime-field';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { FileUploadField } from '@/components/file-upload-field';
import type { FilterField } from '@/components/filter-dialog';
import { FilterDialog } from '@/components/filter-dialog';
import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
@ -46,6 +48,9 @@ type Props = {
};
courseClasses: CourseClassOption[];
highlight?: number;
filters: {
course_class_id?: string;
};
};
function courseClassLabel(courseClass: CourseClassOption): string {
@ -60,11 +65,23 @@ export default function AssignmentIndex({
assignments,
courseClasses,
highlight,
filters,
}: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Assignment | null>(null);
const [deleting, setDeleting] = useState<Assignment | null>(null);
const filterFields: FilterField[] = [
{
key: 'course_class_id',
label: 'Kelas',
options: courseClasses.map((courseClass) => ({
value: String(courseClass.id),
label: courseClassLabel(courseClass),
})),
},
];
const pagination: PaginationState = {
current_page: assignments.current_page,
last_page: assignments.last_page,
@ -77,9 +94,11 @@ export default function AssignmentIndex({
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
route: () => assignmentIndex.url(),
pagination,
filters,
});
function handleDelete() {
@ -168,6 +187,13 @@ export default function AssignmentIndex({
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
toolbar={
<FilterDialog
fields={filterFields}
activeFilters={filters}
onApply={applyFilters}
/>
}
/>
<DeleteConfirmDialog

View File

@ -4,6 +4,8 @@ import { useState } from 'react';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import type { FilterField } from '@/components/filter-dialog';
import { FilterDialog } from '@/components/filter-dialog';
import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
@ -54,6 +56,10 @@ type Props = {
courseClasses: CourseRegistrationCourseClass[];
lecturers: LecturerOption[];
highlight?: number;
filters: {
status?: string;
academic_term_id?: string;
};
};
function studentLabel(student: CourseRegistrationStudent): string {
@ -78,11 +84,31 @@ export default function CourseRegistrationIndex({
courseClasses,
lecturers,
highlight,
filters,
}: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<CourseRegistration | null>(null);
const [deleting, setDeleting] = useState<CourseRegistration | null>(null);
const filterFields: FilterField[] = [
{
key: 'status',
label: 'Status',
options: RegistrationStatuses.map((status) => ({
value: status,
label: RegistrationStatusLabels[status],
})),
},
{
key: 'academic_term_id',
label: 'Periode Akademik',
options: academicTerms.map((term) => ({
value: String(term.id),
label: term.name,
})),
},
];
const pagination: PaginationState = {
current_page: registrations.current_page,
last_page: registrations.last_page,
@ -95,9 +121,11 @@ export default function CourseRegistrationIndex({
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
route: () => courseRegistrationIndex.url(),
pagination,
filters,
});
function handleDelete() {
@ -177,6 +205,13 @@ export default function CourseRegistrationIndex({
searchValue={search}
searchKey="student"
searchPlaceholder="Cari nama atau NIM mahasiswa..."
toolbar={
<FilterDialog
fields={filterFields}
activeFilters={filters}
onApply={applyFilters}
/>
}
/>
<DeleteConfirmDialog

View File

@ -5,6 +5,8 @@ import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { FileUploadField } from '@/components/file-upload-field';
import type { FilterField } from '@/components/filter-dialog';
import { FilterDialog } from '@/components/filter-dialog';
import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
@ -45,6 +47,9 @@ type Props = {
};
courseClasses: CourseClassOption[];
highlight?: number;
filters: {
course_class_id?: string;
};
};
function courseClassLabel(courseClass: CourseClassOption): string {
@ -59,11 +64,23 @@ export default function MaterialIndex({
materials,
courseClasses,
highlight,
filters,
}: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Material | null>(null);
const [deleting, setDeleting] = useState<Material | null>(null);
const filterFields: FilterField[] = [
{
key: 'course_class_id',
label: 'Kelas',
options: courseClasses.map((courseClass) => ({
value: String(courseClass.id),
label: courseClassLabel(courseClass),
})),
},
];
const pagination: PaginationState = {
current_page: materials.current_page,
last_page: materials.last_page,
@ -76,9 +93,11 @@ export default function MaterialIndex({
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
route: () => materialIndex.url(),
pagination,
filters,
});
function handleDelete() {
@ -167,6 +186,13 @@ export default function MaterialIndex({
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
toolbar={
<FilterDialog
fields={filterFields}
activeFilters={filters}
onApply={applyFilters}
/>
}
/>
<DeleteConfirmDialog

View File

@ -4,6 +4,8 @@ import { useRef, useState } from 'react';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import type { FilterField } from '@/components/filter-dialog';
import { FilterDialog } from '@/components/filter-dialog';
import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
@ -41,17 +43,32 @@ type Props = {
};
departments: AnnouncementDepartment[];
highlight?: number;
filters: {
department_id?: string;
};
};
export default function AnnouncementIndex({
announcements,
departments,
highlight,
filters,
}: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Announcement | null>(null);
const [deleting, setDeleting] = useState<Announcement | null>(null);
const filterFields: FilterField[] = [
{
key: 'department_id',
label: 'Jurusan',
options: departments.map((department) => ({
value: String(department.id),
label: department.name,
})),
},
];
const pagination: PaginationState = {
current_page: announcements.current_page,
last_page: announcements.last_page,
@ -64,9 +81,11 @@ export default function AnnouncementIndex({
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
route: () => announcementIndex.url(),
pagination,
filters,
});
function handleDelete() {
@ -140,6 +159,13 @@ export default function AnnouncementIndex({
searchValue={search}
searchKey="title"
searchPlaceholder="Cari judul pengumuman..."
toolbar={
<FilterDialog
fields={filterFields}
activeFilters={filters}
onApply={applyFilters}
/>
}
/>
<DeleteConfirmDialog

View File

@ -4,6 +4,8 @@ import { useState } from 'react';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import type { FilterField } from '@/components/filter-dialog';
import { FilterDialog } from '@/components/filter-dialog';
import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
@ -39,13 +41,36 @@ type Props = {
total: number;
};
types: FeedbackTypeOption[];
statuses: FeedbackTypeOption[];
filters: {
type?: string;
status?: string;
};
};
export default function FeedbackIndex({ feedbacks, types }: Props) {
export default function FeedbackIndex({
feedbacks,
types,
statuses,
filters,
}: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Feedback | null>(null);
const [deleting, setDeleting] = useState<Feedback | null>(null);
const filterFields: FilterField[] = [
{
key: 'type',
label: 'Jenis',
options: types,
},
{
key: 'status',
label: 'Status',
options: statuses,
},
];
const pagination: PaginationState = {
current_page: feedbacks.current_page,
last_page: feedbacks.last_page,
@ -58,9 +83,11 @@ export default function FeedbackIndex({ feedbacks, types }: Props) {
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
route: () => feedbackIndex.url(),
pagination,
filters,
});
function handleDelete() {
@ -133,6 +160,13 @@ export default function FeedbackIndex({ feedbacks, types }: Props) {
searchValue={search}
searchKey="subject"
searchPlaceholder="Cari subjek..."
toolbar={
<FilterDialog
fields={filterFields}
activeFilters={filters}
onApply={applyFilters}
/>
}
/>
<DeleteConfirmDialog

View File

@ -6,6 +6,8 @@ import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DatePicker } from '@/components/date-picker';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import type { FilterField } from '@/components/filter-dialog';
import { FilterDialog } from '@/components/filter-dialog';
import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
@ -45,6 +47,9 @@ type Props = {
students: TuitionInvoiceStudent[];
academicTerms: AcademicTermOption[];
highlight?: number;
filters: {
academic_term_id?: string;
};
};
function studentLabel(student: TuitionInvoiceStudent): string {
@ -56,11 +61,23 @@ export default function TuitionInvoiceIndex({
students,
academicTerms,
highlight,
filters,
}: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<TuitionInvoice | null>(null);
const [deleting, setDeleting] = useState<TuitionInvoice | null>(null);
const filterFields: FilterField[] = [
{
key: 'academic_term_id',
label: 'Periode Akademik',
options: academicTerms.map((term) => ({
value: String(term.id),
label: term.name,
})),
},
];
const pagination: PaginationState = {
current_page: invoices.current_page,
last_page: invoices.last_page,
@ -73,9 +90,11 @@ export default function TuitionInvoiceIndex({
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
route: () => tuitionInvoiceIndex.url(),
pagination,
filters,
});
function handleDelete() {
@ -151,6 +170,13 @@ export default function TuitionInvoiceIndex({
searchValue={search}
searchKey="student"
searchPlaceholder="Cari nama atau NIM mahasiswa..."
toolbar={
<FilterDialog
fields={filterFields}
activeFilters={filters}
onApply={applyFilters}
/>
}
/>
<DeleteConfirmDialog

View File

@ -4,6 +4,8 @@ import { useState } from 'react';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import type { FilterField } from '@/components/filter-dialog';
import { FilterDialog } from '@/components/filter-dialog';
import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
@ -49,6 +51,10 @@ type Props = {
lecturers: Lecturer[];
academicTerms: AcademicTerm[];
highlight?: number;
filters: {
academic_term_id?: string;
method?: string;
};
};
export default function CourseClassIndex({
@ -57,11 +63,31 @@ export default function CourseClassIndex({
lecturers,
academicTerms,
highlight,
filters,
}: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<CourseClass | null>(null);
const [deleting, setDeleting] = useState<CourseClass | null>(null);
const filterFields: FilterField[] = [
{
key: 'academic_term_id',
label: 'Periode Akademik',
options: academicTerms.map((term) => ({
value: String(term.id),
label: term.name,
})),
},
{
key: 'method',
label: 'Metode',
options: ClassMethods.map((method) => ({
value: method,
label: ClassMethodLabels[method],
})),
},
];
const pagination: PaginationState = {
current_page: courseClasses.current_page,
last_page: courseClasses.last_page,
@ -74,9 +100,11 @@ export default function CourseClassIndex({
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
route: () => courseClassIndex.url(),
pagination,
filters,
});
function handleDelete() {
@ -169,6 +197,13 @@ export default function CourseClassIndex({
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
toolbar={
<FilterDialog
fields={filterFields}
activeFilters={filters}
onApply={applyFilters}
/>
}
/>
<DeleteConfirmDialog

View File

@ -4,6 +4,8 @@ import { useState } from 'react';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import type { FilterField } from '@/components/filter-dialog';
import { FilterDialog } from '@/components/filter-dialog';
import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
@ -39,17 +41,32 @@ type Props = {
};
departments: Department[];
highlight?: number;
filters: {
department_id?: string;
};
};
export default function CourseIndex({
courses,
departments,
highlight,
filters,
}: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Course | null>(null);
const [deleting, setDeleting] = useState<Course | null>(null);
const filterFields: FilterField[] = [
{
key: 'department_id',
label: 'Jurusan',
options: departments.map((department) => ({
value: String(department.id),
label: department.name,
})),
},
];
const pagination: PaginationState = {
current_page: courses.current_page,
last_page: courses.last_page,
@ -62,9 +79,11 @@ export default function CourseIndex({
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
route: () => courseIndex.url(),
pagination,
filters,
});
function handleDelete() {
@ -153,6 +172,13 @@ export default function CourseIndex({
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
toolbar={
<FilterDialog
fields={filterFields}
activeFilters={filters}
onApply={applyFilters}
/>
}
/>
<DeleteConfirmDialog

View File

@ -5,6 +5,8 @@ import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DatePicker } from '@/components/date-picker';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import type { FilterField } from '@/components/filter-dialog';
import { FilterDialog } from '@/components/filter-dialog';
import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
@ -33,9 +35,36 @@ type Props = {
total: number;
};
highlight?: number;
filters: {
semester?: string;
is_active?: string;
};
};
export default function AcademicTermIndex({ academicTerms, highlight }: Props) {
const filterFields: FilterField[] = [
{
key: 'semester',
label: 'Semester',
options: Object.values(Semester).map((value) => ({
value,
label: SemesterLabels[value],
})),
},
{
key: 'is_active',
label: 'Status',
options: [
{ value: 'true', label: 'Aktif' },
{ value: 'false', label: 'Tidak Aktif' },
],
},
];
export default function AcademicTermIndex({
academicTerms,
highlight,
filters,
}: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<AcademicTerm | null>(null);
const [deleting, setDeleting] = useState<AcademicTerm | null>(null);
@ -52,9 +81,11 @@ export default function AcademicTermIndex({ academicTerms, highlight }: Props) {
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
route: () => academicTermIndex.url(),
pagination,
filters,
});
function handleDelete() {
@ -114,10 +145,7 @@ export default function AcademicTermIndex({ academicTerms, highlight }: Props) {
}
/>
<CreateForm
open={createOpen}
onOpenChange={setCreateOpen}
/>
<CreateForm open={createOpen} onOpenChange={setCreateOpen} />
<EditForm
key={editing?.id}
@ -141,6 +169,13 @@ export default function AcademicTermIndex({ academicTerms, highlight }: Props) {
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
toolbar={
<FilterDialog
fields={filterFields}
activeFilters={filters}
onApply={applyFilters}
/>
}
/>
<DeleteConfirmDialog
@ -207,9 +242,18 @@ function CreateForm({
className="flex flex-row gap-4"
>
{Object.values(Semester).map((value) => (
<div key={value} className="flex items-center gap-2">
<RadioGroupItem value={value} id={`create-${value}`} />
<Label htmlFor={`create-${value}`} className="font-normal">
<div
key={value}
className="flex items-center gap-2"
>
<RadioGroupItem
value={value}
id={`create-${value}`}
/>
<Label
htmlFor={`create-${value}`}
className="font-normal"
>
{SemesterLabels[value]}
</Label>
</div>
@ -220,9 +264,18 @@ function CreateForm({
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label>
Tanggal Mulai <span className="text-destructive">*</span>
Tanggal Mulai{' '}
<span className="text-destructive">*</span>
</Label>
<input type="hidden" name="start_date" value={startDate ? startDate.toISOString().split('T')[0] : ''} />
<input
type="hidden"
name="start_date"
value={
startDate
? startDate.toISOString().split('T')[0]
: ''
}
/>
<DatePicker
value={startDate}
onChange={setStartDate}
@ -231,8 +284,19 @@ function CreateForm({
<InputError message={errors.start_date} />
</div>
<div className="grid gap-2">
<Label>Tanggal Selesai <span className="text-destructive">*</span></Label>
<input type="hidden" name="end_date" value={endDate ? endDate.toISOString().split('T')[0] : ''} />
<Label>
Tanggal Selesai{' '}
<span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="end_date"
value={
endDate
? endDate.toISOString().split('T')[0]
: ''
}
/>
<DatePicker
value={endDate}
onChange={setEndDate}
@ -286,8 +350,7 @@ function EditForm({
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="edit-name">
Nama{' '}
<span className="text-destructive">*</span>
Nama <span className="text-destructive">*</span>
</Label>
<Input
id="edit-name"
@ -299,7 +362,8 @@ function EditForm({
</div>
<div className="grid gap-2">
<Label>
Semester <span className="text-destructive">*</span>
Semester{' '}
<span className="text-destructive">*</span>
</Label>
<RadioGroup
name="semester"
@ -307,9 +371,18 @@ function EditForm({
className="flex flex-row gap-4"
>
{Object.values(Semester).map((value) => (
<div key={value} className="flex items-center gap-2">
<RadioGroupItem value={value} id={`edit-${value}`} />
<Label htmlFor={`edit-${value}`} className="font-normal">
<div
key={value}
className="flex items-center gap-2"
>
<RadioGroupItem
value={value}
id={`edit-${value}`}
/>
<Label
htmlFor={`edit-${value}`}
className="font-normal"
>
{SemesterLabels[value]}
</Label>
</div>
@ -319,8 +392,21 @@ function EditForm({
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label>Tanggal Mulai <span className="text-destructive">*</span></Label>
<input type="hidden" name="start_date" value={startDate ? startDate.toISOString().split('T')[0] : ''} />
<Label>
Tanggal Mulai{' '}
<span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="start_date"
value={
startDate
? startDate
.toISOString()
.split('T')[0]
: ''
}
/>
<DatePicker
value={startDate}
onChange={setStartDate}
@ -329,8 +415,21 @@ function EditForm({
<InputError message={errors.start_date} />
</div>
<div className="grid gap-2">
<Label>Tanggal Selesai <span className="text-destructive">*</span></Label>
<input type="hidden" name="end_date" value={endDate ? endDate.toISOString().split('T')[0] : ''} />
<Label>
Tanggal Selesai{' '}
<span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="end_date"
value={
endDate
? endDate
.toISOString()
.split('T')[0]
: ''
}
/>
<DatePicker
value={endDate}
onChange={setEndDate}

View File

@ -5,6 +5,8 @@ import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DateTimeField } from '@/components/datetime-field';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import type { FilterField } from '@/components/filter-dialog';
import { FilterDialog } from '@/components/filter-dialog';
import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
@ -44,6 +46,9 @@ type Props = {
students: AcademicAdvisingLogStudent[];
lecturers: AcademicAdvisingLogLecturer[];
highlight?: number;
filters: {
lecturer_id?: string;
};
};
function studentLabel(student: AcademicAdvisingLogStudent): string {
@ -59,11 +64,23 @@ export default function AcademicAdvisingLogIndex({
students,
lecturers,
highlight,
filters,
}: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<AcademicAdvisingLog | null>(null);
const [deleting, setDeleting] = useState<AcademicAdvisingLog | null>(null);
const filterFields: FilterField[] = [
{
key: 'lecturer_id',
label: 'Dosen',
options: lecturers.map((lecturer) => ({
value: String(lecturer.id),
label: lecturerLabel(lecturer),
})),
},
];
const pagination: PaginationState = {
current_page: logs.current_page,
last_page: logs.last_page,
@ -76,9 +93,11 @@ export default function AcademicAdvisingLogIndex({
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
route: () => academicAdvisingLogIndex.url(),
pagination,
filters,
});
function handleDelete() {
@ -154,6 +173,13 @@ export default function AcademicAdvisingLogIndex({
searchValue={search}
searchKey="student"
searchPlaceholder="Cari nama atau NIM mahasiswa..."
toolbar={
<FilterDialog
fields={filterFields}
activeFilters={filters}
onApply={applyFilters}
/>
}
/>
<DeleteConfirmDialog

View File

@ -5,6 +5,8 @@ import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { FileUploadField } from '@/components/file-upload-field';
import type { FilterField } from '@/components/filter-dialog';
import { FilterDialog } from '@/components/filter-dialog';
import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
@ -43,6 +45,9 @@ type Props = {
};
students: LetterRequestStudent[];
highlight?: number;
filters: {
status?: string;
};
};
function studentLabel(student: LetterRequestStudent): string {
@ -53,11 +58,23 @@ export default function LetterRequestIndex({
letterRequests,
students,
highlight,
filters,
}: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<LetterRequest | null>(null);
const [deleting, setDeleting] = useState<LetterRequest | null>(null);
const filterFields: FilterField[] = [
{
key: 'status',
label: 'Status',
options: LetterStatuses.map((status) => ({
value: status,
label: LetterStatusLabels[status],
})),
},
];
const pagination: PaginationState = {
current_page: letterRequests.current_page,
last_page: letterRequests.last_page,
@ -70,9 +87,11 @@ export default function LetterRequestIndex({
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
route: () => letterRequestIndex.url(),
pagination,
filters,
});
function handleDelete() {
@ -146,6 +165,13 @@ export default function LetterRequestIndex({
searchValue={search}
searchKey="student"
searchPlaceholder="Cari nama, NIM, atau jenis surat..."
toolbar={
<FilterDialog
fields={filterFields}
activeFilters={filters}
onApply={applyFilters}
/>
}
/>
<DeleteConfirmDialog

View File

@ -5,6 +5,8 @@ import { ConfirmDialog } from '@/components/confirm-dialog';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import type { FilterField } from '@/components/filter-dialog';
import { FilterDialog } from '@/components/filter-dialog';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { useServerTable } from '@/hooks/use-server-table';
@ -13,7 +15,7 @@ import {
create,
destroy,
edit,
reset_password
reset_password,
} from '@/routes/admin/users/administrators';
import type { Administrator } from './columns';
import { createAdministratorColumns } from './columns';
@ -26,9 +28,23 @@ type Props = {
per_page: number;
total: number;
};
filters: {
gender?: string;
};
};
export default function AdministratorIndex({ administrators }: Props) {
const filterFields: FilterField[] = [
{
key: 'gender',
label: 'Jenis Kelamin',
options: [
{ value: 'male', label: 'Laki-laki' },
{ value: 'female', label: 'Perempuan' },
],
},
];
export default function AdministratorIndex({ administrators, filters }: Props) {
const [deleting, setDeleting] = useState<Administrator | null>(null);
const [resetting, setResetting] = useState<Administrator | null>(null);
@ -44,9 +60,11 @@ export default function AdministratorIndex({ administrators }: Props) {
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
route: () => administratorsIndex.url(),
pagination,
filters,
});
function handleDelete() {
@ -64,9 +82,13 @@ export default function AdministratorIndex({ administrators }: Props) {
return;
}
router.patch(reset_password.url(resetting.id), {}, {
onSuccess: () => setResetting(null),
});
router.patch(
reset_password.url(resetting.id),
{},
{
onSuccess: () => setResetting(null),
},
);
}
const columns = createAdministratorColumns({
@ -105,6 +127,13 @@ export default function AdministratorIndex({ administrators }: Props) {
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
toolbar={
<FilterDialog
fields={filterFields}
activeFilters={filters}
onApply={applyFilters}
/>
}
/>
<DeleteConfirmDialog

View File

@ -5,6 +5,8 @@ import { ConfirmDialog } from '@/components/confirm-dialog';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import type { FilterField } from '@/components/filter-dialog';
import { FilterDialog } from '@/components/filter-dialog';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { useServerTable } from '@/hooks/use-server-table';
@ -18,6 +20,8 @@ import {
import type { Lecturer } from './columns';
import { createLecturerColumns } from './columns';
type Department = { id: number; name: string };
type Props = {
lecturers: {
data: Lecturer[];
@ -26,12 +30,40 @@ type Props = {
per_page: number;
total: number;
};
departments: Department[];
filters: {
gender?: string;
department_id?: string;
};
};
export default function LecturerIndex({ lecturers }: Props) {
export default function LecturerIndex({
lecturers,
departments,
filters,
}: Props) {
const [deleting, setDeleting] = useState<Lecturer | null>(null);
const [resetting, setResetting] = useState<Lecturer | null>(null);
const filterFields: FilterField[] = [
{
key: 'gender',
label: 'Jenis Kelamin',
options: [
{ value: 'male', label: 'Laki-laki' },
{ value: 'female', label: 'Perempuan' },
],
},
{
key: 'department_id',
label: 'Jurusan',
options: departments.map((department) => ({
value: String(department.id),
label: department.name,
})),
},
];
const pagination: PaginationState = {
current_page: lecturers.current_page,
last_page: lecturers.last_page,
@ -44,9 +76,11 @@ export default function LecturerIndex({ lecturers }: Props) {
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
route: () => lecturersIndex.url(),
pagination,
filters,
});
function handleDelete() {
@ -64,9 +98,13 @@ export default function LecturerIndex({ lecturers }: Props) {
return;
}
router.patch(reset_password.url(resetting.id), {}, {
onSuccess: () => setResetting(null),
});
router.patch(
reset_password.url(resetting.id),
{},
{
onSuccess: () => setResetting(null),
},
);
}
const columns = createLecturerColumns({
@ -105,6 +143,13 @@ export default function LecturerIndex({ lecturers }: Props) {
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
toolbar={
<FilterDialog
fields={filterFields}
activeFilters={filters}
onApply={applyFilters}
/>
}
/>
<DeleteConfirmDialog

View File

@ -5,6 +5,8 @@ import { ConfirmDialog } from '@/components/confirm-dialog';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import type { FilterField } from '@/components/filter-dialog';
import { FilterDialog } from '@/components/filter-dialog';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { useServerTable } from '@/hooks/use-server-table';
@ -15,8 +17,11 @@ import {
reset_password,
index as studentsIndex,
} from '@/routes/admin/users/students';
import { createStudentColumns } from './columns';
import type {Student} from './columns';
import { createStudentColumns } from './columns';
import type { Student } from './columns';
type Department = { id: number; name: string };
type StatusOption = { value: string; label: string };
type Props = {
students: {
@ -26,12 +31,48 @@ type Props = {
per_page: number;
total: number;
};
departments: Department[];
statuses: StatusOption[];
filters: {
gender?: string;
department_id?: string;
status?: string;
};
};
export default function StudentIndex({ students }: Props) {
export default function StudentIndex({
students,
departments,
statuses,
filters,
}: Props) {
const [deleting, setDeleting] = useState<Student | null>(null);
const [resetting, setResetting] = useState<Student | null>(null);
const filterFields: FilterField[] = [
{
key: 'gender',
label: 'Jenis Kelamin',
options: [
{ value: 'male', label: 'Laki-laki' },
{ value: 'female', label: 'Perempuan' },
],
},
{
key: 'department_id',
label: 'Jurusan',
options: departments.map((department) => ({
value: String(department.id),
label: department.name,
})),
},
{
key: 'status',
label: 'Status',
options: statuses,
},
];
const pagination: PaginationState = {
current_page: students.current_page,
last_page: students.last_page,
@ -44,9 +85,11 @@ export default function StudentIndex({ students }: Props) {
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
route: () => studentsIndex.url(),
pagination,
filters,
});
function handleDelete() {
@ -64,9 +107,13 @@ export default function StudentIndex({ students }: Props) {
return;
}
router.patch(reset_password.url(resetting.id), {}, {
onSuccess: () => setResetting(null),
});
router.patch(
reset_password.url(resetting.id),
{},
{
onSuccess: () => setResetting(null),
},
);
}
const columns = createStudentColumns({
@ -105,6 +152,13 @@ export default function StudentIndex({ students }: Props) {
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
toolbar={
<FilterDialog
fields={filterFields}
activeFilters={filters}
onApply={applyFilters}
/>
}
/>
<DeleteConfirmDialog