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:
@@ -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) {
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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']);
|
||||
|
||||
Reference in New Issue
Block a user