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
+ Belum ada data kehadiran. +
+ ) : ( ++ Pertemuan ke- + {item.meeting_number ?? '-'}{' '} + ·{' '} + {format( + new Date(item.date), + 'd MMM yyyy', + )} +
++ Pertemuan ke-{meetingNumber} +
++ Belum ada mahasiswa yang terdaftar di kelas ini. +
+ ) : ( ++ {entry.student?.user?.profile + ?.full_name ?? 'N/A'} +
++ {entry.student?.student_number} +
+