From 88286fe596d9b3bc53d0b5468743a9f857571607 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Thu, 3 Sep 2026 10:12:58 +0700 Subject: [PATCH] feat: implement attendance management features including user-specific views and permissions --- .../AcademicClasses/AttendanceController.php | 61 +++- .../AttendanceStoreRequest.php | 13 +- .../AcademicClasses/AttendanceService.php | 80 ++++- app/Support/PermissionCatalog.php | 2 +- database/seeders/RolePermissionSeeder.php | 4 + resources/js/components/app-sidebar.tsx | 14 +- .../attendances/course-class.tsx | 255 +++++++++++++ .../academic-classes/attendances/index.tsx | 340 ++++++------------ .../academic-classes/attendances/mine.tsx | 150 ++++++++ .../academic-classes/attendances/session.tsx | 7 +- resources/js/types/attendance.ts | 28 +- routes/admin.php | 2 + 12 files changed, 694 insertions(+), 262 deletions(-) create mode 100644 resources/js/pages/admin/academic-classes/attendances/course-class.tsx create mode 100644 resources/js/pages/admin/academic-classes/attendances/mine.tsx 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(null); + const { hasPermission } = usePermissions(); + const canCreate = hasPermission('create-attendances'); + const canDelete = hasPermission('delete-attendances'); + + function handleDelete() { + if (!deleting) { + return; + } + + router.delete(destroy([courseClass.id, deleting.meeting_number]).url, { + onSuccess: () => setDeleting(null), + }); + } + + return ( + <> + + +
+ + {formatAcademicTermLabel( + courseClass.academic_term, + )} + + ) + } + actions={ +
+ + {canCreate && ( + + )} +
+ } + /> + + {sessions.length === 0 ? ( +

+ Belum ada pertemuan yang tercatat. +

+ ) : ( +
+ {sessions.map((item) => ( + + + + Pertemuan ke-{item.meeting_number} + + + {item.present_count}/{item.total_count}{' '} + Hadir + + + +

+ {format( + new Date(item.date), + 'd MMM yyyy', + )} +

+
+ + {canDelete && ( + + )} +
+
+
+ ))} +
+ )} + + + + { + if (!open) { + setDeleting(null); + } + }} + title="Hapus Sesi Kehadiran" + description={(item) => + `Apakah Anda yakin ingin menghapus seluruh data kehadiran pertemuan ke-${item.meeting_number}? Tindakan ini tidak dapat dibatalkan.` + } + onConfirm={handleDelete} + /> +
+ + ); +} + +function NewSessionDialog({ + open, + onOpenChange, + courseClassId, + defaultMeetingNumber, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + courseClassId: number; + defaultMeetingNumber: number; +}) { + const [meetingNumber, setMeetingNumber] = useState( + String(defaultMeetingNumber), + ); + + function handleOpenSession() { + if (!meetingNumber) { + return; + } + + router.get(session([courseClassId, Number(meetingNumber)]).url); + } + + return ( + { + onOpenChange(value); + + if (!value) { + setMeetingNumber(String(defaultMeetingNumber)); + } + }} + > + + + Tambah Pertemuan + +
+
+ + setMeetingNumber(e.target.value)} + autoFocus + /> +
+
+ + + + +
+
+ ); +} diff --git a/resources/js/pages/admin/academic-classes/attendances/index.tsx b/resources/js/pages/admin/academic-classes/attendances/index.tsx index fcbd6e6..5b9c275 100644 --- a/resources/js/pages/admin/academic-classes/attendances/index.tsx +++ b/resources/js/pages/admin/academic-classes/attendances/index.tsx @@ -1,60 +1,73 @@ -import { Head, Link, router } from '@inertiajs/react'; -import { format } from 'date-fns'; -import { ClipboardCheck, Pencil, Plus, Trash2, X } from 'lucide-react'; -import { useState } from 'react'; -import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog'; +import { Head, Link } from '@inertiajs/react'; +import type { FilterField } from '@/components/filter-dialog'; +import { FilterDialog } from '@/components/filter-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 { useServerTable } from '@/hooks/use-server-table'; 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 { usePermissions } from '@/hooks/use-permissions'; -import { destroy, session } from '@/routes/admin/academic-classes/attendances'; -import type { - AttendanceCourseClass, - AttendanceSession, -} from '@/types/attendance'; + index as attendanceIndex, + show, +} from '@/routes/admin/academic-classes/attendances'; +import { formatAcademicTermLabel } from '@/types/academic-term'; +import type { AttendanceCourseClassOverview } from '@/types/attendance'; -type Props = { - sessions: AttendanceSession[]; - courseClasses: AttendanceCourseClass[]; +type AcademicTermOption = { + id: number; + academic_year: string; + semester: string; }; -function courseClassLabel(courseClass: AttendanceCourseClass): string { +type Props = { + courseClasses: AttendanceCourseClassOverview[]; + academicTerms: AcademicTermOption[]; + filters: { + academic_term_id?: string; + }; +}; + +function courseClassLabel(courseClass: AttendanceCourseClassOverview): string { return `${courseClass.course?.code ?? ''} ${courseClass.course?.name ?? ''}`; } -export default function AttendanceIndex({ sessions, courseClasses }: Props) { - const [newSessionOpen, setNewSessionOpen] = useState(false); - const [deleting, setDeleting] = useState(null); - const { hasPermission } = usePermissions(); - const canCreate = hasPermission('create-attendances'); - const canDelete = hasPermission('delete-attendances'); +export default function AttendanceIndex({ + courseClasses, + academicTerms, + filters, +}: Props) { + const filterFields: FilterField[] = [ + { + key: 'academic_term_id', + label: 'Periode Akademik', + options: academicTerms.map((term) => ({ + value: String(term.id), + label: formatAcademicTermLabel(term), + })), + }, + ]; - function handleDelete() { - if (!deleting || deleting.meeting_number === null) { - return; - } + const { applyFilters } = useServerTable({ + route: () => attendanceIndex.url(), + pagination: { + current_page: 1, + last_page: 1, + per_page: 999999, + total: courseClasses.length, + }, + filters, + }); - router.delete( - destroy([deleting.course_class_id, deleting.meeting_number]).url, - { onSuccess: () => setDeleting(null) }, - ); + function handleApplyFilters(newFilters: Record) { + // Tanpa `academic_term_id` eksplisit, backend akan kembali ke + // periode aktif, jadi menghapus filter ini perlu dikirim eksplisit + // alih-alih hanya menghilangkan key-nya. + const clearedAcademicTerm = + Boolean(filters.academic_term_id) && !newFilters.academic_term_id; + + applyFilters({ + ...newFilters, + ...(clearedAcademicTerm ? { academic_term_id: '' } : {}), + }); } return ( @@ -65,218 +78,65 @@ export default function AttendanceIndex({ sessions, courseClasses }: Props) { setNewSessionOpen(true)}> - - Ambil Kehadiran - - ) + } /> - {sessions.length === 0 ? ( -

- Belum ada data kehadiran. + {courseClasses.length === 0 ? ( +

+ Tidak ada kelas untuk periode ini.

) : ( -
- {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, - ) - : '-'} +
+ {courseClasses.map((courseClass) => ( + + + + + {courseClassLabel(courseClass)} - - {item.present_count}/ - {item.total_count} Hadir - -

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

-
- - {canDelete && ( - + {formatAcademicTermLabel( + courseClass.academic_term, + )} + )}
- ); - })} + + ))}
)} - - - - { - 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/academic-classes/attendances/mine.tsx b/resources/js/pages/admin/academic-classes/attendances/mine.tsx new file mode 100644 index 0000000..faf3fd8 --- /dev/null +++ b/resources/js/pages/admin/academic-classes/attendances/mine.tsx @@ -0,0 +1,150 @@ +import { Head } from '@inertiajs/react'; +import { format } from 'date-fns'; +import { PageHeader } from '@/components/page-header'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { formatAcademicTermLabel } from '@/types/academic-term'; +import type { AttendanceClassSummary } from '@/types/attendance'; +import { AttendanceStatusLabels } from '@/types/attendance'; + +type Props = { + summaries: AttendanceClassSummary[]; +}; + +function courseClassLabel( + courseClass: AttendanceClassSummary['course_class'], +): string { + return `${courseClass.course?.code ?? ''} ${courseClass.course?.name ?? ''}`; +} + +function percentageOf(part: number, total: number): number { + return total > 0 ? Math.round((part / total) * 100) : 0; +} + +export default function MyAttendance({ summaries }: Props) { + return ( + <> + + +
+ + + {summaries.length === 0 ? ( +

+ Belum ada kelas yang terdaftar. +

+ ) : ( +
+ {summaries.map((summary) => ( + + + + {courseClassLabel(summary.course_class)} + + + +
+ = 75 + ? 'secondary' + : 'destructive' + } + className="w-fit text-[10px] font-normal" + > + Hadir {summary.present_count} /{' '} + {summary.total_count} ( + {percentageOf( + summary.present_count, + summary.total_count, + )} + %) + + {summary.course_class.academic_term && ( + + {formatAcademicTermLabel( + summary.course_class + .academic_term, + )} + + )} +
+ + {summary.records.length > 0 && ( + + + + Detail Pertemuan + + +
+ {summary.records.map( + (record) => ( +
+ + Pertemuan + ke- + {record.meeting_number ?? + '-'}{' '} + ·{' '} + {format( + new Date( + record.date, + ), + 'd MMM yyyy', + )} + + + { + AttendanceStatusLabels[ + record + .status + ] + } + +
+ ), + )} +
+
+
+
+ )} +
+
+ ))} +
+ )} +
+ + ); +} diff --git a/resources/js/pages/admin/academic-classes/attendances/session.tsx b/resources/js/pages/admin/academic-classes/attendances/session.tsx index 2fa3dc6..d05515d 100644 --- a/resources/js/pages/admin/academic-classes/attendances/session.tsx +++ b/resources/js/pages/admin/academic-classes/attendances/session.tsx @@ -8,10 +8,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; import { usePermissions } from '@/hooks/use-permissions'; -import { - index as attendanceIndex, - store, -} from '@/routes/admin/academic-classes/attendances'; +import { show, store } from '@/routes/admin/academic-classes/attendances'; import type { AttendanceCourseClass, AttendanceRosterEntry, @@ -89,7 +86,7 @@ export default function AttendanceSession({ title="Kehadiran" actions={