feat: Face ID (scan, register, liveness) + role baru KS/Waka/Ketua Tim + notifikasi WA ortu + scoping wali kelas + error pages premium

This commit is contained in:
Novy San87
2026-08-02 20:57:11 +07:00
parent 0a5f471854
commit 6e256661dd
76 changed files with 5335 additions and 888 deletions
@@ -3,9 +3,12 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Classes;
use App\Models\HandlingType;
use App\Models\SpThreshold;
use App\Models\ViolationCategory;
use App\Models\ViolationType;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
@@ -143,6 +146,147 @@ class MasterDataController extends Controller
return back()->with('success', 'Jenis pelanggaran berhasil dihapus.');
}
// === Handling Types (Jenis Penanganan) ===
public function handlingTypes(): View
{
$types = HandlingType::orderBy('sort_order')->orderBy('name')->get();
$roles = HandlingType::roles();
return view('settings.handling-types', compact('types', 'roles'));
}
public function storeHandlingType(Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255', 'unique:handling_types,name'],
'icon' => ['required', 'string', 'max:100'],
'color' => ['required', 'string', 'max:7'],
'sort_order' => ['nullable', 'integer', 'min:0', 'max:999'],
]);
$validated['sort_order'] = $validated['sort_order'] ?? 0;
$validated['is_active'] = true;
HandlingType::create($validated);
return back()->with('success', 'Jenis penanganan berhasil ditambahkan.');
}
public function updateHandlingType(Request $request, HandlingType $type): RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255', 'unique:handling_types,name,' . $type->id],
'icon' => ['required', 'string', 'max:100'],
'color' => ['required', 'string', 'max:7'],
'sort_order' => ['nullable', 'integer', 'min:0', 'max:999'],
'is_active' => ['boolean'],
]);
$validated['sort_order'] = $validated['sort_order'] ?? $type->sort_order;
$validated['is_active'] = $request->boolean('is_active');
$type->update($validated);
return back()->with('success', 'Jenis penanganan berhasil diperbarui.');
}
public function reorderHandlingTypes(Request $request): JsonResponse
{
$validated = $request->validate([
'order' => ['required', 'array'],
'order.*' => ['integer', 'exists:handling_types,id'],
]);
foreach ($validated['order'] as $i => $id) {
HandlingType::where('id', $id)->update(['sort_order' => $i + 1]);
}
return response()->json(['ok' => true]);
}
public function destroyHandlingType(HandlingType $type): RedirectResponse
{
if ($type->is_system) {
return back()->with('error', 'Jenis penanganan sistem tidak bisa dihapus. Nonaktifkan saja jika tidak diperlukan.');
}
$type->delete();
return back()->with('success', 'Jenis penanganan berhasil dihapus.');
}
public function storeHandlingRole(Request $request): RedirectResponse
{
$validated = $request->validate([
'role' => ['required', 'string', 'max:100'],
]);
$roles = HandlingType::roles();
$role = trim($validated['role']);
if (in_array($role, $roles)) {
return back()->with('error', 'Peran "' . $role . '" sudah ada.');
}
$roles[] = $role;
HandlingType::saveRoles($roles);
return back()->with('success', 'Peran "' . $role . '" berhasil ditambahkan.');
}
public function destroyHandlingRole(string $role): RedirectResponse
{
$roles = HandlingType::roles();
$roles = array_values(array_filter($roles, fn($r) => $r !== $role));
HandlingType::saveRoles($roles);
return back()->with('success', 'Peran "' . $role . '" berhasil dihapus.');
}
public function reorderHandlingRoles(Request $request): JsonResponse
{
$validated = $request->validate([
'order' => ['required', 'array'],
'order.*' => ['required', 'string', 'max:100'],
]);
HandlingType::saveRoles($validated['order']);
return response()->json(['ok' => true]);
}
// === Homeroom Teachers (Wali Kelas) ===
public function homeroomTeachers(): View
{
$classes = Classes::with('homeroomTeacher')
->where('is_active', true)
->orderBy('level')
->orderBy('name')
->get();
$teachers = \App\Models\User::where(function ($q) {
$q->whereJsonContains('roles', 'wali_kelas')->orWhere('role', 'wali_kelas');
})
->where('is_active', true)
->orderBy('name')
->get();
return view('settings.homeroom', compact('classes', 'teachers'));
}
public function updateHomeroomTeacher(Request $request): RedirectResponse
{
$validated = $request->validate([
'class_id' => ['required', 'exists:classes,id'],
'teacher_id' => ['nullable', 'exists:users,id'],
]);
$class = Classes::findOrFail($validated['class_id']);
$class->homeroom_teacher_id = $validated['teacher_id'] ?? null;
$class->save();
return back()->with('success', 'Wali kelas untuk ' . $class->name . ' berhasil diperbarui.');
}
// === SP Thresholds ===
public function thresholds(): View
{
@@ -19,7 +19,7 @@ class PermissionController extends Controller
->get()
->groupBy('group');
$roles = ['admin', 'bk', 'wali_kelas', 'staff', 'other'];
$roles = ['admin', 'bk', 'wali_kelas', 'staff', 'other', 'kepala_sekolah', 'waka_kesiswaan', 'ketua_tim'];
$rolePermissions = [];
foreach ($roles as $role) {
@@ -69,7 +69,7 @@ class PermissionController extends Controller
}
// Clear cache
foreach (['admin', 'bk', 'wali_kelas', 'staff', 'other'] as $role) {
foreach (['admin', 'bk', 'wali_kelas', 'staff', 'other', 'kepala_sekolah', 'waka_kesiswaan', 'ketua_tim'] as $role) {
Cache::forget('role_permissions:' . $role);
}
@@ -27,6 +27,7 @@ class SettingsController extends Controller
'kepala_sekolah_name' => ['nullable', 'string', 'max:255'],
'kepala_sekolah_nip' => ['nullable', 'string', 'max:50'],
'bk_koordinator_name' => ['nullable', 'string', 'max:255'],
'wa_notification_template' => ['nullable', 'string', 'max:3000'],
]);
foreach ($validated as $key => $value) {
+17 -4
View File
@@ -25,7 +25,11 @@ class UserController extends Controller
}
if ($request->filled('role')) {
$query->where('role', $request->role);
$role = $request->role;
$query->where(function ($q) use ($role) {
$q->where('role', $role)
->orWhereJsonContains('roles', $role);
});
}
$users = $query->orderBy('name')->paginate(20);
@@ -40,10 +44,14 @@ class UserController extends Controller
'username' => ['required', 'string', 'max:255', 'unique:users,username'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'string', 'min:6'],
'role' => ['required', 'in:admin,bk,wali_kelas,staff,other'],
'roles' => ['required', 'array', 'min:1'],
'roles.*' => ['in:admin,bk,wali_kelas,staff,other,kepala_sekolah,waka_kesiswaan,ketua_tim'],
'is_active' => ['boolean'],
]);
$roles = array_values(array_unique($validated['roles']));
$validated['roles'] = $roles;
$validated['role'] = $roles[0];
$validated['password'] = Hash::make($validated['password']);
$validated['is_active'] = $request->boolean('is_active', true);
@@ -60,10 +68,15 @@ class UserController extends Controller
'username' => ['required', 'string', 'max:255', 'unique:users,username,' . $user->id],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email,' . $user->id],
'password' => ['nullable', 'string', 'min:6'],
'role' => ['required', 'in:admin,bk,wali_kelas,staff,other'],
'roles' => ['required', 'array', 'min:1'],
'roles.*' => ['in:admin,bk,wali_kelas,staff,other,kepala_sekolah,waka_kesiswaan,ketua_tim'],
'is_active' => ['boolean'],
]);
$roles = array_values(array_unique($validated['roles']));
$validated['roles'] = $roles;
$validated['role'] = $roles[0];
if (!empty($validated['password'])) {
$validated['password'] = Hash::make($validated['password']);
} else {
@@ -92,7 +105,7 @@ class UserController extends Controller
public function resetPassword(Request $request, User $user): RedirectResponse
{
if ($user->id === auth()->id() && $user->role === 'admin') {
if ($user->id === auth()->id() && $user->isAdmin()) {
return back()->with('error', 'Ganti password admin lewat profil.');
}
+70 -8
View File
@@ -16,22 +16,30 @@ class DashboardController extends Controller
{
$today = now()->toDateString();
// Wali kelas murni → data dibatasi ke kelas yang diwalikan
$scoped = auth()->user()->isScopedWaliKelas();
$scopedStudentIds = $scoped ? Student::whereIn('class_id', auth()->user()->homeroomClassIds())->pluck('id')->all() : [];
$scopedClassIds = $scoped ? auth()->user()->homeroomClassIds() : [];
$vScope = fn ($q) => $q->when($scoped, fn ($qq) => $qq->whereIn('student_id', $scopedStudentIds));
$stats = [
'today_violations' => Violation::whereDate('violation_date', $today)->count(),
'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(),
'today_violations' => Violation::when($scoped, fn ($q) => $q->whereIn('student_id', $scopedStudentIds))->whereDate('violation_date', $today)->count(),
'total_violations' => Violation::when($scoped, fn ($q) => $q->whereIn('student_id', $scopedStudentIds))->count(),
'total_students' => Student::where('is_active', true)->when($scoped, fn ($q) => $q->whereIn('class_id', $scopedClassIds))->count(),
'active_sp' => SpLetter::where('status', 'draft')->when($scoped, fn ($q) => $q->whereHas('student', fn ($qq) => $qq->whereIn('class_id', $scopedClassIds)))->count(),
'unhandled_violations' => Violation::when($scoped, fn ($q) => $q->whereIn('student_id', $scopedStudentIds))->where('handling_status', 'unhandled')->count(),
'in_progress_violations' => Violation::when($scoped, fn ($q) => $q->whereIn('student_id', $scopedStudentIds))->where('handling_status', 'in_progress')->count(),
'resolved_violations' => Violation::when($scoped, fn ($q) => $q->whereIn('student_id', $scopedStudentIds))->where('handling_status', 'resolved')->count(),
];
$recentViolations = Violation::with(['student', 'violationType.category', 'recorder'])
->when($scoped, fn ($q) => $q->whereIn('student_id', $scopedStudentIds))
->latest()
->take(10)
->get();
$topStudents = Student::where('is_active', true)
->when($scoped, fn ($q) => $q->whereIn('class_id', $scopedClassIds))
->withSum('violations', 'points')
->orderByDesc('violations_sum_points')
->take(5)
@@ -55,14 +63,66 @@ class DashboardController extends Controller
// Data untuk calendar: pelanggaran per tanggal di bulan ini
$calendarData = Violation::selectRaw('DATE(violation_date) as date, COUNT(*) as count')
->when($scoped, fn ($q) => $q->whereIn('student_id', $scopedStudentIds))
->whereMonth('violation_date', now()->month)
->whereYear('violation_date', now()->year)
->groupBy('date')
->pluck('count', 'date');
// Tren pelanggaran HARIAN — 7 / 14 / 30 hari terakhir
$dailySet = function (int $days) use ($scoped, $scopedStudentIds): array {
$labels = [];
$data = [];
for ($i = $days - 1; $i >= 0; $i--) {
$d = now()->subDays($i);
$labels[] = $d->format('d/m');
$data[] = Violation::when($scoped, fn ($q) => $q->whereIn('student_id', $scopedStudentIds))
->whereDate('violation_date', $d->toDateString())->count();
}
return [$labels, $data];
};
[$dailyLabels7, $dailyData7] = $dailySet(7);
[$dailyLabels14, $dailyData14] = $dailySet(14);
[$dailyLabels30, $dailyData30] = $dailySet(30);
$dailyCurrent = $dailyData7[6] ?? 0; // hari ini
$dailyPrev = $dailyData7[5] ?? 0; // kemarin
$trendPercent = $dailyPrev > 0 ? round((($dailyCurrent - $dailyPrev) / $dailyPrev) * 100) : ($dailyCurrent > 0 ? 100 : 0);
$trendTotal7 = array_sum($dailyData7);
$trendTotal14 = array_sum($dailyData14);
$trendTotal30 = array_sum($dailyData30);
// Pelanggaran per jenis (grafik batang) — 3 periode
$typeCounts = function ($from = null, $month = null) use ($scoped, $scopedStudentIds) {
$q = Violation::query();
if ($scoped) $q->whereIn('student_id', $scopedStudentIds);
if ($from) $q->whereDate('violation_date', '>=', $from);
if ($month) $q->whereMonth('violation_date', $month)->whereYear('violation_date', now()->year);
return $q->selectRaw('violation_type_id, COUNT(*) as jml')
->groupBy('violation_type_id')
->pluck('jml', 'violation_type_id');
};
$typeMapToday = $typeCounts(now()->toDateString());
$typeMapWeek = $typeCounts(now()->subDays(7)->toDateString());
$typeMapMonth = $typeCounts(null, now()->month);
$typeNames = [];
$typeToday = [];
$typeWeek = [];
$typeMonth = [];
foreach (\App\Models\ViolationType::where('is_active', true)->orderBy('name')->get() as $t) {
$typeNames[] = $t->name;
$typeToday[] = $typeMapToday[$t->id] ?? 0;
$typeWeek[] = $typeMapWeek[$t->id] ?? 0;
$typeMonth[] = $typeMapMonth[$t->id] ?? 0;
}
return view('dashboard.index', compact(
'stats', 'recentViolations', 'topStudents',
'unreadNotifications', 'notificationCount', 'spThresholds', 'calendarData'
'unreadNotifications', 'notificationCount', 'spThresholds', 'calendarData',
'dailyLabels7', 'dailyData7', 'dailyLabels14', 'dailyData14', 'dailyLabels30', 'dailyData30',
'dailyCurrent', 'trendPercent', 'trendTotal7', 'trendTotal14', 'trendTotal30',
'typeNames', 'typeToday', 'typeWeek', 'typeMonth'
));
}
@@ -72,6 +132,8 @@ class DashboardController extends Controller
$month = $request->input('month', now()->month);
$data = Violation::selectRaw('DATE(violation_date) as date, COUNT(*) as count')
->when(auth()->user()->isScopedWaliKelas(), fn ($q) => $q->whereIn('student_id',
Student::whereIn('class_id', auth()->user()->homeroomClassIds())->pluck('id')->all()))
->whereYear('violation_date', $year)
->whereMonth('violation_date', $month)
->groupBy('date')
+204
View File
@@ -0,0 +1,204 @@
<?php
namespace App\Http\Controllers;
use App\Models\Student;
use App\Services\FaceRecognitionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Face ID — alur face-first:
* - scan: foto wajah -> sistem kenali siswa -> tambah pelanggaran
* - register: daftarkan wajah ke siswa yang sudah ada di database
*
* Microservice hanya ASISTEN. Bila FaceID mati / siswa belum terdaftar,
* alur manual tetap berfungsi penuh.
*/
class FaceIdController extends Controller
{
public function __construct(private FaceRecognitionService $faceid) {}
public function scan(): View
{
return view('face.scan', [
'faceidConfigured' => $this->faceid->isConfigured(),
]);
}
public function scanVerify(Request $request): JsonResponse
{
$request->validate([
'photo' => ['required', 'image', 'mimes:jpeg,png,jpg,webp', 'max:5120'],
]);
if (! $this->faceid->isConfigured()) {
return response()->json(['ok' => false, 'error' => 'faceid_not_configured']);
}
$result = $this->faceid->verify($request->file('photo')->getRealPath());
if (! $result['ok']) {
return response()->json(['ok' => false, 'error' => $result['error'] ?? 'faceid_error']);
}
$data = $result['data'];
$candidates = [];
foreach ($data['top_candidates'] ?? [] as $c) {
$student = Student::find($c['student_id']);
if (! $student) {
continue;
}
$candidates[] = [
'id' => $student->id,
'full_name' => $student->full_name,
'nisn' => $student->nisn,
'class_name' => $student->class_name,
'score' => $c['score'],
'total_points' => (int) $student->violations()->sum('points'),
'recent_violations' => $student->violations()
->with('violationType:id,name')
->latest('violation_date')
->take(3)
->get(['id', 'violation_date', 'violation_type_id', 'points'])
->map(fn ($v) => [
'date' => $v->violation_date?->format('d/m/Y'),
'type' => $v->violationType?->name ?? '—',
'points' => $v->points,
]),
];
}
return response()->json([
'ok' => true,
'matched' => $data['matched'] ?? false,
'ambiguous' => $data['ambiguous'] ?? false,
'reason' => $data['reason'] ?? 'tidak_cocok',
'candidates' => $candidates,
]);
}
public function register(Request $request): View
{
$query = Student::where('is_active', true);
$q = trim((string) $request->get('q', ''));
if ($q !== '') {
$query->where(fn ($qq) => $qq->where('full_name', 'like', "%{$q}%")
->orWhere('nisn', 'like', "%{$q}%")
->orWhere('student_number', 'like', "%{$q}%"));
}
$students = $query->orderBy('full_name')->paginate(20)->withQueryString();
$enrolledMap = $this->faceid->enrolledMap();
$selected = null;
if ($request->has('student_id')) {
$selected = Student::find($request->integer('student_id'));
}
return view('face.register', [
'students' => $students,
'enrolledMap' => $enrolledMap,
'selected' => $selected,
'q' => $q,
'faceidConfigured' => $this->faceid->isConfigured(),
]);
}
public function registerStore(Request $request): RedirectResponse
{
$request->validate([
'student_id' => ['required', 'exists:students,id'],
'photos' => ['required', 'array', 'min:1', 'max:3'],
'photos.*' => ['image', 'mimes:jpeg,png,jpg,webp', 'max:5120'],
]);
$student = Student::findOrFail($request->integer('student_id'));
$result = $this->enrollPhotos($student, $request->file('photos') ?? []);
if (! $result['ok']) {
return back()->with('error', 'Registrasi wajah gagal: '.($result['error'] ?? 'unknown'))
->withInput();
}
$message = "Wajah {$student->full_name} berhasil terdaftar ({$result['accepted']} foto diterima).";
if ($result['rejected']) {
$reasons = collect($result['rejected'])->pluck('reason')->unique()->implode(', ');
$message .= " Foto ditolak: {$reasons} — ulangi pengambilan foto.";
}
return redirect()->route('face.register', ['student_id' => $student->id, 'q' => $request->input('q')])
->with('success', $message);
}
/**
* Registrasi cepat dari hasil scan (foto yang sama dipakai untuk enroll).
* Dipanggil dari kartu "Siswa Belum Terdaftar" di halaman scan.
*/
public function registerQuick(Request $request): JsonResponse
{
$request->validate([
'student_id' => ['required', 'exists:students,id'],
'photo' => ['required', 'image', 'mimes:jpeg,png,jpg,webp', 'max:5120'],
]);
$student = Student::findOrFail($request->integer('student_id'));
$result = $this->enrollPhotos($student, [$request->file('photo')]);
if (! $result['ok']) {
return response()->json([
'ok' => false,
'error' => $result['error'] ?? 'enroll_failed',
'detail' => $result['rejected'] ?? null,
]);
}
return response()->json([
'ok' => true,
'name' => $student->full_name,
'photos_accepted' => $result['accepted'],
'rejected' => $result['rejected'],
]);
}
/** Kirim foto ke microservice (logika bersama registerStore & registerQuick). */
private function enrollPhotos(Student $student, array $photos): array
{
if (! $this->faceid->isConfigured()) {
return ['ok' => false, 'error' => 'faceid_not_configured', 'rejected' => []];
}
$tmpPaths = [];
foreach ($photos as $photo) {
if (! $photo || ! $photo->isValid()) {
continue;
}
$tmp = tempnam(sys_get_temp_dir(), 'faceid_');
file_put_contents($tmp, file_get_contents($photo->getRealPath()));
$tmpPaths[] = $tmp;
}
if (! $tmpPaths) {
return ['ok' => false, 'error' => 'no_valid_photo', 'rejected' => []];
}
$result = $this->faceid->enroll($student->id, $student->full_name, $tmpPaths, null);
foreach ($tmpPaths as $tmp) {
@unlink($tmp);
}
if (! $result['ok']) {
return ['ok' => false, 'error' => $result['error'] ?? 'unknown', 'rejected' => []];
}
return [
'ok' => true,
'accepted' => $result['data']['photos_accepted'] ?? 0,
'rejected' => $result['data']['photos_rejected'] ?? [],
];
}
}
@@ -13,6 +13,7 @@ class SpLetterController extends Controller
public function index(Request $request): View
{
$letters = SpLetter::with(['student', 'spThreshold', 'generator'])
->when(auth()->user()->isScopedWaliKelas(), fn ($q) => $q->whereHas('student', fn ($qq) => $qq->whereIn('class_id', auth()->user()->homeroomClassIds())))
->latest()
->paginate(20);
@@ -21,6 +22,8 @@ class SpLetterController extends Controller
public function show(SpLetter $spLetter): View
{
abort_unless(auth()->user()->canViewStudent($spLetter->student_id), 403);
$spLetter->load(['student', 'spThreshold', 'generator']);
$school = [
'name' => Setting::getValue('school_name', 'SMK'),
@@ -35,6 +38,8 @@ class SpLetterController extends Controller
public function print(SpLetter $spLetter)
{
abort_unless(auth()->user()->canViewStudent($spLetter->student_id), 403);
$spLetter->load(['student', 'spThreshold', 'generator']);
$school = [
'name' => Setting::getValue('school_name', 'SMK'),
@@ -14,7 +14,8 @@ class StudentReportController extends Controller
{
public function index(Request $request): View
{
$query = Student::where('is_active', true);
$query = Student::where('is_active', true)
->when(auth()->user()->isScopedWaliKelas(), fn ($q) => $q->whereIn('class_id', auth()->user()->homeroomClassIds()));
if ($request->filled('search')) {
$search = $request->search;
@@ -37,15 +38,20 @@ class StudentReportController extends Controller
$query->where('department_code', $request->department);
}
$students = $query->withCount(['violations' => fn($q) => $q->whereNull('deleted_at')])
->orderBy('class_name')
// Default: siswa dengan poin pelanggaran tertinggi di urutan atas
$students = $query
->withCount(['violations' => fn($q) => $q->whereNull('deleted_at')])
->withSum(['violations' => fn($q) => $q->whereNull('deleted_at')], 'points')
->orderByDesc('violations_sum_points')
->orderBy('full_name')
->paginate(20);
// Data untuk dropdown filter
$classLevels = Student::where('is_active', true)->distinct()->pluck('class_level')->sort()->values();
$classNames = Student::where('is_active', true)->distinct()->pluck('class_name')->sort()->values();
$departments = Student::where('is_active', true)
$baseStudents = Student::where('is_active', true)
->when(auth()->user()->isScopedWaliKelas(), fn ($q) => $q->whereIn('class_id', auth()->user()->homeroomClassIds()));
$classLevels = (clone $baseStudents)->distinct()->pluck('class_level')->sort()->values();
$classNames = (clone $baseStudents)->distinct()->pluck('class_name')->sort()->values();
$departments = (clone $baseStudents)
->selectRaw('DISTINCT department_code, department_name')
->whereNotNull('department_code')
->where('department_code', '!=', '')
@@ -71,6 +77,8 @@ class StudentReportController extends Controller
public function show(Student $student): View
{
abort_unless(auth()->user()->canViewStudent($student), 403);
$student->load(['violations' => function ($q) {
$q->with(['violationType.category', 'recorder', 'evidences', 'handlings.participants.user'])
->latest()
@@ -106,6 +114,8 @@ class StudentReportController extends Controller
public function update(Request $request, Student $student): RedirectResponse
{
abort_unless(auth()->user()->canViewStudent($student), 403);
$validated = $request->validate([
'full_name' => ['required', 'string', 'max:255'],
'nisn' => ['nullable', 'string', 'max:50'],
@@ -115,6 +125,7 @@ class StudentReportController extends Controller
'date_of_birth' => ['nullable', 'date'],
'address' => ['nullable', 'string', 'max:500'],
'phone_number' => ['nullable', 'string', 'max:30'],
'parent_phone' => ['nullable', 'string', 'max:30'],
'email' => ['nullable', 'string', 'email', 'max:255'],
'class_name' => ['nullable', 'string', 'max:100'],
'class_level' => ['nullable', 'string', 'max:20'],
+59 -4
View File
@@ -24,7 +24,13 @@ class ViolationController extends Controller
public function index(Request $request): View
{
$query = Violation::with(['student', 'violationType.category', 'recorder']);
$query = Violation::with(['student', 'violationType.category', 'recorder'])
->withCount('notifications');
// Wali kelas murni → hanya kelas yang diwalikan
if (auth()->user()->isScopedWaliKelas()) {
$query->whereHas('student', fn ($q) => $q->whereIn('class_id', auth()->user()->homeroomClassIds()));
}
if ($request->filled('search')) {
$search = $request->search;
@@ -66,8 +72,19 @@ class ViolationController extends Controller
public function create(): View
{
$students = Student::where('is_active', true)->orderBy('full_name')->get();
$violationTypes = ViolationType::with('category')->where('is_active', true)->get();
$studentsQuery = Student::where('is_active', true);
if (auth()->user()->isScopedWaliKelas()) {
$studentsQuery->whereIn('class_id', auth()->user()->homeroomClassIds());
}
$students = $studentsQuery->orderBy('full_name')->get();
$violationTypes = ViolationType::with('category')
->where('is_active', true)
->orderBy(
\App\Models\ViolationCategory::select('sort_order')
->whereColumn('violation_categories.id', 'violation_types.category_id')
)
->orderBy('name')
->get();
// Group by category for searchable dropdown
$typeGroups = $violationTypes->groupBy(fn($t) => $t->category?->name ?? 'Lainnya')
@@ -100,6 +117,9 @@ class ViolationController extends Controller
'evidences.*' => ['image', 'mimes:jpeg,png,jpg,gif,webp', 'max:5120'],
]);
// Wali kelas murni hanya boleh mencatat siswa di kelasnya
abort_unless(auth()->user()->canViewStudent($validated['student_id']), 403);
$evidences = $request->file('evidences') ?? [];
$created = 0;
@@ -123,6 +143,8 @@ class ViolationController extends Controller
public function show(Violation $violation): View
{
abort_unless(auth()->user()->canViewStudent($violation->student_id), 403);
$violation->load([
'student', 'violationType.category', 'recorder', 'evidences',
'handlings.participants.user', 'handlings.creator',
@@ -130,7 +152,12 @@ class ViolationController extends Controller
$users = \App\Models\User::where('is_active', true)->orderBy('name')->get();
return view('violations.show', compact('violation', 'users'));
$handlingTypes = \App\Models\HandlingType::where('is_active', true)
->orderBy('sort_order')->orderBy('name')->get();
$handlingTypeMap = $handlingTypes->keyBy('name');
$handlingRoles = \App\Models\HandlingType::roles();
return view('violations.show', compact('violation', 'users', 'handlingTypes', 'handlingTypeMap', 'handlingRoles'));
}
public function storeHandling(Request $request, Violation $violation): RedirectResponse
@@ -206,6 +233,32 @@ class ViolationController extends Controller
->with('success', 'Catatan penanganan berhasil dihapus.');
}
/**
* Kirim notifikasi WhatsApp ke orang tua/wali (manual, wa.me).
* Mencatat log pengiriman lalu redirect ke WhatsApp dengan pesan terisi.
*/
public function notifyParentWa(Violation $violation): RedirectResponse
{
$wa = $violation->buildWaNotification();
if (! $wa) {
return back()->with('error', 'Nomor HP orang tua/wali belum terisi untuk siswa ini — isi dulu di halaman siswa.');
}
[$phone, $message] = $wa;
$violation->notifications()->create([
'student_id' => $violation->student_id,
'channel' => 'whatsapp',
'recipient' => $phone,
'message' => $message,
'status' => 'sent',
'user_id' => auth()->id(),
'created_at' => now(),
]);
return redirect()->away('https://wa.me/'.$phone.'?text='.rawurlencode($message));
}
public function verify(Request $request, Violation $violation): RedirectResponse
{
$violation->update([
@@ -233,6 +286,8 @@ class ViolationController extends Controller
$q->where('full_name', 'like', "%{$search}%")
->orWhere('nisn', 'like', "%{$search}%");
})
->when(auth()->user()->isScopedWaliKelas(), fn ($q) => $q->whereIn('class_id', auth()->user()->homeroomClassIds()))
->withSum('violations', 'points')
->orderBy('full_name')
->take(20)
->get(['id', 'nisn', 'full_name', 'class_name', 'class_level']);