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')
Ringkasan pelanggaran siswa
+Ringkasan pelanggaran siswa
Pelanggaran Hari Ini
-{{ $stats['today_violations'] }}
-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
{{ $v->student->full_name ?? '-' }}
-{{ $v->violationType->name ?? '-' }} +{{ $v->points }} poin
+ +{{ $v->violation_date->format('d/m/Y') }}
-{{ $v->recorder->name ?? '-' }}
-Siswa per level
+{{ $threshold->name }}
-{{ $threshold->min_points }}-{{ $threshold->max_points ?? '~' }} poin
+{{ $threshold->name }}
+{{ $threshold->min_points }}{{ $threshold->max_points ? '–'.$threshold->max_points : '+' }} poin
+{{ $count }}
+siswa
+Top 5 siswa
+{{ $student->full_name }}
-{{ $student->class_name }}
+{{ $student->full_name }}
+{{ $student->class_name }}
Belum ada data
+