Merge pull request 'feat: add attendance management functionality with CRUD operations' (#24) from feat/add-attendance-management into dev

Reviewed-on: #24
This commit is contained in:
pangestu 2026-08-24 19:47:27 +08:00
commit 605f451a8e
15 changed files with 833 additions and 0 deletions

View File

@ -0,0 +1,17 @@
<?php
namespace App\Enums;
enum AttendanceStatus: string
{
case Present = 'present';
case Absent = 'absent';
public function label(): string
{
return match ($this) {
self::Present => 'Hadir',
self::Absent => 'Tidak Hadir',
};
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Admin\Manage;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\AttendanceStoreRequest;
use App\Models\CourseClass;
use App\Services\Admin\Manage\AttendanceService;
use App\Services\Admin\Manage\CourseClassService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class AttendanceController extends Controller
{
public function __construct(
private readonly AttendanceService $service,
private readonly CourseClassService $courseClassService,
) {}
public function index(): Response
{
return Inertia::render('admin/manage/attendances/index', [
'sessions' => $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');
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use App\Enums\AttendanceStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class AttendanceStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'date' => ['required', 'date'],
'records' => ['required', 'array', 'min:1'],
'records.*.student_id' => ['required', 'integer', Rule::exists('students', 'id')],
'records.*.status' => ['required', Rule::enum(AttendanceStatus::class)],
];
}
}

34
app/Models/Attendance.php Normal file
View File

@ -0,0 +1,34 @@
<?php
namespace App\Models;
use App\Enums\AttendanceStatus;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Guarded(['id'])]
class Attendance extends Model
{
use HasFactory;
protected function casts(): array
{
return [
'date' => '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);
}
}

View File

@ -56,4 +56,9 @@ public function schedules(): HasMany
{
return $this->hasMany(Schedule::class);
}
public function attendances(): HasMany
{
return $this->hasMany(Attendance::class);
}
}

View File

@ -46,4 +46,9 @@ public function submissions(): HasMany
{
return $this->hasMany(Submission::class);
}
public function attendances(): HasMany
{
return $this->hasMany(Attendance::class);
}
}

View File

@ -0,0 +1,77 @@
<?php
namespace App\Services\Admin\Manage;
use App\Enums\AttendanceStatus;
use App\Models\Attendance;
use App\Models\CourseClass;
use Illuminate\Database\Eloquent\Collection;
class AttendanceService
{
public function sessions(): Collection
{
return Attendance::query()
->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();
}
}

View File

@ -0,0 +1,30 @@
<?php
use App\Enums\AttendanceStatus;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('attendances', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,35 @@
<?php
namespace Database\Seeders;
use App\Models\Attendance;
use Illuminate\Database\Seeder;
class AttendanceSeeder extends Seeder
{
public function run(): void
{
Attendance::insert([
[
'course_class_id' => 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',
],
]);
}
}

View File

@ -24,6 +24,7 @@ public function run(): void
AssignmentSeeder::class,
SubmissionSeeder::class,
ScheduleSeeder::class,
AttendanceSeeder::class,
]);
}
}

View File

@ -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,
},
],
},
{

View File

@ -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<AttendanceSession | null>(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 (
<>
<Head title="Kehadiran" />
<div className="flex h-full flex-1 flex-col gap-6 p-4 md:p-6">
<PageHeader
title="Kehadiran"
actions={
<Button onClick={() => setNewSessionOpen(true)}>
<Plus className="h-4 w-4" />
Ambil Kehadiran
</Button>
}
/>
{sessions.length === 0 ? (
<p className="text-sm text-muted-foreground">
Belum ada data kehadiran.
</p>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{sessions.map((item) => {
const key = `${item.course_class_id}-${item.meeting_number}-${item.date}`;
const canOpen = item.meeting_number !== null;
return (
<Card key={key}>
<CardHeader className="flex flex-row items-start justify-between gap-2">
<CardTitle className="text-sm leading-tight">
{item.course_class
? courseClassLabel(
item.course_class,
)
: '-'}
</CardTitle>
<Badge variant="secondary">
{item.present_count}/
{item.total_count} Hadir
</Badge>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<p className="text-xs text-muted-foreground">
Pertemuan ke-
{item.meeting_number ?? '-'}{' '}
&middot;{' '}
{format(
new Date(item.date),
'd MMM yyyy',
)}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
asChild
disabled={!canOpen}
>
{canOpen ? (
<Link
href={
session([
item.course_class_id,
item.meeting_number as number,
]).url
}
>
Lihat / Edit
</Link>
) : (
<span>Lihat / Edit</span>
)}
</Button>
<Button
variant="ghost"
size="sm"
disabled={!canOpen}
onClick={() =>
setDeleting(item)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
<NewSessionDialog
open={newSessionOpen}
onOpenChange={setNewSessionOpen}
courseClasses={courseClasses}
/>
<DeleteConfirmDialog
target={deleting}
onOpenChange={(open) => {
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}
/>
</div>
</>
);
}
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 (
<Dialog
open={open}
onOpenChange={(value) => {
onOpenChange(value);
if (!value) {
reset();
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Ambil Kehadiran</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>
Kelas <span className="text-destructive">*</span>
</Label>
<Select
value={courseClassId}
onValueChange={setCourseClassId}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih kelas" />
</SelectTrigger>
<SelectContent>
{courseClasses.map((courseClass) => (
<SelectItem
key={courseClass.id}
value={String(courseClass.id)}
>
{courseClassLabel(courseClass)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="meeting_number">
Pertemuan Ke-{' '}
<span className="text-destructive">*</span>
</Label>
<Input
id="meeting_number"
type="number"
min={1}
placeholder="Contoh: 1"
value={meetingNumber}
onChange={(e) => setMeetingNumber(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Batal
</Button>
<Button
type="button"
disabled={!courseClassId || !meetingNumber}
onClick={handleOpenSession}
>
<ClipboardCheck className="h-4 w-4" />
Buka Kehadiran
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -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 | undefined>(
date ? new Date(date) : new Date(),
);
const [statuses, setStatuses] = useState<Record<number, AttendanceStatus>>(
() =>
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 (
<>
<Head title="Kehadiran" />
<div className="flex h-full flex-1 flex-col gap-6 p-4 md:p-6">
<PageHeader
title="Kehadiran"
actions={
<Button variant="outline" asChild>
<a href={attendanceIndex.url()}>Kembali</a>
</Button>
}
/>
<Card>
<CardHeader>
<CardTitle>{courseClassLabel(courseClass)}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">
Pertemuan ke-{meetingNumber}
</p>
<div className="grid max-w-xs gap-2">
<Label>Tanggal</Label>
<DatePicker
value={selectedDate}
onChange={setSelectedDate}
placeholder="Pilih tanggal"
/>
</div>
</CardContent>
</Card>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Daftar Mahasiswa</h2>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setAll('present')}
>
<CheckCircle2 className="h-4 w-4" />
Tandai Semua Hadir
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setAll('absent')}
>
<XCircle className="h-4 w-4" />
Tandai Semua Tidak Hadir
</Button>
</div>
</div>
{roster.length === 0 ? (
<p className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
Belum ada mahasiswa yang terdaftar di kelas ini.
</p>
) : (
<div className="flex flex-col divide-y rounded-md border">
{roster.map((entry) => (
<div
key={entry.student_id}
className="flex items-center justify-between gap-4 px-4 py-3"
>
<div>
<p className="text-sm font-medium">
{entry.student?.user?.profile
?.full_name ?? 'N/A'}
</p>
<p className="text-xs text-muted-foreground">
{entry.student?.student_number}
</p>
</div>
<div className="flex items-center gap-1 rounded-md border p-1">
<Button
type="button"
size="sm"
variant={
statuses[entry.student_id] ===
'present'
? 'default'
: 'ghost'
}
onClick={() =>
setStatuses((prev) => ({
...prev,
[entry.student_id]: 'present',
}))
}
>
{AttendanceStatusLabels.present}
</Button>
<Button
type="button"
size="sm"
variant={
statuses[entry.student_id] ===
'absent'
? 'default'
: 'ghost'
}
onClick={() =>
setStatuses((prev) => ({
...prev,
[entry.student_id]: 'absent',
}))
}
>
{AttendanceStatusLabels.absent}
</Button>
</div>
</div>
))}
</div>
)}
{roster.length > 0 && (
<div className="flex justify-end">
<Button
onClick={handleSave}
disabled={saving || !selectedDate}
>
<Save className="h-4 w-4" />
{saving ? 'Menyimpan...' : 'Simpan Kehadiran'}
</Button>
</div>
)}
</div>
</>
);
}

View File

@ -0,0 +1,35 @@
export const AttendanceStatuses = ['present', 'absent'] as const;
export type AttendanceStatus = (typeof AttendanceStatuses)[number];
export const AttendanceStatusLabels: Record<AttendanceStatus, string> = {
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;
};

View File

@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\Admin\Manage\AssignmentController;
use App\Http\Controllers\Admin\Manage\AttendanceController;
use App\Http\Controllers\Admin\Manage\ClassEnrollmentController;
use App\Http\Controllers\Admin\Manage\CourseClassController;
use App\Http\Controllers\Admin\Manage\CourseController;
@ -42,6 +43,19 @@
});
Route::resource('schedules', ScheduleController::class)->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 () {