From 31ef9c0b0b1ef42ecb1d05e44b8075bfe21366e5 Mon Sep 17 00:00:00 2001 From: Bro Date: Wed, 22 Jul 2026 23:55:27 +0700 Subject: [PATCH] system-locked violation types (alpha & terlambat) + proporsional poin alpha per jam - Migration: add is_system column to violation_types, mark alpha & terlambat - DestroyType guard: system types cannot be deleted - UpdateType guard: slug tetap walau nama diubah untuk system types - UI: delete button hidden, badge lock untuk system types - Seeder: ViolationTypeDefaultsSeeder untuk fresh deploy - AttendanceController: lookup by slug (alpha/terlambat) instead of fuzzy match - Alpha auto-violation: poin proporsional per jam (round((points/10)*count), min 1) - Terlambat auto-violation: poin fix sesuai jenis (2 poin) - Also handles both A (alpha) and T (terlambat) statuses for auto-violations --- .../Controllers/Admin/BackupController.php | 194 ++++++++++++ .../Admin/MasterDataController.php | 51 +++- .../Controllers/Admin/ResetController.php | 52 +++- app/Http/Controllers/AttendanceController.php | 205 +++++++++++++ app/Models/Attendance.php | 32 ++ ..._07_22_161010_create_attendances_table.php | 31 ++ ...add_is_system_to_violation_types_table.php | 50 ++++ .../seeders/ViolationTypeDefaultsSeeder.php | 53 ++++ resources/views/attendances/create.blade.php | 276 ++++++++++++++++++ resources/views/attendances/index.blade.php | 118 ++++++++ resources/views/attendances/recap.blade.php | 132 +++++++++ resources/views/components/nav-item.blade.php | 2 + resources/views/layouts/app.blade.php | 2 + resources/views/settings/backup.blade.php | 199 +++++++++++++ resources/views/settings/index.blade.php | 37 +++ resources/views/settings/reset.blade.php | 20 ++ resources/views/settings/thresholds.blade.php | 236 ++++++++++++--- .../views/settings/violation-types.blade.php | 23 +- routes/web.php | 12 + 19 files changed, 1674 insertions(+), 51 deletions(-) create mode 100644 app/Http/Controllers/Admin/BackupController.php create mode 100644 app/Http/Controllers/AttendanceController.php create mode 100644 app/Models/Attendance.php create mode 100644 database/migrations/2026_07_22_161010_create_attendances_table.php create mode 100644 database/migrations/2026_07_22_164059_add_is_system_to_violation_types_table.php create mode 100644 database/seeders/ViolationTypeDefaultsSeeder.php create mode 100644 resources/views/attendances/create.blade.php create mode 100644 resources/views/attendances/index.blade.php create mode 100644 resources/views/attendances/recap.blade.php create mode 100644 resources/views/settings/backup.blade.php diff --git a/app/Http/Controllers/Admin/BackupController.php b/app/Http/Controllers/Admin/BackupController.php new file mode 100644 index 0000000..91d1a54 --- /dev/null +++ b/app/Http/Controllers/Admin/BackupController.php @@ -0,0 +1,194 @@ +user()?->role === 'admin', 403, 'Hanya admin yang dapat mengakses fitur backup.'); + + $backups = collect(); + + if (Storage::disk('local')->exists($this->backupDir)) { + $files = Storage::disk('local')->files($this->backupDir); + + $backups = collect($files) + ->filter(fn($f) => str_ends_with($f, '.sql.gz')) + ->map(function ($path) { + $fullPath = Storage::disk('local')->path($path); + return (object) [ + 'filename' => basename($path), + 'path' => $path, + 'size' => $this->formatBytes(filesize($fullPath)), + 'size_bytes' => filesize($fullPath), + 'date' => date('Y-m-d H:i:s', filemtime($fullPath)), + 'timestamp' => filemtime($fullPath), + ]; + }) + ->sortByDesc('timestamp') + ->values(); + } + + $dbSize = $this->getDatabaseSize(); + $stats = [ + 'total_backups' => $backups->count(), + 'latest_backup' => $backups->first()?->date ?? 'Belum pernah', + 'total_size' => $this->formatBytes($backups->sum('size_bytes')), + 'db_size' => $dbSize, + ]; + + return view('settings.backup', compact('backups', 'stats')); + } + + public function create(): RedirectResponse + { + abort_unless(auth()->user()?->role === 'admin', 403); + + $db = config('database.connections.mysql'); + $filename = 'aplikasi_ketertiban_' . now()->format('Y-m-d_His') . '.sql.gz'; + $outputPath = Storage::disk('local')->path($this->backupDir . '/' . $filename); + + if (!is_dir(dirname($outputPath))) { + mkdir(dirname($outputPath), 0755, true); + } + + $command = sprintf( + 'mysqldump --host=%s --port=%s --user=%s --password=%s %s --routines --events --single-transaction --quick | gzip > %s 2>&1', + escapeshellarg($db['host']), + escapeshellarg($db['port']), + escapeshellarg($db['username']), + escapeshellarg($db['password']), + escapeshellarg($db['database']), + escapeshellarg($outputPath) + ); + + $output = null; + $exitCode = null; + exec($command, $output, $exitCode); + + if ($exitCode !== 0 || !file_exists($outputPath)) { + return redirect()->route('settings.backup') + ->with('error', 'Backup gagal: ' . implode("\n", $output)); + } + + return redirect()->route('settings.backup') + ->with('success', 'Backup berhasil: ' . $filename); + } + + public function download(string $filename) + { + abort_unless(auth()->user()?->role === 'admin', 403); + + $path = $this->backupDir . '/' . basename($filename); + + if (!Storage::disk('local')->exists($path)) { + abort(404, 'File backup tidak ditemukan.'); + } + + return Storage::disk('local')->download($path, $filename); + } + + public function destroy(string $filename) + { + abort_unless(auth()->user()?->role === 'admin', 403); + + $path = $this->backupDir . '/' . basename($filename); + + if (!Storage::disk('local')->exists($path)) { + return redirect()->route('settings.backup') + ->with('error', 'File backup tidak ditemukan.'); + } + + Storage::disk('local')->delete($path); + + return redirect()->route('settings.backup') + ->with('success', 'Backup ' . $filename . ' berhasil dihapus.'); + } + + public function restore(Request $request): RedirectResponse + { + abort_unless(auth()->user()?->role === 'admin', 403); + + $filename = basename($request->input('filename')); + $path = $this->backupDir . '/' . $filename; + + if (!Storage::disk('local')->exists($path)) { + return redirect()->route('settings.backup') + ->with('error', 'File backup tidak ditemukan.'); + } + + $fullPath = Storage::disk('local')->path($path); + $db = config('database.connections.mysql'); + + // Backup otomatis sebelum restore + $autoBackup = 'pre_restore_' . now()->format('Y-m-d_His') . '.sql.gz'; + $autoPath = Storage::disk('local')->path($this->backupDir . '/' . $autoBackup); + exec(sprintf( + 'mysqldump --host=%s --port=%s --user=%s --password=%s %s --routines --events --single-transaction --quick | gzip > %s', + escapeshellarg($db['host']), + escapeshellarg($db['port']), + escapeshellarg($db['username']), + escapeshellarg($db['password']), + escapeshellarg($db['database']), + escapeshellarg($autoPath) + )); + + // Restore + $command = sprintf( + 'gunzip -c %s | mysql --host=%s --port=%s --user=%s --password=%s %s 2>&1', + escapeshellarg($fullPath), + escapeshellarg($db['host']), + escapeshellarg($db['port']), + escapeshellarg($db['username']), + escapeshellarg($db['password']), + escapeshellarg($db['database']) + ); + + $output = null; + $exitCode = null; + exec($command, $output, $exitCode); + + if ($exitCode !== 0) { + return redirect()->route('settings.backup') + ->with('error', 'Restore gagal: ' . implode("\n", $output)); + } + + return redirect()->route('settings.backup') + ->with('success', 'Database berhasil direstore dari ' . $filename . '. Backup otomatis sebelum restore: ' . $autoBackup); + } + + private function formatBytes(int $bytes): string + { + $units = ['B', 'KB', 'MB', 'GB']; + $i = 0; + while ($bytes >= 1024 && $i < count($units) - 1) { + $bytes /= 1024; + $i++; + } + return round($bytes, 2) . ' ' . $units[$i]; + } + + private function getDatabaseSize(): string + { + $db = config('database.connections.mysql'); + $command = sprintf( + "mysql --host=%s --port=%s --user=%s --password=%s -e \"SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS size_mb FROM information_schema.tables WHERE table_schema = '%s'\" -sN 2>/dev/null", + escapeshellarg($db['host']), + escapeshellarg($db['port']), + escapeshellarg($db['username']), + escapeshellarg($db['password']), + escapeshellarg($db['database']) + ); + $size = trim(shell_exec($command)); + return $size ? $size . ' MB' : '—'; + } +} diff --git a/app/Http/Controllers/Admin/MasterDataController.php b/app/Http/Controllers/Admin/MasterDataController.php index 448fcba..8a9f112 100644 --- a/app/Http/Controllers/Admin/MasterDataController.php +++ b/app/Http/Controllers/Admin/MasterDataController.php @@ -117,7 +117,10 @@ class MasterDataController extends Controller 'is_active' => ['boolean'], ]); - $validated['slug'] = Str::slug($validated['name']); + // System types: slug tetap, nama bisa diubah tapi slug gak ikut berubah + if (!$type->is_system) { + $validated['slug'] = Str::slug($validated['name']); + } $validated['is_active'] = $request->boolean('is_active'); $type->update($validated); @@ -127,6 +130,10 @@ class MasterDataController extends Controller public function destroyType(ViolationType $type): RedirectResponse { + if ($type->is_system) { + return back()->with('error', 'Jenis pelanggaran sistem tidak bisa dihapus. Nonaktifkan saja jika tidak diperlukan.'); + } + if ($type->violations()->count() > 0) { return back()->with('error', 'Jenis pelanggaran ini sudah tercatat pada pelanggaran siswa. Nonaktifkan saja.'); } @@ -154,15 +161,53 @@ class MasterDataController extends Controller 'thresholds.*.default_description' => ['nullable', 'string', 'max:500'], ]); + $inputs = $request->input('thresholds', []); + $idx = 0; foreach ($thresholds['thresholds'] as $data) { - SpThreshold::where('id', $data['id'])->update([ + $update = [ 'name' => $data['name'], 'min_points' => $data['min_points'], 'max_points' => $data['max_points'], 'default_description' => $data['default_description'], - ]); + 'is_active' => isset($inputs[$idx]['is_active']), + ]; + + SpThreshold::where('id', $data['id'])->update($update); + $idx++; } return back()->with('success', 'Ambang batas SP berhasil diperbarui.'); } + + public function storeThreshold(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'min_points' => ['required', 'integer', 'min:0'], + 'max_points' => ['nullable', 'integer', 'min:0'], + 'default_description' => ['nullable', 'string', 'max:500'], + 'color' => ['nullable', 'string', 'max:20'], + ]); + + $slug = str($data['name'])->slug('-'); + SpThreshold::create([ + 'name' => $data['name'], + 'slug' => $slug, + 'min_points' => $data['min_points'], + 'max_points' => $data['max_points'], + 'default_description' => $data['default_description'], + 'color' => $data['color'] ?? '#6b7280', + 'is_active' => true, + ]); + + return redirect()->route('settings.thresholds')->with('success', 'Threshold ' . $data['name'] . ' berhasil ditambahkan.'); + } + + public function destroyThreshold(SpThreshold $threshold): RedirectResponse + { + $name = $threshold->name; + $threshold->delete(); + + return redirect()->route('settings.thresholds')->with('success', 'Threshold ' . $name . ' berhasil dihapus.'); + } } diff --git a/app/Http/Controllers/Admin/ResetController.php b/app/Http/Controllers/Admin/ResetController.php index 35a5839..b4d0689 100644 --- a/app/Http/Controllers/Admin/ResetController.php +++ b/app/Http/Controllers/Admin/ResetController.php @@ -20,6 +20,7 @@ class ResetController extends Controller $stats = [ 'violations' => DB::table('violations')->count(), 'evidences' => DB::table('violation_evidences')->count(), + 'handlings' => DB::table('violation_handlings')->count(), 'sp_letters' => DB::table('sp_letters')->count(), 'notifications' => DB::table('notifications')->count(), 'students' => DB::table('students')->count(), @@ -29,6 +30,7 @@ class ResetController extends Controller 'types' => DB::table('violation_types')->count(), 'thresholds' => DB::table('sp_thresholds')->count(), 'users_other' => DB::table('users')->where('role', '!=', 'admin')->count(), + 'backups' => $this->countBackupFiles(), ]; return view('settings.reset', compact('stats')); @@ -39,7 +41,7 @@ class ResetController extends Controller $request->validate([ 'confirm_password' => ['required', 'string'], 'reset_items' => ['required', 'array', 'min:1'], - 'reset_items.*' => ['in:violations,evidences,sp_letters,notifications,students,classes,categories,types,thresholds,settings,users'], + 'reset_items.*' => ['in:violations,evidences,handlings,sp_letters,notifications,students,classes,categories,types,thresholds,settings,users,backups'], ]); if (!Hash::check($request->confirm_password, $request->user()->password)) { @@ -53,12 +55,28 @@ class ResetController extends Controller $cleared = []; + if (in_array('handlings', $items)) { + DB::table('handling_participants')->delete(); + DB::table('violation_handlings')->delete(); + // Juga reset handling_status di violations + if (!in_array('violations', $items)) { + DB::table('violations')->update([ + 'handling_status' => 'unhandled', + 'handled_at' => null, + 'handled_by' => null, + ]); + } + $cleared[] = 'Riwayat penanganan'; + } + if (in_array('evidences', $items)) { DB::table('violation_evidences')->delete(); $cleared[] = 'Foto bukti'; } if (in_array('violations', $items)) { + DB::table('handling_participants')->delete(); + DB::table('violation_handlings')->delete(); DB::table('violations')->delete(); $cleared[] = 'Pelanggaran'; } @@ -133,6 +151,18 @@ class ResetController extends Controller } } + // Hapus file backup jika backups dipilih + if (in_array('backups', $items)) { + $backupDir = storage_path('app/backups'); + if (is_dir($backupDir)) { + $files = glob($backupDir . '/*.sql.gz'); + foreach ($files as $f) { + unlink($f); + } + } + $cleared[] = 'File backup'; + } + $msg = 'Reset berhasil! Data yang dibersihkan: ' . implode(', ', $cleared) . '.'; if ($admin) { @@ -142,12 +172,20 @@ class ResetController extends Controller return redirect()->route('settings.reset')->with('success', $msg); } + private function countBackupFiles(): int + { + $backupDir = storage_path('app/backups'); + if (!is_dir($backupDir)) return 0; + $files = glob($backupDir . '/*.sql.gz'); + return count($files); + } + private function seedDefaultCategories(): void { $categories = [ - ['name' => 'Ringan', 'description' => 'Pelanggaran ringan (poin 1-15)', 'color' => '#eab308', 'min_points' => 1, 'max_points' => 15, 'is_active' => true, 'sort_order' => 1, 'created_at' => now(), 'updated_at' => now()], - ['name' => 'Sedang', 'description' => 'Pelanggaran sedang (poin 15-50)', 'color' => '#f97316', 'min_points' => 15, 'max_points' => 50, 'is_active' => true, 'sort_order' => 2, 'created_at' => now(), 'updated_at' => now()], - ['name' => 'Berat', 'description' => 'Pelanggaran berat (poin 50-100)', 'color' => '#ef4444', 'min_points' => 50, 'max_points' => 100, 'is_active' => true, 'sort_order' => 3, 'created_at' => now(), 'updated_at' => now()], + ['name' => 'Ringan', 'slug' => 'ringan', 'description' => 'Pelanggaran ringan (poin 1-15)', 'color' => '#eab308', 'is_active' => true, 'sort_order' => 1, 'created_at' => now(), 'updated_at' => now()], + ['name' => 'Sedang', 'slug' => 'sedang', 'description' => 'Pelanggaran sedang (poin 15-50)', 'color' => '#f97316', 'is_active' => true, 'sort_order' => 2, 'created_at' => now(), 'updated_at' => now()], + ['name' => 'Berat', 'slug' => 'berat', 'description' => 'Pelanggaran berat (poin 50-100)', 'color' => '#ef4444', 'is_active' => true, 'sort_order' => 3, 'created_at' => now(), 'updated_at' => now()], ]; DB::table('violation_categories')->insert($categories); } @@ -155,9 +193,9 @@ class ResetController extends Controller private function seedDefaultThresholds(): void { $thresholds = [ - ['name' => 'SP 1', 'slug' => 'sp-1', 'min_points' => 50, 'max_points' => 99, 'color' => '#eab308', 'is_active' => true, 'description' => 'Surat Peringatan 1 — poin mencapai 50', 'created_at' => now(), 'updated_at' => now()], - ['name' => 'SP 2', 'slug' => 'sp-2', 'min_points' => 100, 'max_points' => 149, 'color' => '#f97316', 'is_active' => true, 'description' => 'Surat Peringatan 2 — poin mencapai 100', 'created_at' => now(), 'updated_at' => now()], - ['name' => 'SP 3', 'slug' => 'sp-3', 'min_points' => 150, 'max_points' => null, 'color' => '#ef4444', 'is_active' => true, 'description' => 'Surat Peringatan 3 — poin mencapai 150', 'created_at' => now(), 'updated_at' => now()], + ['name' => 'SP 1', 'slug' => 'sp-1', 'min_points' => 50, 'max_points' => 99, 'color' => '#eab308', 'is_active' => true, 'default_description' => 'Surat Peringatan 1 — poin mencapai 50', 'created_at' => now(), 'updated_at' => now()], + ['name' => 'SP 2', 'slug' => 'sp-2', 'min_points' => 100, 'max_points' => 149, 'color' => '#f97316', 'is_active' => true, 'default_description' => 'Surat Peringatan 2 — poin mencapai 100', 'created_at' => now(), 'updated_at' => now()], + ['name' => 'SP 3', 'slug' => 'sp-3', 'min_points' => 150, 'max_points' => null, 'color' => '#ef4444', 'is_active' => true, 'default_description' => 'Surat Peringatan 3 — poin mencapai 150', 'created_at' => now(), 'updated_at' => now()], ]; DB::table('sp_thresholds')->insert($thresholds); } diff --git a/app/Http/Controllers/AttendanceController.php b/app/Http/Controllers/AttendanceController.php new file mode 100644 index 0000000..e2b5957 --- /dev/null +++ b/app/Http/Controllers/AttendanceController.php @@ -0,0 +1,205 @@ +violationService = $violationService; + } + + public function index(Request $request): View + { + return view('attendances.index'); + } + + public function create(Request $request): View + { + $date = $request->input('date', now()->toDateString()); + $className = $request->input('class_name'); + + $students = collect(); + if ($className) { + $students = Student::where('is_active', true) + ->where('class_name', $className) + ->orderBy('full_name') + ->get(); + } + + // Load existing attendances for this class + date + $existing = collect(); + if ($students->isNotEmpty()) { + $existing = Attendance::where('date', $date) + ->whereIn('student_id', $students->pluck('id')) + ->get() + ->keyBy(fn($a) => $a->student_id . '-' . $a->lesson_hour); + } + + $classNames = Student::where('is_active', true) + ->whereNotNull('class_name') + ->distinct() + ->orderBy('class_name') + ->pluck('class_name'); + + $lessonHours = range(1, 10); + + return view('attendances.create', compact( + 'students', 'date', 'className', + 'classNames', 'lessonHours', 'existing' + )); + } + + public function store(Request $request): RedirectResponse + { + $request->validate([ + 'date' => ['required', 'date'], + 'class_name' => ['nullable', 'string'], + 'attendances' => ['required', 'array'], + 'auto_violation' => ['nullable', 'boolean'], + ]); + + $date = $request->input('date'); + $class_name = $request->input('class_name'); + $saved = 0; + $autoViolations = 0; + $alphaIds = []; + $terlambatIds = []; + + foreach ($request->input('attendances') as $studentId => $studentData) { + foreach ($studentData as $hour => $data) { + if (!isset($data['student_id'], $data['lesson_hour'], $data['status'])) { + continue; + } + $data['lesson_hour'] = (int) $data['lesson_hour']; + Attendance::updateOrCreate( + [ + 'student_id' => $data['student_id'], + 'date' => $date, + 'lesson_hour' => $data['lesson_hour'], + ], + [ + 'status' => $data['status'], + 'recorded_by' => $request->user()->id, + ] + ); + $saved++; + + if ($data['status'] === 'alpha') { + $alphaIds[$data['student_id']] = ($alphaIds[$data['student_id']] ?? 0) + 1; + } elseif ($data['status'] === 'terlambat') { + $terlambatIds[$data['student_id']] = ($terlambatIds[$data['student_id']] ?? 0) + 1; + } + } + } + + // Auto-generate violation untuk alpha & terlambat + if ($request->boolean('auto_violation') && (!empty($alphaIds) || !empty($terlambatIds))) { + $alphaType = ViolationType::where('slug', 'alpha')->first(); + $terlambatType = ViolationType::where('slug', 'terlambat')->first(); + + foreach ($alphaIds as $studentId => $count) { + if ($alphaType) { + $this->violationService->recordViolation([ + 'student_id' => $studentId, + 'violation_type_id' => $alphaType->id, + 'violation_date' => $date, + 'points' => max(1, (int) round(($alphaType->points / 10) * $count)), + 'description' => "Alpha - Tidak hadir tanpa keterangan ({$count} jam pelajaran)", + 'notes' => 'Dibuat otomatis dari presensi.', + 'evidences' => [], + ], $request->user()->id); + $autoViolations++; + } + } + + foreach ($terlambatIds as $studentId => $count) { + if ($terlambatType) { + $this->violationService->recordViolation([ + 'student_id' => $studentId, + 'violation_type_id' => $terlambatType->id, + 'violation_date' => $date, + 'points' => $terlambatType->points, + 'description' => "Terlambat ({$count} jam pelajaran)", + 'notes' => 'Dibuat otomatis dari presensi.', + 'evidences' => [], + ], $request->user()->id); + $autoViolations++; + } + } + + if (!$alphaType) { + $msgExtra = 'Jenis pelanggaran "Alpha" tidak ditemukan. Buat jenis pelanggaran dengan slug "alpha".'; + } + if (!$terlambatType) { + $msgExtra = (empty($msgExtra) ? '' : $msgExtra . ' ') + . 'Jenis pelanggaran "Terlambat" tidak ditemukan. Buat dengan slug "terlambat".'; + } + } + + $studentCount = count($request->input('attendances')); + $lessonHourCount = $saved / max($studentCount, 1); + $msg = "Presensi berhasil disimpan — {$studentCount} siswa × " . round($lessonHourCount) . " jam pelajaran."; + if ($autoViolations > 0) { + $msg .= " {$autoViolations} pelanggaran dibuat otomatis (alpha/terlambat)."; + } elseif (!empty($msgExtra)) { + $msg .= " Peringatan: {$msgExtra}"; + } + + return redirect()->route('attendances.create', [ + 'date' => $date, + 'class_name' => $class_name, + ])->with('success', $msg); + } + + public function recap(Request $request): View + { + $month = $request->input('month', now()->month); + $year = $request->input('year', now()->year); + $className = $request->input('class_name'); + + $query = Student::where('is_active', true); + if ($className) { + $query->where('class_name', $className); + } + $students = $query->orderBy('class_name')->orderBy('full_name')->get(); + + $recap = []; + foreach ($students as $student) { + $attendances = Attendance::where('student_id', $student->id) + ->whereMonth('date', $month) + ->whereYear('date', $year) + ->get() + ->groupBy('status') + ->map(fn($g) => $g->count()); + + $recap[] = (object) [ + 'student' => $student, + 'hadir' => $attendances->get('hadir', 0), + 'sakit' => $attendances->get('sakit', 0), + 'izin' => $attendances->get('izin', 0), + 'alpha' => $attendances->get('alpha', 0), + 'terlambat' => $attendances->get('terlambat', 0), + 'total' => $attendances->sum(), + ]; + } + + $classNames = Student::where('is_active', true) + ->whereNotNull('class_name') + ->distinct() + ->orderBy('class_name') + ->pluck('class_name'); + + return view('attendances.recap', compact('recap', 'month', 'year', 'className', 'classNames')); + } +} diff --git a/app/Models/Attendance.php b/app/Models/Attendance.php new file mode 100644 index 0000000..43ca8e8 --- /dev/null +++ b/app/Models/Attendance.php @@ -0,0 +1,32 @@ + 'date', + 'lesson_hour' => 'integer', + ]; + } + + public function student(): BelongsTo + { + return $this->belongsTo(Student::class); + } + + public function recorder(): BelongsTo + { + return $this->belongsTo(User::class, 'recorded_by'); + } +} diff --git a/database/migrations/2026_07_22_161010_create_attendances_table.php b/database/migrations/2026_07_22_161010_create_attendances_table.php new file mode 100644 index 0000000..69c668e --- /dev/null +++ b/database/migrations/2026_07_22_161010_create_attendances_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('student_id')->constrained()->cascadeOnDelete(); + $table->date('date'); + $table->unsignedTinyInteger('lesson_hour')->comment('Jam ke- 1 s.d 10'); + $table->string('status', 20)->default('hadir')->comment('hadir|sakit|izin|alpha|terlambat'); + $table->text('notes')->nullable(); + $table->foreignId('recorded_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->unique(['student_id', 'date', 'lesson_hour'], 'attendance_unique'); + $table->index('date'); + $table->index('status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('attendances'); + } +}; diff --git a/database/migrations/2026_07_22_164059_add_is_system_to_violation_types_table.php b/database/migrations/2026_07_22_164059_add_is_system_to_violation_types_table.php new file mode 100644 index 0000000..d07eb5e --- /dev/null +++ b/database/migrations/2026_07_22_164059_add_is_system_to_violation_types_table.php @@ -0,0 +1,50 @@ +boolean('is_system')->default(false)->after('is_active'); + }); + + // Mark existing alpha & terlambat types as system + DB::table('violation_types') + ->whereIn('id', function ($q) { + $q->select('id') + ->from('violation_types') + ->where('name', 'like', '%alpha%') + ->orWhere('slug', 'terlambat-datang-ke-sekolah-1'); + }) + ->update(['is_system' => true]); + + // Set clean slugs for system types + DB::table('violation_types') + ->where('name', 'like', '%alpha%') + ->update(['slug' => 'alpha']); + + DB::table('violation_types') + ->where('slug', 'terlambat-datang-ke-sekolah-1') + ->update(['slug' => 'terlambat']); + } + + public function down(): void + { + // Restore slugs + DB::table('violation_types') + ->where('slug', 'alpha') + ->update(['slug' => 'tidak-masuk-tanpa-keterangan-alpha']); + + DB::table('violation_types') + ->where('slug', 'terlambat') + ->update(['slug' => 'terlambat-datang-ke-sekolah']); + + Schema::table('violation_types', function (Blueprint $table) { + $table->dropColumn('is_system'); + }); + } +}; diff --git a/database/seeders/ViolationTypeDefaultsSeeder.php b/database/seeders/ViolationTypeDefaultsSeeder.php new file mode 100644 index 0000000..bf1153f --- /dev/null +++ b/database/seeders/ViolationTypeDefaultsSeeder.php @@ -0,0 +1,53 @@ +orWhere('name', 'like', '%ringan%') + ->first(); + + if (!$ringan) { + $this->command->warn('Kategori "Ringan" tidak ditemukan. Buat kategori Ringan dulu.'); + return; + } + + // Alpha + ViolationType::updateOrCreate( + ['slug' => 'alpha'], + [ + 'name' => 'Alpha - Tidak hadir tanpa keterangan', + 'category_id' => $ringan->id, + 'points' => 15, + 'default_sanction' => 'Peringatan lisan / teguran BK', + 'description' => 'Pelanggaran otomatis dari sistem presensi ketika siswa tidak hadir tanpa keterangan.', + 'is_active' => true, + 'is_system' => true, + ] + ); + + // Terlambat + ViolationType::updateOrCreate( + ['slug' => 'terlambat'], + [ + 'name' => 'Terlambat', + 'category_id' => $ringan->id, + 'points' => 2, + 'default_sanction' => 'Peringatan lisan', + 'description' => 'Pelanggaran otomatis dari sistem presensi ketika siswa terlambat.', + 'is_active' => true, + 'is_system' => true, + ] + ); + + $this->command->info('System violation types (alpha & terlambat) seeded/updated.'); + } +} diff --git a/resources/views/attendances/create.blade.php b/resources/views/attendances/create.blade.php new file mode 100644 index 0000000..1d34422 --- /dev/null +++ b/resources/views/attendances/create.blade.php @@ -0,0 +1,276 @@ +@extends('layouts.app') + +@section('title', 'Input Presensi') + +@section('content') +
+ {{-- Header --}} +
+
+ +

Input Presensi Per Kelas

+

Pilih kelas, tanggal, dan isi kehadiran per jam pelajaran

+
+
+ + {{-- Pilih Kelas + Tanggal --}} +
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + @if($errors->any() && $students->count() > 0) +
+
+ +
+

Gagal menyimpan presensi

+
    + @foreach($errors->all() as $err) +
  • {{ $err }}
  • + @endforeach +
+
+
+
+ @endif + + @if($students->count() > 0) +
+ @csrf + + + + + {{-- Info bar --}} +
+
+
+ + {{ $students->count() }} siswa + + {{ \Carbon\Carbon::parse($date)->translatedFormat('l, d F Y') }} + + Kelas {{ $className }} +
+ +
+
+ + {{-- Grid table --}} +
+
+ + + + + + @foreach($lessonHours as $lh) + + @endforeach + + + + @foreach($students as $s) + + {{-- Student name --}} + + + {{-- Per-row bulk buttons --}} + + + {{-- Lesson hours cells --}} + @foreach($lessonHours as $lh) + @php + $key = $s->id . '-' . $lh; + $currentStatus = isset($existing[$key]) ? $existing[$key]->status : 'hadir'; + $statusColors = [ + 'hadir' => 'bg-emerald-100 border-emerald-300 text-emerald-700', + 'sakit' => 'bg-purple-100 border-purple-300 text-purple-700', + 'izin' => 'bg-blue-100 border-blue-300 text-blue-700', + 'alpha' => 'bg-red-100 border-red-300 text-red-700', + 'terlambat' => 'bg-yellow-100 border-yellow-300 text-yellow-700', + ]; + $statusLabels = [ + 'hadir' => 'H', + 'sakit' => 'S', + 'izin' => 'I', + 'alpha' => 'A', + 'terlambat' => 'T', + ]; + $statusFull = [ + 'hadir' => 'Hadir', + 'sakit' => 'Sakit', + 'izin' => 'Izin', + 'alpha' => 'Alpha', + 'terlambat' => 'Terlambat', + ]; + $nextStatus = [ + 'hadir' => 'sakit', + 'sakit' => 'izin', + 'izin' => 'alpha', + 'alpha' => 'terlambat', + 'terlambat' => 'hadir', + ]; + @endphp + + @endforeach + + @endforeach + +
SiswaSet + Jam ke-{{ $lh }} +
+
+
+ {{ strtoupper(substr($s->full_name, 0, 1)) }} +
+
+

{{ $s->full_name }}

+
+
+
+
+ + + + + +
+
+ + + + + +
+
+
+ + {{-- Submit --}} +
+
+ H Hadir + S Sakit + I Izin + A Alpha + T Terlambat + + Klik tombol untuk ganti status +
+ +
+ + {{-- Info --}} +
+
+ + Siswa dengan status Alpha (A) minimal 1 jam akan otomatis dicatat sebagai pelanggaran. +
+
+
+ @endif +
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/attendances/index.blade.php b/resources/views/attendances/index.blade.php new file mode 100644 index 0000000..7b5dd4e --- /dev/null +++ b/resources/views/attendances/index.blade.php @@ -0,0 +1,118 @@ +@extends('layouts.app') + +@section('title', 'Presensi Siswa') + +@section('content') +
+ {{-- Header --}} +
+
+

Presensi Siswa

+

Pencatatan kehadiran siswa per jam pelajaran

+
+ +
+ + {{-- Quick Stats --}} + @php + $today = now()->toDateString(); + $todayAlpha = \App\Models\Attendance::where('date', $today)->where('status', 'alpha')->count(); + $todayTotal = \App\Models\Attendance::where('date', $today)->count(); + $monthTotal = \App\Models\Attendance::whereMonth('date', now()->month)->whereYear('date', now()->year)->count(); + $monthAlpha = \App\Models\Attendance::whereMonth('date', now()->month)->whereYear('date', now()->year)->where('status', 'alpha')->count(); + $recentDates = \App\Models\Attendance::select('date') + ->distinct() + ->orderByDesc('date') + ->take(10) + ->get(); + @endphp + +
+
+
+
+

Presensi Hari Ini

+

{{ $todayTotal }}

+

data tercatat

+
+
+
+
+
+

Alpha Hari Ini

+

{{ $todayAlpha }}

+

tanpa keterangan

+
+
+
+
+
+

Bulan Ini

+

{{ $monthTotal }}

+

total presensi

+
+
+
+
+
+

Alpha Bulan Ini

+

{{ $monthAlpha }}

+

pelanggaran

+
+
+
+ + {{-- Recent --}} +
+
+
+
+ +
+
+

Riwayat Presensi

+

10 hari terakhir

+
+
+
+ @if($recentDates->count() > 0) + + @else +
+
+ +
+

Belum Ada Presensi

+

Klik "Input Presensi" untuk memulai

+
+ @endif +
+
+@endsection diff --git a/resources/views/attendances/recap.blade.php b/resources/views/attendances/recap.blade.php new file mode 100644 index 0000000..9fda07f --- /dev/null +++ b/resources/views/attendances/recap.blade.php @@ -0,0 +1,132 @@ +@extends('layouts.app') + +@section('title', 'Rekap Presensi') + +@section('content') +
+ {{-- Header --}} +
+
+ +

Rekap Presensi Bulanan

+

Rekapitulasi kehadiran siswa per jam pelajaran

+
+ + + Input Presensi + +
+ + {{-- Filter --}} +
+
+
+
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+
+
+
+ + {{-- Table --}} +
+
+
+
+ +
+
+

+ Rekap {{ \Carbon\Carbon::create()->month($month)->format('F') }} {{ $year }} +

+

{{ count($recap) }} siswa

+
+
+
+ + @if(count($recap) > 0) +
+ + + + + + + + + + + + + + @foreach($recap as $r) + + + + + + + + + + @endforeach + +
Siswa✅ Hadir🟣 Sakit🔵 Izin❌ Alpha🟡 TerlambatTotal
+

{{ $r->student->full_name }}

+

{{ $r->student->class_name }}

+
{{ $r->hadir }}{{ $r->sakit }}{{ $r->izin }}{{ $r->alpha }}{{ $r->terlambat }}{{ $r->total }}
+
+
+ Menampilkan rekap {{ \Carbon\Carbon::create()->month($month)->format('F') }} {{ $year }} + @if($className) • Kelas {{ $className }} @endif +
+ @else +
+
+ +
+

Belum Ada Data Presensi

+

Belum ada catatan presensi untuk bulan ini

+
+ @endif +
+
+@endsection diff --git a/resources/views/components/nav-item.blade.php b/resources/views/components/nav-item.blade.php index 6479f16..2f0dd2f 100644 --- a/resources/views/components/nav-item.blade.php +++ b/resources/views/components/nav-item.blade.php @@ -11,6 +11,7 @@ $faIcons = [ 'plus-circle' => 'fa-solid fa-circle-plus', 'exclamation-triangle' => 'fa-solid fa-triangle-exclamation', 'users' => 'fa-solid fa-users', + 'clipboard-check' => 'fa-solid fa-clipboard-check', 'document-text' => 'fa-solid fa-file-lines', 'tag' => 'fa-solid fa-tag', 'list' => 'fa-solid fa-list', @@ -18,6 +19,7 @@ $faIcons = [ 'refresh' => 'fa-solid fa-rotate', 'cog' => 'fa-solid fa-gear', 'users-cog' => 'fa-solid fa-user-gear', + 'database' => 'fa-solid fa-database', ]; $faClass = $faIcons[$icon] ?? 'fa-solid fa-circle'; @endphp diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 38b81ed..1c90f39 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -48,6 +48,7 @@ Input Pelanggaran Data Pelanggaran Data Siswa + Presensi Siswa Surat Peringatan
@@ -58,6 +59,7 @@ Ambang SP Sinkronisasi Manajemen User + Backup Database Reset Aplikasi Pengaturan diff --git a/resources/views/settings/backup.blade.php b/resources/views/settings/backup.blade.php new file mode 100644 index 0000000..4a66ef5 --- /dev/null +++ b/resources/views/settings/backup.blade.php @@ -0,0 +1,199 @@ +@extends('layouts.app') + +@section('title', 'Backup Database') + +@section('content') +
+ {{-- Header --}} +
+
+ +

Backup Database

+

Kelola backup dan restore database aplikasi

+
+
+ @csrf + +
+
+ + {{-- Stats Cards --}} +
+
+
+ +
+
+

Total Backup

+

{{ $stats['total_backups'] }}

+

file tersimpan

+
+
+
+
+ +
+
+

Backup Terakhir

+

{{ $stats['latest_backup'] }}

+

terbaru

+
+
+
+
+ +
+
+

Total Ukuran

+

{{ $stats['total_size'] }}

+

semua backup

+
+
+
+
+ +
+
+

Ukuran Database

+

{{ $stats['db_size'] }}

+

saat ini

+
+
+
+ + {{-- Daftar Backup --}} +
+
+
+
+ +
+
+

Riwayat Backup

+

Daftar file backup database

+
+
+
+ + @if($backups->count() > 0) +
+ + + + + + + + + + + @foreach($backups as $backup) + + + + + + + @endforeach + +
Nama FileUkuranAksi
+
+
+ +
+
+

{{ $backup->filename }}

+

{{ $backup->date }}

+
+
+
+ + + {{ $backup->size }} + + +
+ + + + + @if($loop->first) +
+ @csrf + + +
+ @endif + +
+ @csrf @method('DELETE') + +
+
+
+
+
+ {{ $backups->count() }} file backup + Total {{ $stats['total_size'] }} +
+ @else +
+
+ +
+

Belum Ada Backup

+

Backup database pertama Anda untuk mengamankan data

+
+ @csrf + +
+
+ @endif +
+ + {{-- Info Card --}} +
+
+
+ +
+
+

Informasi Backup

+
    +
  • Backup disimpan di storage/app/backups/
  • +
  • Format file: .sql.gz (SQL + kompresi Gzip)
  • +
  • Restore akan membuat backup otomatis sebelum menjalankan restore
  • +
  • Hanya admin yang dapat mengakses fitur ini
  • +
  • Pastikan ada cukup ruang penyimpanan di server
  • +
+
+
+
+
+@endsection diff --git a/resources/views/settings/index.blade.php b/resources/views/settings/index.blade.php index 45619ae..e3d9f33 100644 --- a/resources/views/settings/index.blade.php +++ b/resources/views/settings/index.blade.php @@ -82,5 +82,42 @@
+ + {{-- Backup Database Card --}} +
+
+
+
+ +
+
+

Backup Database

+

Kelola backup dan restore database

+
+
+ + Kelola Backup + + +
+
+

Fitur backup database memungkinkan Anda untuk:

+
    +
  • + + Membuat backup database kapan saja +
  • +
  • + + Mendownload file backup untuk penyimpanan eksternal +
  • +
  • + + Merestore database dari backup dengan safety backup otomatis +
  • +
+
+
@endsection diff --git a/resources/views/settings/reset.blade.php b/resources/views/settings/reset.blade.php index aff31f6..dcfb934 100644 --- a/resources/views/settings/reset.blade.php +++ b/resources/views/settings/reset.blade.php @@ -159,6 +159,26 @@ + {{-- Penanganan --}} + + + {{-- Backup Files --}} + + {{-- User Lain --}}