diff --git a/app/Enums/AttendanceStatus.php b/app/Enums/AttendanceStatus.php new file mode 100644 index 0000000..bfe21e2 --- /dev/null +++ b/app/Enums/AttendanceStatus.php @@ -0,0 +1,17 @@ + 'Hadir', + self::Absent => 'Tidak Hadir', + }; + } +} diff --git a/app/Http/Controllers/Admin/Manage/AttendanceController.php b/app/Http/Controllers/Admin/Manage/AttendanceController.php new file mode 100644 index 0000000..712105f --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/AttendanceController.php @@ -0,0 +1,59 @@ + $this->service->sessions(), + 'courseClasses' => $this->courseClassService->getAllForSelect(), + ]); + } + + public function session(CourseClass $courseClass, int $meetingNumber): Response + { + $courseClass->load('course'); + $data = $this->service->session($courseClass, $meetingNumber); + + return Inertia::render('admin/manage/attendances/session', [ + 'courseClass' => $courseClass, + 'meetingNumber' => $meetingNumber, + 'roster' => $data['roster'], + 'date' => $data['date'], + ]); + } + + public function store(AttendanceStoreRequest $request, CourseClass $courseClass, int $meetingNumber): RedirectResponse + { + $this->service->saveSession($courseClass->id, $meetingNumber, $request->validated('date'), $request->validated('records')); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Kehadiran berhasil disimpan.']); + + return to_route('admin.manage.attendances.session', [$courseClass->id, $meetingNumber]); + } + + public function destroy(CourseClass $courseClass, int $meetingNumber): RedirectResponse + { + $this->service->deleteSession($courseClass->id, $meetingNumber); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Sesi kehadiran berhasil dihapus.']); + + return to_route('admin.manage.attendances.index'); + } +} diff --git a/app/Http/Requests/Admin/Manage/AttendanceStoreRequest.php b/app/Http/Requests/Admin/Manage/AttendanceStoreRequest.php new file mode 100644 index 0000000..c2832b1 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/AttendanceStoreRequest.php @@ -0,0 +1,25 @@ + ['required', 'date'], + 'records' => ['required', 'array', 'min:1'], + 'records.*.student_id' => ['required', 'integer', Rule::exists('students', 'id')], + 'records.*.status' => ['required', Rule::enum(AttendanceStatus::class)], + ]; + } +} diff --git a/app/Models/Attendance.php b/app/Models/Attendance.php new file mode 100644 index 0000000..88254df --- /dev/null +++ b/app/Models/Attendance.php @@ -0,0 +1,34 @@ + 'date', + 'status' => AttendanceStatus::class, + 'recorded_at' => 'datetime', + ]; + } + + public function courseClass(): BelongsTo + { + return $this->belongsTo(CourseClass::class); + } + + public function student(): BelongsTo + { + return $this->belongsTo(Student::class); + } +} diff --git a/app/Models/CourseClass.php b/app/Models/CourseClass.php index a41ecde..7d8c572 100644 --- a/app/Models/CourseClass.php +++ b/app/Models/CourseClass.php @@ -56,4 +56,9 @@ public function schedules(): HasMany { return $this->hasMany(Schedule::class); } + + public function attendances(): HasMany + { + return $this->hasMany(Attendance::class); + } } diff --git a/app/Models/Student.php b/app/Models/Student.php index 2b5097d..b2a5e2b 100644 --- a/app/Models/Student.php +++ b/app/Models/Student.php @@ -46,4 +46,9 @@ public function submissions(): HasMany { return $this->hasMany(Submission::class); } + + public function attendances(): HasMany + { + return $this->hasMany(Attendance::class); + } } diff --git a/app/Services/Admin/Manage/AttendanceService.php b/app/Services/Admin/Manage/AttendanceService.php new file mode 100644 index 0000000..cbf7fda --- /dev/null +++ b/app/Services/Admin/Manage/AttendanceService.php @@ -0,0 +1,77 @@ +select(['course_class_id', 'meeting_number', 'date']) + ->selectRaw('COUNT(*) as total_count') + ->selectRaw("SUM(CASE WHEN status = 'present' THEN 1 ELSE 0 END) as present_count") + ->with('courseClass.course:id,code,name') + ->groupBy('course_class_id', 'meeting_number', 'date') + ->orderByDesc('date') + ->get(); + } + + public function session(CourseClass $courseClass, int $meetingNumber): array + { + $existing = Attendance::query() + ->where('course_class_id', $courseClass->id) + ->where('meeting_number', $meetingNumber) + ->get() + ->keyBy('student_id'); + + $roster = $courseClass->enrollments() + ->with('student.user.profile') + ->get() + ->map(function ($enrollment) use ($existing) { + $attendance = $existing->get($enrollment->student_id); + + return [ + 'student_id' => $enrollment->student_id, + 'student' => $enrollment->student, + 'status' => $attendance?->status?->value ?? AttendanceStatus::Absent->value, + ]; + }) + ->values(); + + return [ + 'roster' => $roster, + 'date' => $existing->first()?->date?->format('Y-m-d'), + ]; + } + + public function saveSession(int $courseClassId, int $meetingNumber, string $date, array $records): void + { + foreach ($records as $record) { + Attendance::updateOrCreate( + [ + 'course_class_id' => $courseClassId, + 'student_id' => $record['student_id'], + 'meeting_number' => $meetingNumber, + ], + [ + 'date' => $date, + 'status' => $record['status'], + 'recorded_at' => now(), + ], + ); + } + } + + public function deleteSession(int $courseClassId, int $meetingNumber): void + { + Attendance::query() + ->where('course_class_id', $courseClassId) + ->where('meeting_number', $meetingNumber) + ->delete(); + } +} diff --git a/database/migrations/2026_08_24_000004_create_attendances_table.php b/database/migrations/2026_08_24_000004_create_attendances_table.php new file mode 100644 index 0000000..38d87d3 --- /dev/null +++ b/database/migrations/2026_08_24_000004_create_attendances_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('course_class_id')->constrained()->cascadeOnDelete(); + $table->foreignId('student_id')->constrained()->cascadeOnDelete(); + $table->integer('meeting_number')->nullable(); + $table->date('date'); + $table->enum('status', array_values(AttendanceStatus::cases()))->nullable()->default(AttendanceStatus::Absent->value); + $table->timestamp('recorded_at')->nullable(); + $table->timestamps(); + + $table->unique(['course_class_id', 'student_id', 'meeting_number']); + }); + } + + public function down(): void + { + Schema::dropIfExists('attendances'); + } +}; diff --git a/database/seeders/AttendanceSeeder.php b/database/seeders/AttendanceSeeder.php new file mode 100644 index 0000000..2d2d9e0 --- /dev/null +++ b/database/seeders/AttendanceSeeder.php @@ -0,0 +1,35 @@ + 1, + 'student_id' => 1, + 'meeting_number' => 1, + 'date' => '2026-08-04', + 'status' => 'present', + 'recorded_at' => '2026-08-04 08:05:00', + 'created_at' => '2026-08-04 08:05:00', + 'updated_at' => '2026-08-04 08:05:00', + ], + [ + 'course_class_id' => 2, + 'student_id' => 2, + 'meeting_number' => 1, + 'date' => '2026-08-05', + 'status' => 'present', + 'recorded_at' => '2026-08-05 10:03:00', + 'created_at' => '2026-08-05 10:03:00', + 'updated_at' => '2026-08-05 10:03:00', + ], + ]); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index ea9058c..6238346 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -24,6 +24,7 @@ public function run(): void AssignmentSeeder::class, SubmissionSeeder::class, ScheduleSeeder::class, + AttendanceSeeder::class, ]); } } diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 9138639..0d2adac 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -11,6 +11,7 @@ import { Building2, Calendar, CalendarClock, + ClipboardCheck, ClipboardList, FileText, GraduationCap, @@ -33,6 +34,7 @@ import { SidebarMenuItem, } from '@/components/ui/sidebar'; import { index as assignmentsRoute } from '@/routes/admin/manage/assignments'; +import { index as attendancesRoute } from '@/routes/admin/manage/attendances'; import { index as courseClassesRoute } from '@/routes/admin/manage/course-classes'; import { index as coursesRoute } from '@/routes/admin/manage/courses'; import { index as materialsRoute } from '@/routes/admin/manage/materials'; @@ -101,6 +103,11 @@ const data: { url: schedulesRoute.url(), icon: CalendarClock, }, + { + name: 'Kehadiran', + url: attendancesRoute.url(), + icon: ClipboardCheck, + }, ], }, { diff --git a/resources/js/pages/admin/manage/attendances/index.tsx b/resources/js/pages/admin/manage/attendances/index.tsx new file mode 100644 index 0000000..494af49 --- /dev/null +++ b/resources/js/pages/admin/manage/attendances/index.tsx @@ -0,0 +1,273 @@ +import { Head, Link, router } from '@inertiajs/react'; +import { format } from 'date-fns'; +import { ClipboardCheck, Plus, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog'; +import { PageHeader } from '@/components/page-header'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { destroy, session } from '@/routes/admin/manage/attendances'; +import type { + AttendanceCourseClass, + AttendanceSession, +} from '@/types/attendance'; + +type Props = { + sessions: AttendanceSession[]; + courseClasses: AttendanceCourseClass[]; +}; + +function courseClassLabel(courseClass: AttendanceCourseClass): string { + const namePart = courseClass.class_name + ? `${courseClass.class_name} - ` + : ''; + + return `${namePart}${courseClass.course?.code ?? ''} ${courseClass.course?.name ?? ''}`; +} + +export default function AttendanceIndex({ sessions, courseClasses }: Props) { + const [newSessionOpen, setNewSessionOpen] = useState(false); + const [deleting, setDeleting] = useState(null); + + function handleDelete() { + if (!deleting || deleting.meeting_number === null) { + return; + } + + router.delete( + destroy([deleting.course_class_id, deleting.meeting_number]).url, + { onSuccess: () => setDeleting(null) }, + ); + } + + return ( + <> + + +
+ setNewSessionOpen(true)}> + + Ambil Kehadiran + + } + /> + + {sessions.length === 0 ? ( +

+ Belum ada data kehadiran. +

+ ) : ( +
+ {sessions.map((item) => { + const key = `${item.course_class_id}-${item.meeting_number}-${item.date}`; + const canOpen = item.meeting_number !== null; + + return ( + + + + {item.course_class + ? courseClassLabel( + item.course_class, + ) + : '-'} + + + {item.present_count}/ + {item.total_count} Hadir + + + +

+ Pertemuan ke- + {item.meeting_number ?? '-'}{' '} + ·{' '} + {format( + new Date(item.date), + 'd MMM yyyy', + )} +

+
+ + +
+
+
+ ); + })} +
+ )} + + + + { + if (!open) { + setDeleting(null); + } + }} + title="Hapus Sesi Kehadiran" + description={(item) => + `Apakah Anda yakin ingin menghapus seluruh data kehadiran pertemuan ke-${item.meeting_number} untuk kelas ini? Tindakan ini tidak dapat dibatalkan.` + } + onConfirm={handleDelete} + /> +
+ + ); +} + +function NewSessionDialog({ + open, + onOpenChange, + courseClasses, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + courseClasses: AttendanceCourseClass[]; +}) { + const [courseClassId, setCourseClassId] = useState(''); + const [meetingNumber, setMeetingNumber] = useState(''); + + function reset() { + setCourseClassId(''); + setMeetingNumber(''); + } + + function handleOpenSession() { + if (!courseClassId || !meetingNumber) { + return; + } + + router.get(session([Number(courseClassId), Number(meetingNumber)]).url); + } + + return ( + { + onOpenChange(value); + + if (!value) { + reset(); + } + }} + > + + + Ambil Kehadiran + +
+
+ + +
+
+ + setMeetingNumber(e.target.value)} + /> +
+
+ + + + +
+
+ ); +} diff --git a/resources/js/pages/admin/manage/attendances/session.tsx b/resources/js/pages/admin/manage/attendances/session.tsx new file mode 100644 index 0000000..0af8bd8 --- /dev/null +++ b/resources/js/pages/admin/manage/attendances/session.tsx @@ -0,0 +1,216 @@ +import { Head, router } from '@inertiajs/react'; +import { format } from 'date-fns'; +import { CheckCircle2, Save, XCircle } from 'lucide-react'; +import { useState } from 'react'; +import { DatePicker } from '@/components/date-picker'; +import { PageHeader } from '@/components/page-header'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { + index as attendanceIndex, + store, +} from '@/routes/admin/manage/attendances'; +import type { + AttendanceCourseClass, + AttendanceRosterEntry, + AttendanceStatus, +} from '@/types/attendance'; +import { AttendanceStatusLabels } from '@/types/attendance'; + +type Props = { + courseClass: AttendanceCourseClass; + meetingNumber: number; + roster: AttendanceRosterEntry[]; + date: string | null; +}; + +function courseClassLabel(courseClass: AttendanceCourseClass): string { + const namePart = courseClass.class_name + ? `${courseClass.class_name} - ` + : ''; + + return `${namePart}${courseClass.course?.code ?? ''} ${courseClass.course?.name ?? ''}`; +} + +export default function AttendanceSession({ + courseClass, + meetingNumber, + roster, + date, +}: Props) { + const [selectedDate, setSelectedDate] = useState( + date ? new Date(date) : new Date(), + ); + const [statuses, setStatuses] = useState>( + () => + Object.fromEntries( + roster.map((entry) => [entry.student_id, entry.status]), + ), + ); + const [saving, setSaving] = useState(false); + + function setAll(status: AttendanceStatus) { + setStatuses( + Object.fromEntries( + roster.map((entry) => [entry.student_id, status]), + ), + ); + } + + function handleSave() { + if (!selectedDate) { + return; + } + + setSaving(true); + + router.post( + store([courseClass.id, meetingNumber]).url, + { + date: format(selectedDate, 'yyyy-MM-dd'), + records: roster.map((entry) => ({ + student_id: entry.student_id, + status: statuses[entry.student_id], + })), + }, + { + preserveScroll: true, + onFinish: () => setSaving(false), + }, + ); + } + + return ( + <> + + +
+ + Kembali + + } + /> + + + + {courseClassLabel(courseClass)} + + +

+ Pertemuan ke-{meetingNumber} +

+
+ + +
+
+
+ +
+

Daftar Mahasiswa

+
+ + +
+
+ + {roster.length === 0 ? ( +

+ Belum ada mahasiswa yang terdaftar di kelas ini. +

+ ) : ( +
+ {roster.map((entry) => ( +
+
+

+ {entry.student?.user?.profile + ?.full_name ?? 'N/A'} +

+

+ {entry.student?.student_number} +

+
+
+ + +
+
+ ))} +
+ )} + + {roster.length > 0 && ( +
+ +
+ )} +
+ + ); +} diff --git a/resources/js/types/attendance.ts b/resources/js/types/attendance.ts new file mode 100644 index 0000000..55010df --- /dev/null +++ b/resources/js/types/attendance.ts @@ -0,0 +1,35 @@ +export const AttendanceStatuses = ['present', 'absent'] as const; + +export type AttendanceStatus = (typeof AttendanceStatuses)[number]; + +export const AttendanceStatusLabels: Record = { + present: 'Hadir', + absent: 'Tidak Hadir', +}; + +export type AttendanceCourseClass = { + id: number; + class_name: string | null; + course: { id: number; code: string; name: string } | null; +}; + +export type AttendanceSession = { + course_class_id: number; + course_class: AttendanceCourseClass | null; + meeting_number: number | null; + date: string; + total_count: number; + present_count: number; +}; + +export type AttendanceRosterStudent = { + id: number; + student_number: string; + user: { profile: { full_name: string } | null } | null; +}; + +export type AttendanceRosterEntry = { + student_id: number; + student: AttendanceRosterStudent | null; + status: AttendanceStatus; +}; diff --git a/routes/admin.php b/routes/admin.php index de9b5ed..89759a6 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -1,6 +1,7 @@ except(['create', 'edit', 'show']); + + Route::prefix('attendances')->name('attendances.')->group(function () { + Route::get('/', [AttendanceController::class, 'index'])->name('index'); + Route::get('{course_class}/{meeting_number}', [AttendanceController::class, 'session']) + ->whereNumber('meeting_number') + ->name('session'); + Route::post('{course_class}/{meeting_number}', [AttendanceController::class, 'store']) + ->whereNumber('meeting_number') + ->name('store'); + Route::delete('{course_class}/{meeting_number}', [AttendanceController::class, 'destroy']) + ->whereNumber('meeting_number') + ->name('destroy'); + }); }); Route::prefix('admin/users')->name('admin.users.')->group(function () {