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:
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\FaceRecognitionService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Enroll massal embedding wajah siswa (fase pilot).
|
||||
*
|
||||
* Format CSV (header wajib):
|
||||
* student_id,name,photo1,photo2,photo3,twin_group
|
||||
* 2026001,Budi Santoso,budi-1.jpg,budi-2.jpg,budi-3.jpg,
|
||||
* 2026002,Ayu Lestari,ayu-1.jpg,ayu-2.jpg,ayu-3.jpg,TW-07
|
||||
*
|
||||
* Jalankan:
|
||||
* php artisan faceid:enroll /path/data.csv --photos=/path/foto
|
||||
*/
|
||||
class FaceIdEnroll extends Command
|
||||
{
|
||||
protected $signature = 'faceid:enroll
|
||||
{csv : Path file CSV (kolom: student_id,name,photo1,photo2,photo3,twin_group)}
|
||||
{--photos= : Direktori basis foto; path relatif di CSV di-resolve dari sini}';
|
||||
|
||||
protected $description = 'Enroll massal embedding wajah siswa dari CSV + folder foto (fase pilot).';
|
||||
|
||||
public function handle(FaceRecognitionService $faceid): int
|
||||
{
|
||||
$csv = $this->argument('csv');
|
||||
if (! is_file($csv)) {
|
||||
$this->error("File CSV tidak ditemukan: {$csv}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
if (! $faceid->isConfigured()) {
|
||||
$this->error('FaceID belum dikonfigurasi (FACEID_BASE_URL / FACEID_API_KEY di .env).');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$photosDir = $this->option('photos') ?: dirname($csv);
|
||||
$rows = array_map('str_getcsv', file($csv));
|
||||
$header = array_shift($rows);
|
||||
if (! $header) {
|
||||
$this->error('CSV kosong / header tidak ditemukan.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
$idx = array_flip($header);
|
||||
|
||||
$ok = 0;
|
||||
$fail = 0;
|
||||
foreach ($rows as $row) {
|
||||
if (count($row) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$studentId = trim($row[$idx['student_id'] ?? 0] ?? '');
|
||||
$name = trim($row[$idx['name'] ?? 1] ?? '');
|
||||
if ($studentId === '' || $name === '') {
|
||||
$fail++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$paths = [];
|
||||
foreach (['photo1', 'photo2', 'photo3'] as $col) {
|
||||
if (! isset($idx[$col])) {
|
||||
continue;
|
||||
}
|
||||
$p = trim($row[$idx[$col]] ?? '');
|
||||
if ($p === '') {
|
||||
continue;
|
||||
}
|
||||
$full = $p[0] === '/' ? $p : rtrim($photosDir, '/').'/'.$p;
|
||||
if (is_file($full)) {
|
||||
$paths[] = $full;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $paths) {
|
||||
$this->warn(" [skip] {$studentId}: tidak ada foto valid");
|
||||
|
||||
$fail++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$twin = null;
|
||||
if (isset($idx['twin_group'])) {
|
||||
$twin = trim($row[$idx['twin_group']] ?? '') ?: null;
|
||||
}
|
||||
|
||||
$res = $faceid->enroll($studentId, $name, $paths, $twin);
|
||||
if ($res['ok']) {
|
||||
$this->info(" [ok] {$studentId} ({$name}): ".$res['data']['photos_accepted'].' foto diterima');
|
||||
$ok++;
|
||||
} else {
|
||||
$this->error(" [gagal] {$studentId} ({$name}): ".($res['error'] ?? 'unknown'));
|
||||
$fail++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Selesai: {$ok} berhasil, {$fail} gagal/skip.");
|
||||
|
||||
return $fail === 0 ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
|
||||
@@ -22,7 +22,7 @@ class CheckRole
|
||||
}
|
||||
|
||||
foreach ($roles as $role) {
|
||||
if ($user->role === $role) {
|
||||
if ($user->hasRole($role)) {
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,21 +23,15 @@ class PermissionMiddleware
|
||||
}
|
||||
|
||||
// Admin always has all permissions
|
||||
if ($user->role === 'admin') {
|
||||
if ($user->isAdmin()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Cache role permissions (cleared when role_permissions changes)
|
||||
$rolePermissions = Cache::remember('role_permissions:' . $user->role, 3600, function () use ($user) {
|
||||
return \DB::table('role_permissions')
|
||||
->join('permissions', 'role_permissions.permission_id', '=', 'permissions.id')
|
||||
->where('role_permissions.role', $user->role)
|
||||
->pluck('permissions.key')
|
||||
->toArray();
|
||||
});
|
||||
// Gabungkan permission dari SEMUA role user (multi-role)
|
||||
$userPermissions = $user->allPermissions();
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
if (in_array($permission, $rolePermissions)) {
|
||||
if (in_array($permission, $userPermissions)) {
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ class Classes extends Model
|
||||
protected $fillable = [
|
||||
'source_id', 'name', 'level', 'department_code',
|
||||
'department_name', 'academic_year_name', 'is_active',
|
||||
'metadata',
|
||||
'metadata', 'homeroom_teacher_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -26,4 +26,9 @@ class Classes extends Model
|
||||
{
|
||||
return $this->hasMany(Student::class, 'class_id');
|
||||
}
|
||||
|
||||
public function homeroomTeacher()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'homeroom_teacher_id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class FaceVerificationLog extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'student_id',
|
||||
'violation_id',
|
||||
'faceid_matched',
|
||||
'faceid_ambiguous',
|
||||
'faceid_reason',
|
||||
'faceid_score',
|
||||
'photo_hash',
|
||||
'top_candidates',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'faceid_matched' => 'boolean',
|
||||
'faceid_ambiguous' => 'boolean',
|
||||
'faceid_score' => 'float',
|
||||
'top_candidates' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function student(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Student::class);
|
||||
}
|
||||
|
||||
public function violation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Violation::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class HandlingType extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name', 'icon', 'color', 'sort_order', 'is_active', 'is_system',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
'is_system' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Daftar peran penanganan (dari settings, dengan default).
|
||||
*/
|
||||
public static function roles(): array
|
||||
{
|
||||
$defaults = ['Wakil Ketua Tim', 'Anggota Tim', 'Wali Kelas', 'Guru BK', 'Saksi'];
|
||||
$raw = \App\Models\Setting::getValue('handling_roles');
|
||||
if (!$raw) {
|
||||
return $defaults;
|
||||
}
|
||||
$roles = json_decode($raw, true);
|
||||
return is_array($roles) && count($roles) ? $roles : $defaults;
|
||||
}
|
||||
|
||||
public static function saveRoles(array $roles): void
|
||||
{
|
||||
$roles = array_values(array_filter(array_map('trim', $roles)));
|
||||
\App\Models\Setting::setValue('handling_roles', json_encode($roles), 'general', 'Daftar peran penanganan');
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ class Student extends Model
|
||||
protected $fillable = [
|
||||
'source_id', 'nisn', 'student_number', 'full_name',
|
||||
'gender', 'place_of_birth', 'date_of_birth', 'address',
|
||||
'phone_number', 'email', 'class_name', 'class_level',
|
||||
'phone_number', 'parent_phone', 'email', 'class_name', 'class_level',
|
||||
'department_code', 'department_name', 'academic_year_name',
|
||||
'status', 'photo_path', 'class_id', 'is_active',
|
||||
'metadata', 'synced_at',
|
||||
|
||||
+99
-15
@@ -10,8 +10,10 @@ class User extends Authenticatable
|
||||
{
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
public const ROLES = ['admin', 'bk', 'wali_kelas', 'staff', 'other', 'kepala_sekolah', 'waka_kesiswaan', 'ketua_tim'];
|
||||
|
||||
protected $fillable = [
|
||||
'name', 'username', 'email', 'password', 'role', 'is_active',
|
||||
'name', 'username', 'email', 'password', 'role', 'roles', 'is_active',
|
||||
];
|
||||
|
||||
protected $hidden = ['password', 'remember_token'];
|
||||
@@ -22,29 +24,111 @@ class User extends Authenticatable
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'is_active' => 'boolean',
|
||||
'roles' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function isAdmin(): bool { return $this->role === 'admin'; }
|
||||
public function isBK(): bool { return $this->role === 'bk'; }
|
||||
public function isWaliKelas(): bool { return $this->role === 'wali_kelas'; }
|
||||
public function isStaff(): bool { return $this->role === 'staff'; }
|
||||
public function isOther(): bool { return $this->role === 'other'; }
|
||||
/**
|
||||
* Daftar role user (multi-role). Kolom `role` lama tetap sebagai fallback.
|
||||
*/
|
||||
public function roleList(): array
|
||||
{
|
||||
$roles = $this->roles ?? [];
|
||||
if (!is_array($roles) || empty($roles)) {
|
||||
return [$this->role];
|
||||
}
|
||||
return array_values(array_unique(array_filter($roles)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Role utama (pertama) — untuk kompatibilitas kolom `role`.
|
||||
*/
|
||||
public function primaryRole(): string
|
||||
{
|
||||
return $this->roleList()[0] ?? $this->role;
|
||||
}
|
||||
|
||||
public function hasRole(string $role): bool
|
||||
{
|
||||
return in_array($role, $this->roleList(), true);
|
||||
}
|
||||
|
||||
public function hasAnyRole(array $roles): bool
|
||||
{
|
||||
return count(array_intersect($roles, $this->roleList())) > 0;
|
||||
}
|
||||
|
||||
public function isAdmin(): bool { return $this->hasRole('admin'); }
|
||||
public function isBK(): bool { return $this->hasRole('bk'); }
|
||||
public function isWaliKelas(): bool { return $this->hasRole('wali_kelas'); }
|
||||
public function isStaff(): bool { return $this->hasRole('staff'); }
|
||||
public function isOther(): bool { return $this->hasRole('other'); }
|
||||
public function isKepalaSekolah(): bool { return $this->hasRole('kepala_sekolah'); }
|
||||
public function isWakaKesiswaan(): bool { return $this->hasRole('waka_kesiswaan'); }
|
||||
public function isKetuaTim(): bool { return $this->hasRole('ketua_tim'); }
|
||||
|
||||
/**
|
||||
* Role dengan visibilitas data GLOBAL (semua kelas/siswa).
|
||||
* Guru ber-role ganda (mis. staff + wali_kelas) masuk kategori ini.
|
||||
*/
|
||||
public function isGlobalRole(): bool
|
||||
{
|
||||
return $this->hasAnyRole(['admin', 'bk', 'staff', 'waka_kesiswaan', 'kepala_sekolah', 'ketua_tim']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wali kelas MURNI (tanpa role global) → datanya dibatasi ke kelas yang diwalikan.
|
||||
*/
|
||||
public function isScopedWaliKelas(): bool
|
||||
{
|
||||
return $this->hasRole('wali_kelas') && ! $this->isGlobalRole();
|
||||
}
|
||||
|
||||
/** ID kelas yang diwalikan ke user ini. */
|
||||
public function homeroomClassIds(): array
|
||||
{
|
||||
return \App\Models\Classes::where('homeroom_teacher_id', $this->id)->pluck('id')->all();
|
||||
}
|
||||
|
||||
/** Boleh tidak user ini mengakses data siswa tertentu? */
|
||||
public function canViewStudent(\App\Models\Student|int $student): bool
|
||||
{
|
||||
if (! $this->isScopedWaliKelas()) {
|
||||
return true;
|
||||
}
|
||||
$classId = is_int($student)
|
||||
? \App\Models\Student::find($student)?->class_id
|
||||
: $student->class_id;
|
||||
|
||||
return in_array($classId, $this->homeroomClassIds(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission dari SEMUA role user (digabung).
|
||||
*/
|
||||
public function allPermissions(): array
|
||||
{
|
||||
$perms = [];
|
||||
foreach ($this->roleList() as $role) {
|
||||
$perms = array_merge($perms, \Illuminate\Support\Facades\Cache::remember(
|
||||
'role_permissions:' . $role,
|
||||
3600,
|
||||
fn () => \DB::table('role_permissions')
|
||||
->join('permissions', 'role_permissions.permission_id', '=', 'permissions.id')
|
||||
->where('role_permissions.role', $role)
|
||||
->pluck('permissions.key')
|
||||
->toArray()
|
||||
));
|
||||
}
|
||||
return array_values(array_unique($perms));
|
||||
}
|
||||
|
||||
public function canPermission(string $permission): bool
|
||||
{
|
||||
if ($this->isAdmin()) {
|
||||
return true;
|
||||
}
|
||||
return in_array($permission, \Illuminate\Support\Facades\Cache::remember(
|
||||
'role_permissions:' . $this->role,
|
||||
3600,
|
||||
fn () => \DB::table('role_permissions')
|
||||
->join('permissions', 'role_permissions.permission_id', '=', 'permissions.id')
|
||||
->where('role_permissions.role', $this->role)
|
||||
->pluck('permissions.key')
|
||||
->toArray()
|
||||
));
|
||||
return in_array($permission, $this->allPermissions(), true);
|
||||
}
|
||||
|
||||
public function recordedViolations()
|
||||
|
||||
@@ -56,6 +56,63 @@ class Violation extends Model
|
||||
return $this->hasMany(ViolationEvidence::class);
|
||||
}
|
||||
|
||||
public function notifications(): HasMany
|
||||
{
|
||||
return $this->hasMany(ViolationNotification::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bangun notifikasi WA untuk orang tua/wali.
|
||||
*
|
||||
* @return array{0: string, 1: string}|null [nomor_wa, pesan] — null bila HP ortu belum diisi
|
||||
*/
|
||||
public function buildWaNotification(): ?array
|
||||
{
|
||||
$student = $this->student;
|
||||
if (! $student || ! $student->parent_phone) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalisasi nomor Indonesia: 08xx / 8xx → 628xx
|
||||
$phone = preg_replace('/\D/', '', $student->parent_phone);
|
||||
if (str_starts_with($phone, '0')) {
|
||||
$phone = '62'.substr($phone, 1);
|
||||
} elseif (str_starts_with($phone, '8')) {
|
||||
$phone = '62'.$phone;
|
||||
}
|
||||
if (strlen($phone) < 10) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$template = trim((string) \App\Models\Setting::getValue('wa_notification_template', ''));
|
||||
if ($template === '') {
|
||||
$template = "Yth. Bapak/Ibu Wali dari {nama_siswa} ({kelas}),\n\n"
|
||||
."Dengan hormat, kami informasikan bahwa ananda tercatat melakukan pelanggaran:\n"
|
||||
."• Jenis: {jenis}\n"
|
||||
."• Tanggal: {tanggal}\n"
|
||||
."• Poin: {poin}\n\n"
|
||||
."Total poin ananda saat ini: {total_poin}.\n\n"
|
||||
."Demikian disampaikan, atas perhatian dan kerja samanya kami ucapkan terima kasih.\n\n"
|
||||
."Tim Ketertiban\n{sekolah}";
|
||||
}
|
||||
|
||||
$placeholders = [
|
||||
'{nama_siswa}' => $student->full_name,
|
||||
'{nisn}' => $student->nisn,
|
||||
'{kelas}' => $student->class_name,
|
||||
'{jenis}' => $this->violationType?->name ?? '-',
|
||||
'{tanggal}' => $this->violation_date ? $this->violation_date->format('d/m/Y') : '-',
|
||||
'{waktu}' => $this->violation_time ? $this->violation_time->format('H:i') : '-',
|
||||
'{poin}' => (string) $this->points,
|
||||
'{total_poin}' => (string) ($student->total_points ?? 0),
|
||||
'{lokasi}' => $this->location ?: '-',
|
||||
'{deskripsi}' => $this->description ?: '-',
|
||||
'{sekolah}' => (string) \App\Models\Setting::getValue('school_name', 'SMKN 1 Wonorejo'),
|
||||
];
|
||||
|
||||
return [$phone, strtr($template, $placeholders)];
|
||||
}
|
||||
|
||||
public function handlings(): HasMany
|
||||
{
|
||||
return $this->hasMany(ViolationHandling::class);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ViolationNotification extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'violation_id',
|
||||
'student_id',
|
||||
'channel',
|
||||
'recipient',
|
||||
'message',
|
||||
'status',
|
||||
'user_id',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function violation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Violation::class);
|
||||
}
|
||||
|
||||
public function student(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Student::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Klien microservice deteksi wajah (FastAPI + InsightFace).
|
||||
*
|
||||
* Prinsip: microservice adalah ASISTEN petugas, bukan hakim.
|
||||
* Bila microservice mati / timeout -> method mengembalikan ['ok' => false, 'error' => 'faceid_down'],
|
||||
* dan pemanggil WAJIB punya fallback manual (form tetap berfungsi tanpa verifikasi wajah).
|
||||
*/
|
||||
class FaceRecognitionService
|
||||
{
|
||||
private string $baseUrl;
|
||||
private string $apiKey;
|
||||
private int $timeout;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->baseUrl = rtrim((string) config('services.faceid.base_url', ''), '/');
|
||||
$this->apiKey = (string) config('services.faceid.api_key', '');
|
||||
$this->timeout = (int) config('services.faceid.timeout', 3);
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->baseUrl !== '' && $this->apiKey !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Daftar siswa yang sudah terdaftar di microservice (embedding count).
|
||||
*
|
||||
* @return array<string, int> student_id => embedding_count
|
||||
*/
|
||||
public function enrolledMap(): array
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$resp = Http::timeout($this->timeout)
|
||||
->withHeaders(['X-API-Key' => $this->apiKey])
|
||||
->get($this->baseUrl.'/students');
|
||||
|
||||
if (! $resp->successful()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$map = [];
|
||||
foreach ($resp->json('students') ?? [] as $s) {
|
||||
$map[(string) $s['student_id']] = (int) $s['embedding_count'];
|
||||
}
|
||||
|
||||
return $map;
|
||||
} catch (ConnectionException $e) {
|
||||
Log::warning('FaceID students gagal: '.$e->getMessage());
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifikasi satu foto -> 3 kandidat teratas + skor + flag ambiguous.
|
||||
*
|
||||
* @return array{ok: bool, data?: array, error?: string}
|
||||
*/
|
||||
public function verify(string $photoPath): array
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
return ['ok' => false, 'error' => 'faceid_not_configured'];
|
||||
}
|
||||
|
||||
try {
|
||||
$resp = Http::timeout($this->timeout)
|
||||
->withHeaders(['X-API-Key' => $this->apiKey])
|
||||
->attach('photo', fopen($photoPath, 'r'), basename($photoPath))
|
||||
->post($this->baseUrl.'/verify');
|
||||
|
||||
if ($resp->successful()) {
|
||||
return ['ok' => true, 'data' => $resp->json()];
|
||||
}
|
||||
|
||||
Log::warning('FaceID verify HTTP '.$resp->status().': '.substr($resp->body(), 0, 300));
|
||||
|
||||
return ['ok' => false, 'error' => 'faceid_http_'.$resp->status()];
|
||||
} catch (ConnectionException $e) {
|
||||
Log::warning('FaceID tidak terjangkau, fallback manual: '.$e->getMessage());
|
||||
|
||||
return ['ok' => false, 'error' => 'faceid_down'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enroll embedding siswa dari 1-5 foto.
|
||||
*
|
||||
* @param array<string> $photoPaths
|
||||
* @return array{ok: bool, data?: array, error?: string}
|
||||
*/
|
||||
public function enroll(string $studentId, string $name, array $photoPaths, ?string $twinGroup = null): array
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
return ['ok' => false, 'error' => 'faceid_not_configured'];
|
||||
}
|
||||
|
||||
try {
|
||||
$request = Http::timeout($this->timeout * 3)
|
||||
->withHeaders(['X-API-Key' => $this->apiKey])
|
||||
->asMultipart();
|
||||
|
||||
foreach ($photoPaths as $path) {
|
||||
$request->attach('photos', fopen($path, 'r'), basename($path));
|
||||
}
|
||||
|
||||
$resp = $request->post($this->baseUrl.'/enroll', [
|
||||
'student_id' => $studentId,
|
||||
'name' => $name,
|
||||
'twin_group' => $twinGroup ?? '',
|
||||
]);
|
||||
|
||||
if ($resp->successful()) {
|
||||
return ['ok' => true, 'data' => $resp->json()];
|
||||
}
|
||||
|
||||
return ['ok' => false, 'error' => 'faceid_http_'.$resp->status(), 'detail' => $resp->json()];
|
||||
} catch (ConnectionException $e) {
|
||||
Log::warning('FaceID enroll gagal (timeout/down): '.$e->getMessage());
|
||||
|
||||
return ['ok' => false, 'error' => 'faceid_down'];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user