diff --git a/app/Enums/DayOfWeek.php b/app/Enums/DayOfWeek.php new file mode 100644 index 0000000..a9db26c --- /dev/null +++ b/app/Enums/DayOfWeek.php @@ -0,0 +1,31 @@ + 'Senin', + self::Tuesday => 'Selasa', + self::Wednesday => 'Rabu', + self::Thursday => 'Kamis', + self::Friday => 'Jumat', + self::Saturday => 'Sabtu', + self::Sunday => 'Minggu', + }; + } +} diff --git a/app/Http/Controllers/Admin/AcademicClasses/ScheduleController.php b/app/Http/Controllers/Admin/AcademicClasses/ScheduleController.php index d09ce33..4f56c9f 100644 --- a/app/Http/Controllers/Admin/AcademicClasses/ScheduleController.php +++ b/app/Http/Controllers/Admin/AcademicClasses/ScheduleController.php @@ -4,9 +4,12 @@ use App\Http\Controllers\Controller; use App\Http\Requests\Admin\AcademicClasses\ScheduleRequest; +use App\Http\Requests\PaginatedRequest; use App\Models\Schedule; use App\Services\Admin\AcademicClasses\ScheduleService; -use App\Services\Admin\Manage\CourseClassService; +use App\Services\Admin\Master\AcademicTermService; +use App\Services\Admin\Master\CourseService; +use App\Services\Admin\Master\DepartmentService; use Illuminate\Http\RedirectResponse; use Inertia\Inertia; use Inertia\Response; @@ -15,14 +18,37 @@ class ScheduleController extends Controller { public function __construct( private readonly ScheduleService $service, - private readonly CourseClassService $courseClassService, + private readonly AcademicTermService $academicTermService, + private readonly DepartmentService $departmentService, + private readonly CourseService $courseService, ) {} - public function index(): Response + public function index(PaginatedRequest $request): Response { + $user = $request->user(); + $isPersonalView = $user->hasRole('mahasiswa') || $user->hasRole('dosen'); + + $academicTermId = $request->has('academic_term_id') + ? $request->validated('academic_term_id') + : $this->academicTermService->getActive()?->id; + return Inertia::render('admin/academic-classes/schedules/index', [ - 'schedules' => $this->service->all(), - 'courseClasses' => $this->courseClassService->getAllForSelect(), + 'schedules' => $this->service->all( + $user, + academicTermId: $academicTermId, + departmentId: $request->validated('department_id'), + semesterNumber: $request->validated('semester_number'), + ), + 'courseClasses' => $this->service->courseClassOptions(), + 'academicTerms' => $this->academicTermService->getAllForSelect(), + 'departments' => $this->departmentService->getAllForSelect(), + 'semesterNumbers' => $this->courseService->getSemesterNumbers(), + 'isPersonalView' => $isPersonalView, + 'filters' => [ + 'academic_term_id' => $academicTermId ? (string) $academicTermId : null, + 'department_id' => $request->validated('department_id'), + 'semester_number' => $request->validated('semester_number'), + ], ]); } diff --git a/app/Http/Requests/Admin/AcademicClasses/ScheduleRequest.php b/app/Http/Requests/Admin/AcademicClasses/ScheduleRequest.php index f6776a7..3dbc0c6 100644 --- a/app/Http/Requests/Admin/AcademicClasses/ScheduleRequest.php +++ b/app/Http/Requests/Admin/AcademicClasses/ScheduleRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests\Admin\AcademicClasses; +use App\Enums\DayOfWeek; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -16,9 +17,9 @@ public function rules(): array { return [ 'course_class_id' => ['required', 'integer', Rule::exists('course_classes', 'id')], - 'day_of_week' => ['nullable', 'string', 'max:15'], - 'start_time' => ['nullable', 'date_format:H:i'], - 'end_time' => ['nullable', 'date_format:H:i', 'after:start_time'], + 'day_of_week' => ['required', 'string', Rule::in(DayOfWeek::values())], + 'start_time' => ['required', 'date_format:H:i'], + 'end_time' => ['required', 'date_format:H:i', 'after:start_time'], 'room' => ['nullable', 'string', 'max:20'], 'online_link' => ['nullable', 'string', 'max:255', 'url'], ]; diff --git a/app/Models/Schedule.php b/app/Models/Schedule.php index c0d8d5e..31596f4 100644 --- a/app/Models/Schedule.php +++ b/app/Models/Schedule.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\DayOfWeek; use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -12,6 +13,13 @@ class Schedule extends Model { use HasFactory; + protected function casts(): array + { + return [ + 'day_of_week' => DayOfWeek::class, + ]; + } + public function courseClass(): BelongsTo { return $this->belongsTo(CourseClass::class); diff --git a/app/Services/Admin/AcademicClasses/ScheduleService.php b/app/Services/Admin/AcademicClasses/ScheduleService.php index cc3f8bf..cb521c8 100644 --- a/app/Services/Admin/AcademicClasses/ScheduleService.php +++ b/app/Services/Admin/AcademicClasses/ScheduleService.php @@ -2,27 +2,72 @@ namespace App\Services\Admin\AcademicClasses; +use App\Enums\RegistrationStatus; +use App\Models\CourseClass; use App\Models\Schedule; +use App\Models\User; use Illuminate\Database\Eloquent\Collection; class ScheduleService { - public function all(): Collection + public function all(User $user, ?int $academicTermId = null, ?int $departmentId = null, ?int $semesterNumber = null): Collection { return Schedule::query() ->select(['id', 'course_class_id', 'day_of_week', 'start_time', 'end_time', 'room', 'online_link']) - ->with('courseClass.course:id,code,name') + ->with([ + 'courseClass:id,course_id,lecturer_id,academic_term_id,method', + 'courseClass.course:id,code,name,department_id', + 'courseClass.course.department:id,name', + 'courseClass.lecturer:id,user_id,lecturer_number', + 'courseClass.lecturer.user:id,username', + 'courseClass.lecturer.user.profile:id,user_id,full_name', + ]) + ->whereHas('courseClass', function ($q) use ($user, $academicTermId, $departmentId, $semesterNumber) { + $q->when($academicTermId, fn ($q) => $q->where('academic_term_id', $academicTermId)) + ->when($departmentId, fn ($q) => $q->whereHas('course', fn ($q) => $q->where('department_id', $departmentId))) + ->when($semesterNumber, fn ($q) => $q->whereHas('course', fn ($q) => $q->where('semester_number', $semesterNumber))) + ->when($user->hasRole('mahasiswa'), fn ($q) => $q->whereHas('registrations', function ($q) use ($user) { + $q->where('student_id', $user->student?->id) + ->whereHas('submission', fn ($q) => $q->where('status', RegistrationStatus::Approved)); + })) + ->when($user->hasRole('dosen'), fn ($q) => $q->where('lecturer_id', $user->lecturer?->id)); + }) ->orderBy('start_time') ->get(); } + /** + * Course classes for the schedule form's picker, grouped by department and + * semester and ordered alphabetically to match the course-class picker + * pattern used elsewhere. + */ + public function courseClassOptions(): Collection + { + return CourseClass::query() + ->select(['course_classes.id', 'course_classes.course_id', 'course_classes.lecturer_id', 'course_classes.academic_term_id']) + ->join('courses', 'courses.id', '=', 'course_classes.course_id') + ->join('departments', 'departments.id', '=', 'courses.department_id') + ->with([ + 'course:id,code,name,department_id,semester_number', + 'course.department:id,name', + 'lecturer:id,user_id,lecturer_number', + 'lecturer.user:id,username', + 'lecturer.user.profile:id,user_id,full_name', + 'academicTerm:id,academic_year,semester,start_date,end_date', + ]) + ->orderBy('departments.name') + ->orderBy('courses.semester_number') + ->orderBy('courses.name') + ->get(); + } + public function create(array $data): Schedule { return Schedule::create([ 'course_class_id' => $data['course_class_id'], - 'day_of_week' => $data['day_of_week'] ?? null, - 'start_time' => $data['start_time'] ?? null, - 'end_time' => $data['end_time'] ?? null, + 'day_of_week' => $data['day_of_week'], + 'start_time' => $data['start_time'], + 'end_time' => $data['end_time'], 'room' => $data['room'] ?? null, 'online_link' => $data['online_link'] ?? null, ]); @@ -31,9 +76,9 @@ public function create(array $data): Schedule public function update(Schedule $schedule, array $data): Schedule { $schedule->course_class_id = $data['course_class_id']; - $schedule->day_of_week = $data['day_of_week'] ?? null; - $schedule->start_time = $data['start_time'] ?? null; - $schedule->end_time = $data['end_time'] ?? null; + $schedule->day_of_week = $data['day_of_week']; + $schedule->start_time = $data['start_time']; + $schedule->end_time = $data['end_time']; $schedule->room = $data['room'] ?? null; $schedule->online_link = $data['online_link'] ?? null; $schedule->update(); diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php index 6e5fff4..57fdfe8 100644 --- a/database/seeders/RolePermissionSeeder.php +++ b/database/seeders/RolePermissionSeeder.php @@ -46,6 +46,7 @@ public function run(): void 'create-letter-requests', 'update-letter-requests', 'delete-letter-requests', + 'view-schedules', ...$feedbackSelfService, ], 'dosen' => [ @@ -56,6 +57,7 @@ public function run(): void 'view-course-registrations', 'approve-course-registrations', 'reject-course-registrations', + 'view-schedules', ...$feedbackSelfService, ], 'staff-admin' => [ diff --git a/resources/js/pages/admin/academic-classes/schedules/index.tsx b/resources/js/pages/admin/academic-classes/schedules/index.tsx index 759f99f..5f24b40 100644 --- a/resources/js/pages/admin/academic-classes/schedules/index.tsx +++ b/resources/js/pages/admin/academic-classes/schedules/index.tsx @@ -1,4 +1,17 @@ +import { Head, router } from '@inertiajs/react'; +import { + Clock, + MapPin, + Pencil, + Plus, + Trash2, + User, + Video, +} from 'lucide-react'; +import { useState } from 'react'; 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'; @@ -6,6 +19,17 @@ import { RowActions } from '@/components/row-actions'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Combobox, + ComboboxCollection, + ComboboxContent, + ComboboxEmpty, + ComboboxGroup, + ComboboxInput, + ComboboxItem, + ComboboxLabel, + ComboboxList, +} from '@/components/ui/combobox'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { @@ -16,35 +40,119 @@ import { SelectValue, } from '@/components/ui/select'; import { usePermissions } from '@/hooks/use-permissions'; +import { useServerTable } from '@/hooks/use-server-table'; import { cn } from '@/lib/utils'; import { destroy, + index as scheduleIndex, store, update, } from '@/routes/admin/academic-classes/schedules'; +import { formatAcademicTermLabel } from '@/types/academic-term'; +import { ClassMethodLabels } from '@/types/course-class'; +import type { ClassMethod } from '@/types/course-class'; import type { Schedule } from '@/types/schedule'; import { DayOfWeekLabels, DaysOfWeek } from '@/types/schedule'; -import { Head, router } from '@inertiajs/react'; -import { Clock, MapPin, Pencil, Plus, Trash2, Video } from 'lucide-react'; -import { useState } from 'react'; type CourseClassOption = { id: number; - course: { id: number; code: string; name: string } | null; + course: { + id: number; + code: string; + name: string; + semester_number: number | null; + department: { id: number; name: string } | null; + } | null; + lecturer: { + id: number; + user: { profile: { full_name: string } | null } | null; + } | null; + academic_term: { + id: number; + academic_year: string; + semester: string; + } | null; +}; + +type CourseClassGroup = { value: string; items: CourseClassOption[] }; + +type AcademicTermOption = { + id: number; + academic_year: string; + semester: string; +}; + +type DepartmentOption = { + id: number; + name: string; }; type Props = { schedules: Schedule[]; courseClasses: CourseClassOption[]; + academicTerms: AcademicTermOption[]; + departments: DepartmentOption[]; + semesterNumbers: number[]; + isPersonalView: boolean; highlight?: number; + filters: { + academic_term_id?: string; + department_id?: string; + semester_number?: string; + }; }; const UNSCHEDULED = '__unscheduled__'; const BOARD_COLUMNS = [...DaysOfWeek, UNSCHEDULED] as const; -function courseClassLabel(courseClass: CourseClassOption): string { - return `${courseClass.course?.code ?? ''} ${courseClass.course?.name ?? ''}`; +const DEPARTMENT_PALETTE = [ + { border: 'border-l-blue-500 bg-blue-50/60 dark:bg-blue-950/20', dot: 'bg-blue-500' }, + { border: 'border-l-emerald-500 bg-emerald-50/60 dark:bg-emerald-950/20', dot: 'bg-emerald-500' }, + { border: 'border-l-amber-500 bg-amber-50/60 dark:bg-amber-950/20', dot: 'bg-amber-500' }, + { border: 'border-l-violet-500 bg-violet-50/60 dark:bg-violet-950/20', dot: 'bg-violet-500' }, + { border: 'border-l-rose-500 bg-rose-50/60 dark:bg-rose-950/20', dot: 'bg-rose-500' }, + { border: 'border-l-cyan-500 bg-cyan-50/60 dark:bg-cyan-950/20', dot: 'bg-cyan-500' }, + { border: 'border-l-orange-500 bg-orange-50/60 dark:bg-orange-950/20', dot: 'bg-orange-500' }, + { border: 'border-l-fuchsia-500 bg-fuchsia-50/60 dark:bg-fuchsia-950/20', dot: 'bg-fuchsia-500' }, +] as const; + +function departmentPalette(departmentId: number | undefined) { + if (!departmentId) { + return { border: 'border-l-border', dot: 'bg-muted-foreground/40' }; + } + + return DEPARTMENT_PALETTE[departmentId % DEPARTMENT_PALETTE.length]; +} + +function courseClassLabel(courseClass: { + course: { code: string; name: string } | null; +} | null): string { + return `${courseClass?.course?.code ?? ''} ${courseClass?.course?.name ?? ''}`; +} + +function courseClassOptionLabel(option: CourseClassOption): string { + return `${option.course?.code ?? ''} - ${option.course?.name ?? ''}`; +} + +function groupCourseClassesByDepartment( + options: CourseClassOption[], +): CourseClassGroup[] { + const groups: CourseClassGroup[] = []; + let currentKey: string | null = null; + + for (const option of options) { + const key = `${option.course?.department?.name ?? 'Tanpa Jurusan'} — Semester ${option.course?.semester_number ?? 'Tidak ditentukan'}`; + + if (key !== currentKey) { + currentKey = key; + groups.push({ value: key, items: [] }); + } + + groups[groups.length - 1].items.push(option); + } + + return groups; } function toTimeInput(value: string | null): string { @@ -54,7 +162,12 @@ function toTimeInput(value: string | null): string { export default function ScheduleIndex({ schedules, courseClasses, + academicTerms, + departments, + semesterNumbers, + isPersonalView, highlight, + filters, }: Props) { const [createOpen, setCreateOpen] = useState(false); const [editing, setEditing] = useState(null); @@ -64,6 +177,61 @@ export default function ScheduleIndex({ const canUpdate = hasPermission('update-schedules'); const canDelete = hasPermission('delete-schedules'); + const filterFields: FilterField[] = [ + { + key: 'academic_term_id', + label: 'Periode Akademik', + options: academicTerms.map((term) => ({ + value: String(term.id), + label: formatAcademicTermLabel(term), + })), + }, + ...(isPersonalView + ? [] + : [ + { + key: 'department_id', + label: 'Jurusan', + options: departments.map((department) => ({ + value: String(department.id), + label: department.name, + })), + }, + { + key: 'semester_number', + label: 'Semester', + options: semesterNumbers.map((semester) => ({ + value: String(semester), + label: String(semester), + })), + }, + ]), + ]; + + const { applyFilters } = useServerTable({ + route: () => scheduleIndex.url(), + pagination: { + current_page: 1, + last_page: 1, + per_page: 999999, + total: schedules.length, + }, + filters, + }); + + function handleApplyFilters(newFilters: Record) { + // Without an explicit `academic_term_id`, the backend defaults it back + // to the active term, so clearing it needs to be sent explicitly + // instead of just omitting the key. + const clearedAcademicTerm = + Boolean(filters.academic_term_id) && !newFilters.academic_term_id; + + applyFilters({ + ...newFilters, + ...(clearedAcademicTerm ? { academic_term_id: '' } : {}), + }); + } + function handleDelete() { if (!deleting) { return; @@ -86,6 +254,15 @@ export default function ScheduleIndex({ day !== UNSCHEDULED || (grouped.get(UNSCHEDULED)?.length ?? 0) > 0, ); + const departmentsInView = Array.from( + new Map( + schedules + .map((schedule) => schedule.course_class?.course?.department) + .filter((department): department is { id: number; name: string } => !!department) + .map((department) => [department.id, department]), + ).values(), + ); + return ( <> @@ -94,23 +271,52 @@ export default function ScheduleIndex({ - - - ) +
+ + {canCreate && ( + + + )} +
} /> -

- Geser ke samping untuk melihat hari lainnya. -

+
+

+ Geser ke samping untuk melihat hari lainnya. +

+ + {departmentsInView.length > 1 && ( +
+ {departmentsInView.map((department) => ( + + + {department.name} + + ))} +
+ )} +
{courseClassLabel( schedule.course_class ?? { - id: 0, course: null, }, )} @@ -224,6 +436,19 @@ export default function ScheduleIndex({ ) || '-'} )} + {schedule.course_class + ?.lecturer && ( + + + {schedule + .course_class + .lecturer + .user + ?.profile + ?.full_name ?? + 'N/A'} + + )} {schedule.room && ( @@ -243,6 +468,22 @@ export default function ScheduleIndex({ Link Online )} + {schedule.course_class + ?.method && ( + + {ClassMethodLabels[ + schedule + .course_class + .method as ClassMethod + ] ?? + schedule + .course_class + .method} + + )} )) @@ -272,6 +513,60 @@ export default function ScheduleIndex({ ); } +function CourseClassField({ + courseClasses, + value, + onChange, + disabled, +}: { + courseClasses: CourseClassOption[]; + value: CourseClassOption | null; + onChange: (value: CourseClassOption | null) => void; + disabled?: boolean; +}) { + const groups = groupCourseClassesByDepartment(courseClasses); + + return ( + a.id === b.id} + disabled={disabled} + > + + + Kelas tidak ditemukan. + + {(group: CourseClassGroup) => ( + + {group.value} + + {(option: CourseClassOption) => ( + +
+ + {courseClassOptionLabel(option)} + + + {option.lecturer?.user?.profile + ?.full_name ?? 'N/A'} + {option.academic_term && + ` • ${formatAcademicTermLabel(option.academic_term)}`} + +
+
+ )} +
+
+ )} +
+
+
+ ); +} + function CreateForm({ open, onOpenChange, @@ -281,6 +576,10 @@ function CreateForm({ onOpenChange: (open: boolean) => void; courseClasses: CourseClassOption[]; }) { + const [courseClass, setCourseClass] = useState( + null, + ); + return ( onOpenChange(false)} + onSuccess={() => { + onOpenChange(false); + setCourseClass(null); + }} > {({ errors }) => (
@@ -296,26 +598,22 @@ function CreateForm({ - - + +
- +
- +
@@ -382,6 +686,13 @@ function EditForm({ editing: Schedule | null; courseClasses: CourseClassOption[]; }) { + const [courseClass, setCourseClass] = useState( + editing + ? (courseClasses.find((c) => c.id === editing.course_class_id) ?? + null) + : null, + ); + return ( *
- + value={courseClass?.id ?? ''} + /> +
- +