diff --git a/app/Http/Controllers/Admin/AcademicClasses/AttendanceController.php b/app/Http/Controllers/Admin/AcademicClasses/AttendanceController.php
index 51c8a4d..d4fd668 100644
--- a/app/Http/Controllers/Admin/AcademicClasses/AttendanceController.php
+++ b/app/Http/Controllers/Admin/AcademicClasses/AttendanceController.php
@@ -6,8 +6,9 @@
use App\Http\Requests\Admin\AcademicClasses\AttendanceStoreRequest;
use App\Models\CourseClass;
use App\Services\Admin\AcademicClasses\AttendanceService;
-use App\Services\Admin\Manage\CourseClassService;
+use App\Services\Admin\Master\AcademicTermService;
use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
@@ -15,19 +16,52 @@ class AttendanceController extends Controller
{
public function __construct(
private readonly AttendanceService $service,
- private readonly CourseClassService $courseClassService,
+ private readonly AcademicTermService $academicTermService,
) {}
- public function index(): Response
+ public function index(Request $request): Response
{
+ $academicTermId = $request->has('academic_term_id')
+ ? $request->integer('academic_term_id')
+ : $this->academicTermService->getActive()?->id;
+
return Inertia::render('admin/academic-classes/attendances/index', [
- 'sessions' => $this->service->sessions(),
- 'courseClasses' => $this->courseClassService->getAllForSelect(),
+ 'courseClasses' => $this->service->courseClassesForUser($request->user(), $academicTermId),
+ 'academicTerms' => $this->academicTermService->getAllForSelect(),
+ 'filters' => [
+ 'academic_term_id' => $academicTermId ? (string) $academicTermId : null,
+ ],
+ ]);
+ }
+
+ public function mine(Request $request): Response
+ {
+ $student = $request->user()->student;
+
+ abort_if(! $student, 403);
+
+ return Inertia::render('admin/academic-classes/attendances/mine', [
+ 'summaries' => $this->service->forStudent($student),
+ ]);
+ }
+
+ public function show(CourseClass $courseClass): Response
+ {
+ $this->abortUnlessLecturerOwnsClass($courseClass);
+
+ $courseClass->load(['course', 'academicTerm']);
+
+ return Inertia::render('admin/academic-classes/attendances/course-class', [
+ 'courseClass' => $courseClass,
+ 'sessions' => $this->service->sessionsForClass($courseClass),
+ 'nextMeetingNumber' => $this->service->nextMeetingNumber($courseClass),
]);
}
public function session(CourseClass $courseClass, int $meetingNumber): Response
{
+ $this->abortUnlessLecturerOwnsClass($courseClass);
+
$courseClass->load('course');
$data = $this->service->session($courseClass, $meetingNumber);
@@ -50,10 +84,25 @@ public function store(AttendanceStoreRequest $request, CourseClass $courseClass,
public function destroy(CourseClass $courseClass, int $meetingNumber): RedirectResponse
{
+ $this->abortUnlessLecturerOwnsClass($courseClass);
+
$this->service->deleteSession($courseClass->id, $meetingNumber);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Sesi kehadiran berhasil dihapus.']);
- return to_route('admin.academic-classes.attendances.index');
+ return to_route('admin.academic-classes.attendances.show', [$courseClass->id]);
+ }
+
+ /**
+ * A dosen may only take or manage attendance for classes they lecture.
+ */
+ private function abortUnlessLecturerOwnsClass(CourseClass $courseClass): void
+ {
+ $user = request()->user();
+
+ abort_if(
+ $user->hasRole('dosen') && $courseClass->lecturer_id !== $user->lecturer?->id,
+ 403,
+ );
}
}
diff --git a/app/Http/Requests/Admin/AcademicClasses/AttendanceStoreRequest.php b/app/Http/Requests/Admin/AcademicClasses/AttendanceStoreRequest.php
index 37e6918..decc6e4 100644
--- a/app/Http/Requests/Admin/AcademicClasses/AttendanceStoreRequest.php
+++ b/app/Http/Requests/Admin/AcademicClasses/AttendanceStoreRequest.php
@@ -10,7 +10,18 @@ class AttendanceStoreRequest extends FormRequest
{
public function authorize(): bool
{
- return $this->user()->can('create-attendances');
+ if (! $this->user()->can('create-attendances')) {
+ return false;
+ }
+
+ $user = $this->user();
+ $courseClass = $this->route('course_class');
+
+ if ($user->hasRole('dosen') && $courseClass?->lecturer_id !== $user->lecturer?->id) {
+ return false;
+ }
+
+ return true;
}
public function rules(): array
diff --git a/app/Services/Admin/AcademicClasses/AttendanceService.php b/app/Services/Admin/AcademicClasses/AttendanceService.php
index 8686ac2..b3e575e 100644
--- a/app/Services/Admin/AcademicClasses/AttendanceService.php
+++ b/app/Services/Admin/AcademicClasses/AttendanceService.php
@@ -5,22 +5,60 @@
use App\Enums\AttendanceStatus;
use App\Models\Attendance;
use App\Models\CourseClass;
+use App\Models\Student;
+use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Support\Collection as BaseCollection;
class AttendanceService
{
- public function sessions(): Collection
+ /**
+ * Classes to show on the attendance overview: a dosen only sees classes
+ * they lecture, optionally narrowed down to one academic term.
+ */
+ public function courseClassesForUser(User $user, ?int $academicTermId): Collection
+ {
+ return CourseClass::query()
+ ->select(['course_classes.id', 'course_classes.course_id', 'course_classes.lecturer_id', 'course_classes.academic_term_id'])
+ ->withCount('enrollments')
+ ->with([
+ 'course:id,code,name',
+ 'academicTerm:id,academic_year,semester,start_date,end_date',
+ 'attendances' => fn ($q) => $q->select('course_class_id', 'meeting_number')->distinct(),
+ ])
+ ->when($user->hasRole('dosen'), fn ($q) => $q->where('lecturer_id', $user->lecturer?->id))
+ ->when($academicTermId, fn ($q) => $q->where('academic_term_id', $academicTermId))
+ ->orderBy('course_id')
+ ->get()
+ ->each(function (CourseClass $courseClass) {
+ $courseClass->setAttribute('meetings_count', $courseClass->attendances->count());
+ $courseClass->unsetRelation('attendances');
+ });
+ }
+
+ /**
+ * Every meeting recorded so far for a single class.
+ */
+ public function sessionsForClass(CourseClass $courseClass): Collection
{
return Attendance::query()
- ->select(['course_class_id', 'meeting_number', 'date'])
+ ->select(['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')
+ ->where('course_class_id', $courseClass->id)
+ ->groupBy('meeting_number', 'date')
+ ->orderByDesc('meeting_number')
->get();
}
+ /**
+ * The meeting number a new session for this class should default to.
+ */
+ public function nextMeetingNumber(CourseClass $courseClass): int
+ {
+ return (Attendance::query()->where('course_class_id', $courseClass->id)->max('meeting_number') ?? 0) + 1;
+ }
+
public function session(CourseClass $courseClass, int $meetingNumber): array
{
$existing = Attendance::query()
@@ -74,4 +112,36 @@ public function deleteSession(int $courseClassId, int $meetingNumber): void
->where('meeting_number', $meetingNumber)
->delete();
}
+
+ /**
+ * Per-class attendance summary for a student's own classes: every class
+ * they're enrolled in, with their recorded meetings and a present/total
+ * tally, even for classes with no attendance taken yet.
+ */
+ public function forStudent(Student $student): BaseCollection
+ {
+ $enrollments = $student->enrollments()
+ ->with([
+ 'courseClass.course:id,code,name',
+ 'courseClass.academicTerm:id,academic_year,semester,start_date,end_date',
+ ])
+ ->get();
+
+ $recordsByClass = Attendance::query()
+ ->where('student_id', $student->id)
+ ->orderBy('meeting_number')
+ ->get(['course_class_id', 'meeting_number', 'date', 'status'])
+ ->groupBy('course_class_id');
+
+ return $enrollments->map(function ($enrollment) use ($recordsByClass) {
+ $records = $recordsByClass->get($enrollment->course_class_id, new Collection);
+
+ return [
+ 'course_class' => $enrollment->courseClass,
+ 'records' => $records->values(),
+ 'present_count' => $records->where('status', AttendanceStatus::Present)->count(),
+ 'total_count' => $records->count(),
+ ];
+ })->values();
+ }
}
diff --git a/app/Support/PermissionCatalog.php b/app/Support/PermissionCatalog.php
index aa0ddf5..536f105 100644
--- a/app/Support/PermissionCatalog.php
+++ b/app/Support/PermissionCatalog.php
@@ -18,7 +18,7 @@ class PermissionCatalog
'view-assignment-submissions', 'update-assignment-submissions',
'submit-assignments',
'view-schedules', 'create-schedules', 'update-schedules', 'delete-schedules',
- 'view-attendances', 'create-attendances', 'delete-attendances',
+ 'view-attendances', 'create-attendances', 'delete-attendances', 'view-own-attendances',
];
public const MANAGE = [
diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php
index c3f7ca2..d562c75 100644
--- a/database/seeders/RolePermissionSeeder.php
+++ b/database/seeders/RolePermissionSeeder.php
@@ -50,6 +50,7 @@ public function run(): void
'view-materials',
'view-assignments',
'submit-assignments',
+ 'view-own-attendances',
...$feedbackSelfService,
],
'dosen' => [
@@ -71,6 +72,9 @@ public function run(): void
'delete-assignments',
'view-assignment-submissions',
'update-assignment-submissions',
+ 'view-attendances',
+ 'create-attendances',
+ 'delete-attendances',
...$feedbackSelfService,
],
'staff-admin' => [
diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx
index a3fb7c0..3a25804 100644
--- a/resources/js/components/app-sidebar.tsx
+++ b/resources/js/components/app-sidebar.tsx
@@ -36,7 +36,10 @@ import {
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { index as assignmentsRoute } from '@/routes/admin/academic-classes/assignments';
-import { index as attendancesRoute } from '@/routes/admin/academic-classes/attendances';
+import {
+ index as attendancesRoute,
+ mine as myAttendancesRoute,
+} from '@/routes/admin/academic-classes/attendances';
import { index as materialsRoute } from '@/routes/admin/academic-classes/materials';
import { index as schedulesRoute } from '@/routes/admin/academic-classes/schedules';
import { index as logsRoute } from '@/routes/admin/developer/logs';
@@ -172,6 +175,15 @@ function buildNavMain({
},
]
: []),
+ ...(can('view-own-attendances')
+ ? [
+ {
+ name: 'Riwayat Kehadiran',
+ url: myAttendancesRoute.url(),
+ icon: ClipboardCheck,
+ },
+ ]
+ : []),
];
const keuanganItems: NavItem[] = [
diff --git a/resources/js/pages/admin/academic-classes/attendances/course-class.tsx b/resources/js/pages/admin/academic-classes/attendances/course-class.tsx
new file mode 100644
index 0000000..9e06fb0
--- /dev/null
+++ b/resources/js/pages/admin/academic-classes/attendances/course-class.tsx
@@ -0,0 +1,255 @@
+import { Head, Link, router } from '@inertiajs/react';
+import { format } from 'date-fns';
+import { ArrowLeft, ClipboardCheck, Pencil, 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 { usePermissions } from '@/hooks/use-permissions';
+import {
+ destroy,
+ index as attendanceIndex,
+ session,
+} from '@/routes/admin/academic-classes/attendances';
+import { formatAcademicTermLabel } from '@/types/academic-term';
+import type {
+ AttendanceCourseClass,
+ AttendanceSession,
+} from '@/types/attendance';
+
+type Props = {
+ courseClass: AttendanceCourseClass;
+ sessions: AttendanceSession[];
+ nextMeetingNumber: number;
+};
+
+function courseClassLabel(courseClass: AttendanceCourseClass): string {
+ return `${courseClass.course?.code ?? ''} ${courseClass.course?.name ?? ''}`;
+}
+
+export default function AttendanceCourseClass({
+ courseClass,
+ sessions,
+ nextMeetingNumber,
+}: Props) {
+ const [newSessionOpen, setNewSessionOpen] = useState(false);
+ const [deleting, setDeleting] = useState
+ Belum ada pertemuan yang tercatat. +
+ ) : ( ++ {format( + new Date(item.date), + 'd MMM yyyy', + )} +
+- Belum ada data kehadiran. + {courseClasses.length === 0 ? ( +
+ Tidak ada kelas untuk periode ini.
) : ( -- Pertemuan ke- - {item.meeting_number ?? '-'}{' '} - ·{' '} - {format( - new Date(item.date), - 'd MMM yyyy', - )} -
-+ Belum ada kelas yang terdaftar. +
+ ) : ( +