From 8529a49174c1ee9229ef6ca9c28e17edebe3a8a0 Mon Sep 17 00:00:00 2001 From: Bro Date: Wed, 22 Jul 2026 22:19:19 +0700 Subject: [PATCH] feat: penanganan pelanggaran (handling system) - Migration: handling_status, handled_at, handled_by ke violations - Migration: tabel violation_handlings, handling_participants - Model: ViolationHandling, HandlingParticipant + relasi - Routes: storeHandling, resolveHandling, destroyHandling, calendar data - ViolationController: CRUD penanganan + filter status handling - DashboardController: data handling stats + calendar endpoint - Dashboard view: kalender pelanggaran interaktif (Alpine.js) - Violations index: filter status penanganan + kolom status - Violations show: stat card penanganan + timeline + modal tambah - Menambahkan handling_status filter di index --- .../Controllers/Admin/ImportController.php | 63 ++ app/Http/Controllers/DashboardController.php | 27 +- .../Controllers/StudentReportController.php | 25 + app/Http/Controllers/ViolationController.php | 88 ++- app/Models/HandlingParticipant.php | 21 + app/Models/User.php | 10 + app/Models/Violation.php | 27 +- app/Models/ViolationHandling.php | 37 + ...dd_handling_status_to_violations_table.php | 25 + ...51648_create_violation_handlings_table.php | 28 + ...649_create_handling_participants_table.php | 25 + resources/views/dashboard/index.blade.php | 370 ++++++--- resources/views/layouts/app.blade.php | 28 +- resources/views/profile/index.blade.php | 12 +- resources/views/settings/categories.blade.php | 26 +- resources/views/settings/reset.blade.php | 16 +- resources/views/settings/sync.blade.php | 16 +- .../views/settings/violation-types.blade.php | 335 ++++---- resources/views/students/index.blade.php | 168 ++-- resources/views/students/show.blade.php | 605 ++++++++++----- resources/views/users/index.blade.php | 6 +- resources/views/violations/index.blade.php | 312 +++++--- resources/views/violations/show.blade.php | 718 ++++++++++++++---- routes/web.php | 6 + 24 files changed, 2152 insertions(+), 842 deletions(-) create mode 100644 app/Models/HandlingParticipant.php create mode 100644 app/Models/ViolationHandling.php create mode 100644 database/migrations/2026_07_22_151646_add_handling_status_to_violations_table.php create mode 100644 database/migrations/2026_07_22_151648_create_violation_handlings_table.php create mode 100644 database/migrations/2026_07_22_151649_create_handling_participants_table.php diff --git a/app/Http/Controllers/Admin/ImportController.php b/app/Http/Controllers/Admin/ImportController.php index 1a47d16..1e95a69 100644 --- a/app/Http/Controllers/Admin/ImportController.php +++ b/app/Http/Controllers/Admin/ImportController.php @@ -10,6 +10,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Str; use PhpOffice\PhpSpreadsheet\IOFactory; use PhpOffice\PhpSpreadsheet\Writer\Xlsx; +use PhpOffice\PhpSpreadsheet\Style\Border; class ImportController extends Controller { @@ -67,6 +68,68 @@ class ImportController extends Controller ]); } + public function exportViolationTypes() + { + $types = ViolationType::with('category')->orderBy('category_id')->orderBy('name')->get(); + + $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle('Jenis Pelanggaran'); + + // Header styling + $headerStyle = [ + 'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']], + 'fill' => [ + 'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID, + 'startColor' => ['rgb' => '2563EB'], + ], + 'borders' => [ + 'allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'BFDBFE']], + ], + ]; + + // Headers + $headers = ['No', 'Kategori', 'Nama Pelanggaran', 'Poin', 'Sanksi Default', 'Deskripsi', 'Status']; + foreach (range('A', 'G') as $i => $col) { + $sheet->setCellValue($col . '1', $headers[$i]); + } + $sheet->getStyle('A1:G1')->applyFromArray($headerStyle); + + // Data + $row = 2; + foreach ($types as $i => $type) { + $sheet->setCellValue('A' . $row, $i + 1); + $sheet->setCellValue('B' . $row, $type->category?->name ?? '-'); + $sheet->setCellValue('C' . $row, $type->name); + $sheet->setCellValue('D' . $row, $type->points); + $sheet->setCellValue('E' . $row, $type->default_sanction ?? ''); + $sheet->setCellValue('F' . $row, $type->description ?? ''); + $sheet->setCellValue('G' . $row, $type->is_active ? 'Aktif' : 'Nonaktif'); + + // Zebra striping + if ($i % 2 === 0) { + $sheet->getStyle('A' . $row . ':G' . $row) + ->getFill()->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID) + ->getStartColor()->setARGB('FFF0F5FF'); + } + + $row++; + } + + // Autofit + foreach (range('A', 'G') as $col) { + $sheet->getColumnDimension($col)->setAutoSize(true); + } + + $writer = new Xlsx($spreadsheet); + + return response()->streamDownload(function () use ($writer) { + $writer->save('php://output'); + }, 'jenis-pelanggaran.xlsx', [ + 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ]); + } + public function importViolationTypes(Request $request): RedirectResponse { $request->validate([ diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 8275fec..f33e2fc 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -6,6 +6,7 @@ use App\Models\AppNotification; use App\Models\Student; use App\Models\Violation; use App\Models\SpLetter; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -20,6 +21,9 @@ class DashboardController extends Controller 'total_violations' => Violation::count(), 'total_students' => Student::where('is_active', true)->count(), 'active_sp' => SpLetter::where('status', 'draft')->count(), + 'unhandled_violations' => Violation::where('handling_status', 'unhandled')->count(), + 'in_progress_violations' => Violation::where('handling_status', 'in_progress')->count(), + 'resolved_violations' => Violation::where('handling_status', 'resolved')->count(), ]; $recentViolations = Violation::with(['student', 'violationType.category', 'recorder']) @@ -49,12 +53,33 @@ class DashboardController extends Controller $spThresholds = \App\Models\SpThreshold::where('is_active', true)->get(); + // Data untuk calendar: pelanggaran per tanggal di bulan ini + $calendarData = Violation::selectRaw('DATE(violation_date) as date, COUNT(*) as count') + ->whereMonth('violation_date', now()->month) + ->whereYear('violation_date', now()->year) + ->groupBy('date') + ->pluck('count', 'date'); + return view('dashboard.index', compact( 'stats', 'recentViolations', 'topStudents', - 'unreadNotifications', 'notificationCount', 'spThresholds' + 'unreadNotifications', 'notificationCount', 'spThresholds', 'calendarData' )); } + public function getCalendarData(Request $request): JsonResponse + { + $year = $request->input('year', now()->year); + $month = $request->input('month', now()->month); + + $data = Violation::selectRaw('DATE(violation_date) as date, COUNT(*) as count') + ->whereYear('violation_date', $year) + ->whereMonth('violation_date', $month) + ->groupBy('date') + ->pluck('count', 'date'); + + return response()->json($data); + } + public function markNotificationRead(Request $request, $id) { $notif = AppNotification::where('user_id', $request->user()->id)->findOrFail($id); diff --git a/app/Http/Controllers/StudentReportController.php b/app/Http/Controllers/StudentReportController.php index 6e0f4f6..8653fcc 100644 --- a/app/Http/Controllers/StudentReportController.php +++ b/app/Http/Controllers/StudentReportController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\Student; +use Illuminate\Http\RedirectResponse; use App\Models\SpLetter; use App\Models\SpThreshold; use App\Models\Violation; @@ -88,4 +89,28 @@ class StudentReportController extends Controller 'spThresholds', 'currentSpLevel', 'nextSpThreshold', 'activeSpLetters' )); } + + public function update(Request $request, Student $student): RedirectResponse + { + $validated = $request->validate([ + 'full_name' => ['required', 'string', 'max:255'], + 'nisn' => ['nullable', 'string', 'max:50'], + 'student_number' => ['nullable', 'string', 'max:50'], + 'gender' => ['nullable', 'in:L,P'], + 'place_of_birth' => ['nullable', 'string', 'max:255'], + 'date_of_birth' => ['nullable', 'date'], + 'address' => ['nullable', 'string', 'max:500'], + 'phone_number' => ['nullable', 'string', 'max:30'], + 'email' => ['nullable', 'string', 'email', 'max:255'], + 'class_name' => ['nullable', 'string', 'max:100'], + 'class_level' => ['nullable', 'string', 'max:20'], + 'department_code' => ['nullable', 'string', 'max:20'], + 'department_name' => ['nullable', 'string', 'max:100'], + ]); + + $student->update($validated); + + return redirect()->route('students.show', $student->id) + ->with('success', 'Data siswa berhasil diperbarui.'); + } } diff --git a/app/Http/Controllers/ViolationController.php b/app/Http/Controllers/ViolationController.php index ff8612e..f1244e5 100644 --- a/app/Http/Controllers/ViolationController.php +++ b/app/Http/Controllers/ViolationController.php @@ -4,8 +4,10 @@ namespace App\Http\Controllers; use App\Models\Student; use App\Models\Violation; +use App\Models\ViolationHandling; use App\Models\ViolationType; use App\Models\AppNotification; +use App\Models\HandlingParticipant; use App\Services\ViolationService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -52,6 +54,10 @@ class ViolationController extends Controller $query->whereDate('violation_date', '<=', $request->date_to); } + if ($request->filled('handling_status')) { + $query->where('handling_status', $request->handling_status); + } + $violations = $query->latest()->paginate(20); $categories = \App\Models\ViolationCategory::where('is_active', true)->get(); @@ -108,9 +114,87 @@ class ViolationController extends Controller public function show(Violation $violation): View { - $violation->load(['student', 'violationType.category', 'recorder', 'evidences']); + $violation->load([ + 'student', 'violationType.category', 'recorder', 'evidences', + 'handlings.participants.user', 'handlings.creator', + ]); - return view('violations.show', compact('violation')); + $users = \App\Models\User::where('is_active', true)->orderBy('name')->get(); + + return view('violations.show', compact('violation', 'users')); + } + + public function storeHandling(Request $request, Violation $violation): RedirectResponse + { + $validated = $request->validate([ + 'handling_type' => ['required', 'string', 'max:50'], + 'handling_date' => ['required', 'date'], + 'description' => ['nullable', 'string', 'max:2000'], + 'location' => ['nullable', 'string', 'max:255'], + 'evidence' => ['nullable', 'file', 'mimes:jpeg,png,jpg,gif,webp,pdf,doc,docx', 'max:10240'], + 'participants' => ['nullable', 'array', 'max:20'], + 'participants.*.user_id' => ['required_with:participants', 'exists:users,id'], + 'participants.*.role' => ['nullable', 'string', 'max:50'], + ]); + + $evidencePath = null; + if ($request->hasFile('evidence')) { + $evidencePath = $request->file('evidence')->store('handling-evidence', 'public'); + } + + $handling = ViolationHandling::create([ + 'violation_id' => $violation->id, + 'handling_type' => $validated['handling_type'], + 'handling_date' => $validated['handling_date'], + 'description' => $validated['description'] ?? null, + 'location' => $validated['location'] ?? null, + 'evidence' => $evidencePath, + 'created_by' => $request->user()->id, + ]); + + // Add participants + if (!empty($validated['participants'])) { + foreach ($validated['participants'] as $p) { + HandlingParticipant::create([ + 'handling_id' => $handling->id, + 'user_id' => $p['user_id'], + 'role' => $p['role'] ?? null, + ]); + } + } + + // Update violation status + if ($violation->isUnhandled()) { + $violation->update(['handling_status' => 'in_progress']); + } + + return redirect()->route('violations.show', $violation->id) + ->with('success', 'Penanganan berhasil ditambahkan.'); + } + + public function resolveHandling(Request $request, Violation $violation): RedirectResponse + { + $violation->update([ + 'handling_status' => 'resolved', + 'handled_at' => now(), + 'handled_by' => $request->user()->id, + ]); + + return redirect()->route('violations.show', $violation->id) + ->with('success', 'Pelanggaran ditandai selesai ditangani.'); + } + + public function destroyHandling(Violation $violation, ViolationHandling $handling): RedirectResponse + { + $handling->delete(); + + // If no more handlings, revert to unhandled + if ($violation->handlings()->count() === 0) { + $violation->update(['handling_status' => 'unhandled']); + } + + return redirect()->route('violations.show', $violation->id) + ->with('success', 'Catatan penanganan berhasil dihapus.'); } public function verify(Request $request, Violation $violation): RedirectResponse diff --git a/app/Models/HandlingParticipant.php b/app/Models/HandlingParticipant.php new file mode 100644 index 0000000..0a598d7 --- /dev/null +++ b/app/Models/HandlingParticipant.php @@ -0,0 +1,21 @@ +belongsTo(ViolationHandling::class, 'handling_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index d341392..a8f250a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -38,4 +38,14 @@ class User extends Authenticatable { return $this->hasMany(AppNotification::class, 'user_id'); } + + public function handlingParticipants() + { + return $this->hasMany(HandlingParticipant::class); + } + + public function createdHandlings() + { + return $this->hasMany(ViolationHandling::class, 'created_by'); + } } diff --git a/app/Models/Violation.php b/app/Models/Violation.php index a280a51..cacb86a 100644 --- a/app/Models/Violation.php +++ b/app/Models/Violation.php @@ -16,7 +16,7 @@ class Violation extends Model 'description', 'points', 'sanction', 'location', 'violation_date', 'violation_time', 'is_verified', 'verified_by', 'verified_at', - 'notes', + 'notes', 'handling_status', 'handled_at', 'handled_by', ]; protected function casts(): array @@ -54,4 +54,29 @@ class Violation extends Model { return $this->hasMany(ViolationEvidence::class); } + + public function handlings(): HasMany + { + return $this->hasMany(ViolationHandling::class); + } + + public function handler(): BelongsTo + { + return $this->belongsTo(User::class, 'handled_by'); + } + + public function isUnhandled(): bool + { + return $this->handling_status === 'unhandled'; + } + + public function isInProgress(): bool + { + return $this->handling_status === 'in_progress'; + } + + public function isResolved(): bool + { + return $this->handling_status === 'resolved'; + } } diff --git a/app/Models/ViolationHandling.php b/app/Models/ViolationHandling.php new file mode 100644 index 0000000..c270ad7 --- /dev/null +++ b/app/Models/ViolationHandling.php @@ -0,0 +1,37 @@ + 'date', + ]; + } + + public function violation(): BelongsTo + { + return $this->belongsTo(Violation::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function participants(): HasMany + { + return $this->hasMany(HandlingParticipant::class, 'handling_id'); + } +} diff --git a/database/migrations/2026_07_22_151646_add_handling_status_to_violations_table.php b/database/migrations/2026_07_22_151646_add_handling_status_to_violations_table.php new file mode 100644 index 0000000..156a0cd --- /dev/null +++ b/database/migrations/2026_07_22_151646_add_handling_status_to_violations_table.php @@ -0,0 +1,25 @@ +string('handling_status', 20)->default('unhandled')->after('recorded_by')->comment('unhandled|in_progress|resolved'); + $table->timestamp('handled_at')->nullable()->after('handling_status'); + $table->foreignId('handled_by')->nullable()->constrained('users')->nullOnDelete()->after('handled_at'); + }); + } + + public function down(): void + { + Schema::table('violations', function (Blueprint $table) { + $table->dropConstrainedForeignId('handled_by'); + $table->dropColumn(['handling_status', 'handled_at']); + }); + } +}; diff --git a/database/migrations/2026_07_22_151648_create_violation_handlings_table.php b/database/migrations/2026_07_22_151648_create_violation_handlings_table.php new file mode 100644 index 0000000..138b562 --- /dev/null +++ b/database/migrations/2026_07_22_151648_create_violation_handlings_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('violation_id')->constrained()->cascadeOnDelete(); + $table->string('handling_type', 50); + $table->date('handling_date'); + $table->text('description')->nullable(); + $table->string('location', 255)->nullable(); + $table->string('evidence', 255)->nullable(); + $table->unsignedBigInteger('created_by')->nullable()->index(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('violation_handlings'); + } +}; diff --git a/database/migrations/2026_07_22_151649_create_handling_participants_table.php b/database/migrations/2026_07_22_151649_create_handling_participants_table.php new file mode 100644 index 0000000..92106b7 --- /dev/null +++ b/database/migrations/2026_07_22_151649_create_handling_participants_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('handling_id')->constrained('violation_handlings')->cascadeOnDelete(); + $table->unsignedBigInteger('user_id'); + $table->string('role', 50)->nullable()->comment('peran dalam penanganan: Wakil Ketua Tim, Anggota Tim, Wali Kelas, Guru BK, Saksi, dll'); + $table->timestamps(); + $table->unique(['handling_id', 'user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('handling_participants'); + } +}; diff --git a/resources/views/dashboard/index.blade.php b/resources/views/dashboard/index.blade.php index 8bef5e2..ca25b37 100644 --- a/resources/views/dashboard/index.blade.php +++ b/resources/views/dashboard/index.blade.php @@ -4,96 +4,146 @@ @section('content')
+ {{-- Header --}}
-

Dashboard

-

Ringkasan pelanggaran siswa

+

Dashboard

+

Ringkasan pelanggaran siswa

- {{-- Stats Cards --}} -
-
-
-
-

Pelanggaran Hari Ini

-

{{ $stats['today_violations'] }}

-
-
- -
+ {{-- Gradient Stats Cards --}} +
+
+
+ +
+
+

Pelanggaran Hari Ini

+

{{ $stats['today_violations'] }}

+

data terbaru

-
-
-
-

Total Pelanggaran

-

{{ $stats['total_violations'] }}

-
-
- -
+
+
+ +
+
+

Total Pelanggaran

+

{{ $stats['total_violations'] }}

+

semua waktu

-
-
-
-

Siswa Aktif

-

{{ $stats['total_students'] }}

-
-
- -
+
+
+ +
+
+

Siswa Aktif

+

{{ $stats['total_students'] }}

+

terdata

-
-
-
-

SP Aktif (Draft)

-

{{ $stats['active_sp'] }}

-
-
- -
+
+
+ +
+
+

SP Draft

+

{{ $stats['active_sp'] }}

+

menunggu tindak lanjut

- {{-- Recent Violations --}} -
-
-

Pelanggaran Terbaru

+ {{-- Calendar --}} +
+
+
+
+ +
+
+

Kalender Pelanggaran

+

+
+
+
+ + +
-
- @forelse($recentViolations as $v) - -
- -
-

{{ $v->student->full_name ?? '-' }}

-

{{ $v->violationType->name ?? '-' }} +{{ $v->points }} poin

+ +
+ {{-- Day headers --}} +
+ +
+ + {{-- Calendar grid --}} +
+ +
-
- Lihat semua → + + {{-- Legend --}} +
+ + 1 + + + 2 + + + 3+ + + Klik badge untuk lihat detail
- {{-- Top Students + SP Alerts --}} + {{-- Right Sidebar --}}
- {{-- Notifikasi Ambang SP --}} + {{-- SP Thresholds --}} @if($spThresholds->count() > 0) -
-
-

Ambang SP

+
+
+
+
+ +
+
+

Ambang SP

+

Siswa per level

+
+
@foreach($spThresholds as $threshold) @@ -103,42 +153,192 @@ $maxOk = $threshold->max_points ?? 9999; return $pts >= $threshold->min_points && $pts <= $maxOk; }); + $count = $studentsAtRisk->count(); @endphp -
-
-

{{ $threshold->name }}

-

{{ $threshold->min_points }}-{{ $threshold->max_points ?? '~' }} poin

+
+
+
+

{{ $threshold->name }}

+

{{ $threshold->min_points }}{{ $threshold->max_points ? '–'.$threshold->max_points : '+' }} poin

+
+
+

{{ $count }}

+

siswa

+
- {{ $studentsAtRisk->count() }} + @if($count > 0) +
+
+
+ @endif
@endforeach
@endif - {{-- Top 5 Siswa Poin Tertinggi --}} -
-
-

Poin Tertinggi

+ {{-- Top 5 Students --}} +
+
+
+
+ +
+
+

Poin Tertinggi

+

Top 5 siswa

+
+
-
+
@forelse($topStudents as $student) -
-
- {{ $loop->iteration }} -
-

{{ $student->full_name }}

-

{{ $student->class_name }}

+
+
+
+ +
+
+

{{ $student->full_name }}

+

{{ $student->class_name }}

- {{ $student->total_points }} + + {{ $student->total_points }} +
@empty -
Belum ada data
+
+
+ +
+

Belum ada data

+
@endforelse
+ @if(count($topStudents) > 0) + + @endif
+ +@push('scripts') + +@endpush @endsection diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 2144bd9..38b81ed 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -29,7 +29,7 @@ Logo @else
- +
@endif
@@ -136,9 +136,7 @@
{{ strtoupper(substr(auth()->user()->name, 0, 1)) }}
- - - + {{-- Dropdown --}} @@ -168,9 +166,7 @@ @csrf @@ -278,5 +274,23 @@ })); }); + + {{-- Global helpers --}} + diff --git a/resources/views/profile/index.blade.php b/resources/views/profile/index.blade.php index 259dbae..a30de49 100644 --- a/resources/views/profile/index.blade.php +++ b/resources/views/profile/index.blade.php @@ -49,9 +49,7 @@
- - - +

Informasi Profil

@@ -81,9 +79,7 @@
- - - +

Ganti Kata Sandi

@@ -117,9 +113,7 @@
diff --git a/resources/views/settings/categories.blade.php b/resources/views/settings/categories.blade.php index e2ffb80..27e41fb 100644 --- a/resources/views/settings/categories.blade.php +++ b/resources/views/settings/categories.blade.php @@ -12,9 +12,7 @@
@@ -98,9 +96,7 @@ @@ -108,13 +104,11 @@
@if($typeCount === 0)
+ x-data x-on:submit.prevent="if(await window.confirmSwal({text:'Hapus kategori ini?'})) $el.submit()"> @csrf @method('DELETE')
@@ -141,18 +135,16 @@