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
This commit is contained in:
Bro
2026-07-22 22:19:19 +07:00
parent f86aa84d12
commit 8529a49174
24 changed files with 2152 additions and 842 deletions
@@ -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([
+26 -1
View File
@@ -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);
@@ -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.');
}
}
+86 -2
View File
@@ -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