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
+107
View File
@@ -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) {
+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']);
+1 -1
View File
@@ -22,7 +22,7 @@ class CheckRole
}
foreach ($roles as $role) {
if ($user->role === $role) {
if ($user->hasRole($role)) {
return $next($request);
}
}
+4 -10
View File
@@ -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);
}
}
+6 -1
View File
@@ -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');
}
}
+47
View File
@@ -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);
}
}
+44
View File
@@ -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');
}
}
+1 -1
View File
@@ -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
View File
@@ -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()
+57
View File
@@ -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);
+41
View File
@@ -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);
}
}
+136
View File
@@ -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'];
}
}
}
+38
View File
@@ -4,6 +4,9 @@ use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
@@ -25,4 +28,39 @@ return Application::configure(basePath: dirname(__DIR__))
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*'),
);
// Halaman error ramah user (kecuali permintaan JSON)
$htmlView = function (string $view, int $code, Request $request): ?\Illuminate\Http\Response {
if ($request->expectsJson() || $request->is('api/*')) {
return null;
}
return response()->view($view, [], $code);
};
$exceptions->renderable(function (NotFoundHttpException $e, Request $request) use ($htmlView) {
return $htmlView('errors.404', 404, $request);
});
$exceptions->renderable(function (AccessDeniedHttpException $e, Request $request) use ($htmlView) {
return $htmlView('errors.403', 403, $request);
});
$exceptions->renderable(function (HttpException $e, Request $request) use ($htmlView) {
$view = match ($e->getStatusCode()) {
419 => 'errors.419',
403 => 'errors.403',
503 => 'errors.503',
default => null,
};
if (!$view) {
return null;
}
return $htmlView($view, $e->getStatusCode(), $request);
});
$exceptions->renderable(function (\Throwable $e, Request $request) use ($htmlView) {
// Biarkan default menangani (redirect ke login)
if ($e instanceof \Illuminate\Auth\AuthenticationException) {
return null;
}
\Illuminate\Support\Facades\Log::error('Unhandled exception: ' . get_class($e) . ' - ' . $e->getMessage() . ' @ ' . $request->path());
return $htmlView('errors.500', 500, $request);
});
})->create();
+6
View File
@@ -40,4 +40,10 @@ return [
'token' => env('KESISWAAN_TOKEN', ''),
],
'faceid' => [
'base_url' => env('FACEID_BASE_URL', 'http://127.0.0.1:8001'),
'api_key' => env('FACEID_API_KEY', ''),
'timeout' => (int) env('FACEID_TIMEOUT', 3),
],
];
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('handling_types', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->string('icon')->default('fa-clipboard-list');
$table->string('color')->default('#64748b');
$table->integer('sort_order')->default(0);
$table->boolean('is_active')->default(true);
$table->boolean('is_system')->default(false);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('handling_types');
}
};
@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('classes', function (Blueprint $table) {
$table->foreignId('homeroom_teacher_id')
->nullable()
->after('academic_year_name')
->constrained('users')
->nullOnDelete();
});
}
public function down(): void
{
Schema::table('classes', function (Blueprint $table) {
$table->dropConstrainedForeignId('homeroom_teacher_id');
});
}
};
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->json('roles')->nullable()->after('role');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('roles');
});
}
};
@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('face_verification_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('student_id')->nullable()->constrained('students')->nullOnDelete();
$table->foreignId('violation_id')->nullable()->constrained('violations')->nullOnDelete();
$table->boolean('faceid_matched')->default(false);
$table->boolean('faceid_ambiguous')->default(false);
$table->string('faceid_reason')->nullable(); // cocok / tidak_cocok / kembar_terdeteksi / skor_berdekatan
$table->decimal('faceid_score', 5, 4)->nullable();
$table->string('photo_hash', 64)->nullable(); // sha256 foto, tanpa menyimpan foto asli
$table->json('top_candidates')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->index(['user_id', 'created_at']);
$table->index(['student_id', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('face_verification_logs');
}
};
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('students', function (Blueprint $table) {
$table->string('parent_phone', 30)->nullable()->after('phone_number');
});
}
public function down(): void
{
Schema::table('students', function (Blueprint $table) {
$table->dropColumn('parent_phone');
});
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('violation_notifications', function (Blueprint $table) {
$table->id();
$table->foreignId('violation_id')->constrained('violations')->cascadeOnDelete();
$table->foreignId('student_id')->nullable()->constrained('students')->nullOnDelete();
$table->string('channel', 20)->default('whatsapp');
$table->string('recipient', 30)->nullable();
$table->text('message')->nullable();
$table->string('status', 20)->default('sent');
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('created_at')->useCurrent();
$table->index(['violation_id', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('violation_notifications');
}
};
@@ -0,0 +1,87 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
$now = now();
// ===== 1) Permission baru untuk fitur Face ID & Notifikasi WA =====
$newPermissions = [
// [key, group, label, description]
['face-scan', 'wajah', 'Scan Wajah', 'Scan wajah siswa untuk verifikasi identitas'],
['face-register', 'wajah', 'Registrasi Wajah', 'Registrasi/perbarui foto wajah siswa'],
['notify-parent-wa', 'pelanggaran', 'Notifikasi WA Orang Tua', 'Kirim notifikasi WhatsApp ke orang tua/wali'],
];
$permIds = [];
foreach ($newPermissions as [$key, $group, $label, $desc]) {
$id = DB::table('permissions')->insertGetId([
'key' => $key,
'group' => $group,
'label' => $label,
'description' => $desc,
'created_at' => $now,
'updated_at' => $now,
]);
$permIds[$key] = $id;
}
// ===== 2) Default grant: role yang punya akses terkait ikut dapat =====
// face-scan & face-register → role yang punya input-violations
$inputRoles = DB::table('role_permissions')
->join('permissions', 'permissions.id', '=', 'role_permissions.permission_id')
->where('permissions.key', 'input-violations')
->pluck('role_permissions.role')
->unique();
foreach (['face-scan', 'face-register'] as $key) {
foreach ($inputRoles as $role) {
DB::table('role_permissions')->insertOrIgnore([
'role' => $role,
'permission_id' => $permIds[$key],
'created_at' => $now,
'updated_at' => $now,
]);
}
}
// notify-parent-wa → role yang punya view-violations
$viewRoles = DB::table('role_permissions')
->join('permissions', 'permissions.id', '=', 'role_permissions.permission_id')
->where('permissions.key', 'view-violations')
->pluck('role_permissions.role')
->unique();
foreach ($viewRoles as $role) {
DB::table('role_permissions')->insertOrIgnore([
'role' => $role,
'permission_id' => $permIds['notify-parent-wa'],
'created_at' => $now,
'updated_at' => $now,
]);
}
// Admin selalu full — pastikan 3 permission baru masuk admin
foreach ($permIds as $pid) {
DB::table('role_permissions')->insertOrIgnore([
'role' => 'admin',
'permission_id' => $pid,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
public function down(): void
{
DB::table('permissions')
->whereIn('key', ['face-scan', 'face-register', 'notify-parent-wa'])
->delete();
}
};
@@ -0,0 +1,61 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
$now = now();
$pid = fn (string $key) => DB::table('permissions')->where('key', $key)->value('id');
$grant = function (string $role, array $keys) use ($now, $pid) {
foreach ($keys as $key) {
$id = $pid($key);
if ($id) {
DB::table('role_permissions')->insertOrIgnore([
'role' => $role,
'permission_id' => $id,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
};
// ===== Kepala Sekolah: view + export + scan (bukan input harian) =====
$grant('kepala_sekolah', [
'access-dashboard',
'view-violations',
'view-students',
'view-sp-letters',
'view-notifications',
'violations-export',
'face-scan',
]);
// ===== Waka Kesiswaan: setara BK =====
$bkPerms = DB::table('role_permissions')
->join('permissions', 'permissions.id', '=', 'role_permissions.permission_id')
->where('role_permissions.role', 'bk')
->pluck('permissions.key')
->toArray();
$grant('waka_kesiswaan', $bkPerms);
// ===== Ketua Tim: setara Staff =====
$staffPerms = DB::table('role_permissions')
->join('permissions', 'permissions.id', '=', 'role_permissions.permission_id')
->where('role_permissions.role', 'staff')
->pluck('permissions.key')
->toArray();
$grant('ketua_tim', $staffPerms);
}
public function down(): void
{
DB::table('role_permissions')
->whereIn('role', ['kepala_sekolah', 'waka_kesiswaan', 'ketua_tim'])
->delete();
}
};
@@ -0,0 +1,33 @@
<?php
namespace Database\Seeders;
use App\Models\HandlingType;
use Illuminate\Database\Seeder;
class HandlingTypeDefaultsSeeder extends Seeder
{
public function run(): void
{
$defaults = [
['name' => 'Teguran Lisan', 'icon' => 'fa-comment', 'color' => '#3b82f6', 'sort_order' => 1],
['name' => 'Teguran Tertulis', 'icon' => 'fa-file-pen', 'color' => '#4f46e5', 'sort_order' => 2],
['name' => 'Pembinaan BK', 'icon' => 'fa-hand-holding-heart', 'color' => '#14b8a6', 'sort_order' => 3],
['name' => 'Panggilan Orang Tua', 'icon' => 'fa-phone-volume', 'color' => '#f97316', 'sort_order' => 4],
['name' => 'Home Visit', 'icon' => 'fa-house-chimney', 'color' => '#8b5cf6', 'sort_order' => 5],
['name' => 'Skorsing', 'icon' => 'fa-ban', 'color' => '#ef4444', 'sort_order' => 6],
['name' => 'Tugas Sosial', 'icon' => 'fa-handshake-angle', 'color' => '#10b981', 'sort_order' => 7],
['name' => 'SP-1', 'icon' => 'fa-file-signature', 'color' => '#f59e0b', 'sort_order' => 8],
['name' => 'SP-2', 'icon' => 'fa-file-signature', 'color' => '#ea580c', 'sort_order' => 9],
['name' => 'SP-3', 'icon' => 'fa-file-circle-exclamation','color' => '#dc2626', 'sort_order' => 10],
['name' => 'Lainnya', 'icon' => 'fa-clipboard-list', 'color' => '#64748b', 'sort_order' => 99],
];
foreach ($defaults as $data) {
HandlingType::updateOrCreate(
['name' => $data['name']],
$data + ['is_system' => true, 'is_active' => true]
);
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+16
View File
@@ -0,0 +1,16 @@
{
"name": "Aplikasi Ketertiban SMKN 1 Wonorejo",
"short_name": "Ketertiban",
"description": "Sistem pencatatan pelanggaran dan ketertiban siswa",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#eff6ff",
"theme_color": "#2563eb",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

+64
View File
@@ -0,0 +1,64 @@
/* Service Worker - Aplikasi Ketertiban v1 */
const CACHE = 'ketertiban-v1';
const APP_SHELL = ['/', '/login'];
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(CACHE)
.then((c) => c.addAll(APP_SHELL))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (e) => {
const req = e.request;
if (req.method !== 'GET') return;
const url = new URL(req.url);
if (url.origin !== location.origin) return;
// API: selalu ambil dari jaringan (data harus segar)
if (url.pathname.startsWith('/notifications') ||
url.pathname.startsWith('/api/') ||
url.pathname.startsWith('/calendar/')) {
return;
}
// Navigasi halaman: network-first, fallback ke cache saat offline
if (req.mode === 'navigate') {
e.respondWith(
fetch(req)
.then((res) => {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put('/offline-shell', copy));
return res;
})
.catch(() =>
caches.match('/offline-shell').then((r) => r || caches.match('/'))
)
);
return;
}
// Asset statis (build, icons, font): cache-first
e.respondWith(
caches.match(req).then((cached) =>
cached ||
fetch(req).then((res) => {
if (res.ok) {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(req, copy));
}
return res;
})
)
);
});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+363
View File
@@ -0,0 +1,363 @@
/**
* facefinder.js — Live face detection + LIVENESS multi-sinyal overlay.
* Self-hosted penuh (tanpa CDN): vision_bundle.mjs + wasm/ + blaze_face_short_range.tflite
* + face_landmarker.task (blendshape senyum + kedalaman 3D + kedipan).
*
* Sinyal liveness (PASIF, cukup 1 lolos):
* 1) Kedipan mata — EAR (eye aspect ratio) [PENERIMA]
* 2) Senyum — blendshape mouthSmile [PENERIMA]
* 3) Kedalaman 3D — rentang koordinat z landmark [INFO]
* 4) Mikro-gerakan — selisih piksel area wajah antar frame [INFO]
*
* API:
* window.__faceLoop.start(videoEl, canvasEl)
* window.__faceLoop.stop()
* window.__faceLoop.detected
* window.__faceLoop.blinkCount
* window.__faceLoop.signalCount — jumlah sinyal penerimaan (kedip+senyum, 0-2)
* window.__faceLoop.signals — rincian {blink, smile, depth, motion}
* window.__faceLoop.resetLiveness()
*
* Status disiarkan ke UI via CustomEvent 'face-state' dan tombol #btnCapture
* di-disable otomatis bila tidak ada wajah.
*/
const VENDOR = '/vendor/mediapipe';
let detectorPromise = null;
let landmarkerPromise = null;
let running = false;
let rafId = null;
let videoEl = null;
let canvasEl = null;
let ctx = null;
let lastDetected = false;
// ==== Liveness ====
let blinks = 0;
let eyesClosed = false;
let blinkCooldownUntil = 0;
let smileSeen = false;
let depthSeen = false;
let motionSeen = false;
let prevFaceGray = null;
let livenessAvailable = false;
// Ambang (soft — salah kalibrasi tidak memblokir karena logika ANY-of)
const EAR_CLOSED = 0.20; // mata dianggap tertutup
const SMILE_MIN = 0.35; // skor blendshape senyum
const DEPTH_MIN = 0.03; // rentang z landmark
const MOTION_MIN = 2.0; // selisih rata-rata piksel (skala 0-255)
// Throttle beban (HP kelas menengah ke bawah)
let frameCounter = 0;
const LANDMARK_EVERY = 3; // landmarker tiap 3 frame
const MOTION_EVERY = 2; // motion tiap 2 frame
let lastFaceTime = 0; // watchdog: kapan terakhir ada wajah
// Indeks landmark mata (FaceMesh 478 titik)
const LEFT_EYE = [33, 160, 158, 133, 153, 144];
const RIGHT_EYE = [362, 385, 387, 263, 373, 380];
function ear(points, idx) {
const p = (i) => points[idx.indexOf(i)];
const a = Math.hypot(p(1).x - p(5).x, p(1).y - p(5).y);
const b = Math.hypot(p(2).x - p(4).x, p(2).y - p(4).y);
const c = Math.hypot(p(0).x - p(3).x, p(0).y - p(3).y);
return (a + b) / (2 * c + 1e-6);
}
async function loadDetector() {
const { FilesetResolver, FaceDetector } = await import(`${VENDOR}/vision_bundle.mjs`);
const fileset = await FilesetResolver.forVisionTasks(`${VENDOR}/wasm`);
return FaceDetector.createFromOptions(fileset, {
baseOptions: { modelAssetPath: `${VENDOR}/blaze_face_short_range.tflite`, delegate: 'CPU' },
runningMode: 'VIDEO',
minDetectionConfidence: 0.5,
numFaces: 1,
});
}
async function loadLandmarker() {
const { FilesetResolver, FaceLandmarker } = await import(`${VENDOR}/vision_bundle.mjs`);
const fileset = await FilesetResolver.forVisionTasks(`${VENDOR}/wasm`);
const lm = await FaceLandmarker.createFromOptions(fileset, {
baseOptions: { modelAssetPath: `${VENDOR}/face_landmarker.task`, delegate: 'CPU' },
runningMode: 'VIDEO',
numFaces: 1,
outputFaceBlendshapes: true,
outputFacialTransformationMatrixes: false,
});
livenessAvailable = true;
return lm;
}
function emit(detected) {
window.__faceState = { detected, blinkCount: blinks, signalCount: signalCount() };
document.dispatchEvent(new CustomEvent('face-state', {
detail: { detected, blinkCount: blinks, signalCount: signalCount() },
}));
const btn = document.getElementById('btnCapture');
if (btn) {
btn.disabled = !detected;
if (detected) {
btn.classList.remove('opacity-50', 'cursor-not-allowed');
} else {
btn.classList.add('opacity-50', 'cursor-not-allowed');
}
}
const hint = document.getElementById('faceHint');
if (hint) {
hint.textContent = detected ? '✓ WAJAH TERDETEKSI' : 'CARI WAJAH…';
hint.className = 'text-[11px] font-bold tracking-wider px-3.5 py-1.5 rounded-full backdrop-blur-md transition-all duration-300 ' + (detected ? 'bg-emerald-500/90 text-white shadow-lg shadow-emerald-500/30' : 'bg-red-500/80 text-white shadow-lg shadow-red-500/30');
}
}
/** Gambar kotak hijau dengan kalkulasi crop object-cover agar sejajar dengan wajah. */
function draw(box) {
if (!ctx || !canvasEl || !videoEl) return;
const cw = canvasEl.clientWidth, ch = canvasEl.clientHeight;
canvasEl.width = cw; canvasEl.height = ch;
ctx.clearRect(0, 0, cw, ch);
if (!box) return;
const vw = videoEl.videoWidth, vh = videoEl.videoHeight;
if (!vw || !vh) return;
const s = Math.max(cw / vw, ch / vh);
const offX = (vw * s - cw) / 2 / s;
const offY = (vh * s - ch) / 2 / s;
const x = (box.x - offX) * s;
const y = (box.y - offY) * s;
const w = box.w * s;
const h = box.h * s;
ctx.strokeStyle = '#22c55e';
ctx.lineWidth = Math.max(2.5, cw / 320);
ctx.strokeRect(x, y, w, h);
const cl = ctx.lineWidth * 2;
ctx.strokeStyle = '#16a34a';
ctx.lineWidth = cl;
const cs = Math.min(w, h) * 0.18;
[
[x, y, 1, 1], [x + w, y, -1, 1], [x, y + h, 1, -1], [x + w, y + h, -1, -1],
].forEach(([cx, cy, dx, dy]) => {
ctx.beginPath();
ctx.moveTo(cx + dx * cs, cy);
ctx.lineTo(cx, cy);
ctx.lineTo(cx, cy + dy * cs);
ctx.stroke();
});
}
/** Sinyal 1: kedipan mata (EAR). */
function processBlink(landmarks) {
const lm = landmarks?.[0];
if (!lm || lm.length < 468) return;
const earL = ear(lm, LEFT_EYE);
const earR = ear(lm, RIGHT_EYE);
const avg = (earL + earR) / 2;
const now = performance.now();
if (avg < EAR_CLOSED) {
if (!eyesClosed && now > blinkCooldownUntil) {
eyesClosed = true;
blinks++;
blinkCooldownUntil = now + 700;
emit(!!window.__faceState?.detected);
}
} else {
eyesClosed = false;
}
}
/** Sinyal 2: senyum (blendshape). */
function processSmile(blendshapes) {
if (!blendshapes || smileSeen) return;
let left = 0, right = 0;
for (const c of blendshapes) {
if (c.categoryName === 'mouthSmileLeft') left = c.score;
if (c.categoryName === 'mouthSmileRight') right = c.score;
}
if ((left + right) / 2 >= SMILE_MIN) {
smileSeen = true;
emit(!!window.__faceState?.detected);
}
}
/** Sinyal 3: kedalaman 3D wajah (rentang z landmark). */
function processDepth(landmarks) {
const lm = landmarks?.[0];
if (!lm || lm.length < 468 || depthSeen) return;
let minZ = Infinity, maxZ = -Infinity;
for (let i = 0; i < 468; i++) {
const z = lm[i].z;
if (z < minZ) minZ = z;
if (z > maxZ) maxZ = z;
}
if (maxZ - minZ >= DEPTH_MIN) {
depthSeen = true;
emit(!!window.__faceState?.detected);
}
}
/** Sinyal 4: mikro-gerakan area wajah (selisih piksel antar frame). */
function processMotion(box) {
if (motionSeen || !videoEl) return;
const vw = videoEl.videoWidth, vh = videoEl.videoHeight;
if (!vw || !vh) return;
const size = 24;
const c = document.createElement('canvas');
c.width = size; c.height = size;
const g = c.getContext('2d', { willReadFrequently: true });
const sx = Math.max(0, box.x * vw), sy = Math.max(0, box.y * vh);
const sw = Math.min(vw - sx, box.w * vw), sh = Math.min(vh - sy, box.h * vh);
if (sw <= 0 || sh <= 0) return;
g.drawImage(videoEl, sx, sy, sw, sh, 0, 0, size, size);
const d = g.getImageData(0, 0, size, size).data;
const gray = new Float32Array(size * size);
for (let i = 0; i < size * size; i++) {
gray[i] = 0.299 * d[i * 4] + 0.587 * d[i * 4 + 1] + 0.114 * d[i * 4 + 2];
}
if (prevFaceGray) {
let diff = 0;
for (let i = 0; i < gray.length; i++) diff += Math.abs(gray[i] - prevFaceGray[i]);
diff /= gray.length;
if (diff > MOTION_MIN) {
motionSeen = true;
emit(!!window.__faceState?.detected);
}
}
prevFaceGray = gray;
}
/** Jumlah sinyal PENGGUNA UNTUK PENERIMAAN (strong signals): kedip + senyum.
* Depth & motion TIDAK dipakai menerima (foto yang dipegang ikut bergerak;
* ambang depth perlu kalibrasi lapangan) — hanya info di tracker. */
function signalCount() {
return [blinks > 0, smileSeen].filter(Boolean).length;
}
async function tick(detector) {
if (!running) return;
frameCounter++;
if (videoEl && videoEl.readyState >= 2 && !videoEl.paused && videoEl.videoWidth) {
try {
const res = detector.detectForVideo(videoEl, performance.now());
const face = res.detections?.[0];
const detected = !!face;
if (detected) {
lastFaceTime = performance.now();
const b = face.boundingBox;
const box = { x: b.originX / videoEl.videoWidth, y: b.originY / videoEl.videoHeight, w: b.width / videoEl.videoWidth, h: b.height / videoEl.videoHeight };
draw(box);
if (frameCounter % MOTION_EVERY === 0) processMotion(box);
// Liveness via landmark (best-effort, di-throttle)
if (landmarker) {
if (frameCounter % LANDMARK_EVERY === 0) {
try {
const lres = landmarker.detectForVideo(videoEl, performance.now());
processBlink(lres.faceLandmarks);
processSmile(lres.faceBlendshapes?.[0]?.categories);
processDepth(lres.faceLandmarks);
} catch (e) { /* lewati */ }
}
} else if (!landmarkerPromise) {
landmarkerPromise = loadLandmarker()
.then(l => { landmarker = l; })
.catch(() => {
landmarkerPromise = null;
livenessAvailable = false;
document.dispatchEvent(new CustomEvent('face-liveness-off'));
});
}
} else {
draw(null);
prevFaceGray = null;
}
if (detected !== lastDetected) {
lastDetected = detected;
emit(detected);
}
// Watchdog: deteksi macet walau kamera jalan → log (untuk diagnosa)
if (detected && performance.now() - lastFaceTime > 3000) {
console.warn('[facefinder] deteksi lambat/macet, fps rendah');
}
} catch (e) {
console.error('[facefinder] error frame:', e);
}
}
try {
rafId = requestAnimationFrame(() => tick(detector));
} catch (e) {
console.error('[facefinder] rAF gagal:', e);
}
}
window.__faceLoop = {
async start(video, canvas) {
videoEl = video;
canvasEl = canvas;
ctx = canvasEl ? canvasEl.getContext('2d') : null;
running = true;
lastDetected = false;
this.resetLiveness();
emit(false);
try {
if (!detectorPromise) detectorPromise = loadDetector();
const detector = await detectorPromise;
if (!running || !videoEl) return;
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => tick(detector));
} catch (e) {
console.error('Gagal memuat face detector:', e);
running = false;
document.dispatchEvent(new CustomEvent('face-detector-error'));
}
},
stop() {
running = false;
if (rafId) cancelAnimationFrame(rafId);
rafId = null;
if (ctx && canvasEl) {
canvasEl.width = canvasEl.clientWidth;
ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);
}
lastDetected = false;
prevFaceGray = null;
emit(false);
},
resetLiveness() {
blinks = 0;
eyesClosed = false;
smileSeen = false;
depthSeen = false;
motionSeen = false;
prevFaceGray = null;
},
get livenessAvailable() {
return livenessAvailable;
},
get detected() {
return !!(window.__faceState && window.__faceState.detected);
},
get blinkCount() {
return blinks;
},
get signalCount() {
return signalCount();
},
get signals() {
return { blink: blinks > 0, smile: smileSeen, depth: depthSeen, motion: motionSeen };
},
};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
+1
View File
@@ -1,5 +1,6 @@
@import 'tailwindcss';
@import '@fortawesome/fontawesome-free/css/fontawesome.css';
@import '@fortawesome/fontawesome-free/css/brands.css';
@import '@fortawesome/fontawesome-free/css/regular.css';
@import '@fortawesome/fontawesome-free/css/solid.css';
+37
View File
@@ -4,6 +4,24 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
{{-- Favicon + OG --}}
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" type="image/png" sizes="192x192" href="/logo.png">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<meta property="og:title" content="E-TATIB S1WON - SMKN 1 Wonorejo">
<meta property="og:description" content="Sistem Ketertiban Siswa SMKN 1 Wonorejo — Manajemen pelanggaran, presensi, dan surat peringatan siswa.">
<meta property="og:type" content="website">
<meta property="og:image" content="{{ asset('og-image.png') }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
{{-- PWA --}}
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#2563eb">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Ketertiban">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
@php
$schoolName = App\Models\Setting::getValue('school_name', 'Ketertiban');
$appName = App\Models\Setting::getValue('app_name', 'Aplikasi Ketertiban');
@@ -69,6 +87,16 @@
<div class="flex items-center justify-center bg-white p-4 sm:p-8 lg:p-8">
<div class="w-full max-w-sm">
<div class="mb-5 text-center lg:text-left">
{{-- Logo aplikasi — tampil di semua ukuran layar (penting utk mobile/PWA) --}}
<div class="mb-4 flex justify-center lg:justify-start">
<div class="inline-flex h-16 w-16 items-center justify-center rounded-3xl bg-blue-50 ring-1 ring-blue-100 shadow-sm">
@if($logoPath)
<img src="{{ asset('storage/' . $logoPath) }}" alt="Logo {{ $appName }}" class="w-11 h-11 object-contain">
@else
<span class="text-xl font-extrabold tracking-tight text-blue-600">KT</span>
@endif
</div>
</div>
<p class="text-sm font-semibold uppercase tracking-[0.2em] text-blue-600">{{ $appName }}</p>
<p class="text-xs text-slate-400 mt-0.5">{{ $schoolName }}</p>
<h2 class="mt-2 text-2xl sm:text-[28px] font-semibold text-slate-900">Selamat Datang</h2>
@@ -163,5 +191,14 @@
});
});
</script>
{{-- PWA: Service Worker --}}
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.catch(err => console.error('SW registration failed:', err));
});
}
</script>
</body>
</html>
@@ -20,8 +20,12 @@ $faIcons = [
'cog' => 'fa-solid fa-gear',
'users-cog' => 'fa-solid fa-user-gear',
'database' => 'fa-solid fa-database',
'face-scan' => 'fa-solid fa-camera-retro',
'user-plus' => 'fa-solid fa-user-plus',
'arrows-rotate' => 'fa-solid fa-arrows-rotate',
'lock' => 'fa-solid fa-lock',
'hand-holding-heart' => 'fa-solid fa-hand-holding-heart',
'chalkboard-user' => 'fa-solid fa-chalkboard-user',
];
$faClass = $faIcons[$icon] ?? 'fa-solid fa-circle';
@@ -40,8 +44,12 @@ $iconColors = [
'cog' => ['active' => 'text-slate-600', 'inactive' => 'text-slate-400/60 group-hover:text-slate-500'],
'users-cog' => ['active' => 'text-purple-600', 'inactive' => 'text-purple-400/60 group-hover:text-purple-500'],
'database' => ['active' => 'text-sky-600', 'inactive' => 'text-sky-400/60 group-hover:text-sky-500'],
'face-scan' => ['active' => 'text-cyan-600', 'inactive' => 'text-cyan-400/60 group-hover:text-cyan-500'],
'user-plus' => ['active' => 'text-indigo-600', 'inactive' => 'text-indigo-400/60 group-hover:text-indigo-500'],
'arrows-rotate' => ['active' => 'text-rose-600', 'inactive' => 'text-rose-400/60 group-hover:text-rose-500'],
'lock' => ['active' => 'text-yellow-600', 'inactive' => 'text-yellow-400/60 group-hover:text-yellow-500'],
'hand-holding-heart' => ['active' => 'text-amber-600', 'inactive' => 'text-amber-400/60 group-hover:text-amber-500'],
'chalkboard-user' => ['active' => 'text-emerald-600', 'inactive' => 'text-emerald-400/60 group-hover:text-emerald-500'],
];
$ic = $iconColors[$icon] ?? ['active' => 'text-blue-600', 'inactive' => 'text-gray-400 group-hover:text-gray-600'];
$iconClass = $isActive ? $ic['active'] : $ic['inactive'];
+198
View File
@@ -54,6 +54,78 @@
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
{{-- Grafik Tren Pelanggaran --}}
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden" x-data="trendToggle()">
<div class="flex flex-wrap items-center justify-between gap-3 px-5 py-4 border-b border-gray-100">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-sky-400 flex items-center justify-center text-white shadow-sm">
<i class="fa-solid fa-chart-line"></i>
</div>
<div>
<h3 class="text-sm font-bold text-gray-800">Tren Pelanggaran Siswa</h3>
<p class="text-[11px] text-gray-400">Jumlah pelanggaran per hari</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs font-semibold text-gray-500">Total <span x-text="period"></span> hari: <strong class="text-gray-800" x-text="period === 7 ? total7 : (period === 14 ? total14 : total30)"></strong></span>
<span class="text-xs font-semibold text-gray-500">Hari ini: <strong class="text-gray-800">{{ $dailyCurrent }}</strong></span>
@if ($trendPercent > 0)
<span class="inline-flex items-center gap-1 rounded-full bg-red-50 px-2.5 py-1 text-[11px] font-bold text-red-600 ring-1 ring-red-200">
<i class="fa-solid fa-arrow-trend-up"></i> +{{ $trendPercent }}% vs kemarin
</span>
@elseif ($trendPercent < 0)
<span class="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2.5 py-1 text-[11px] font-bold text-emerald-600 ring-1 ring-emerald-200">
<i class="fa-solid fa-arrow-trend-down"></i> {{ $trendPercent }}% vs kemarin
</span>
@else
<span class="inline-flex items-center gap-1 rounded-full bg-slate-50 px-2.5 py-1 text-[11px] font-bold text-slate-500 ring-1 ring-slate-200">
<i class="fa-solid fa-minus"></i> Stabil vs kemarin
</span>
@endif
<div class="flex items-center gap-1 p-1 rounded-xl bg-gray-100">
<button type="button" @click="setPeriod(7)" :class="period === 7 ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500 hover:text-gray-700'" class="px-3 py-1.5 text-xs font-bold rounded-lg transition">7 Hari</button>
<button type="button" @click="setPeriod(14)" :class="period === 14 ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500 hover:text-gray-700'" class="px-3 py-1.5 text-xs font-bold rounded-lg transition">14 Hari</button>
<button type="button" @click="setPeriod(30)" :class="period === 30 ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500 hover:text-gray-700'" class="px-3 py-1.5 text-xs font-bold rounded-lg transition">30 Hari</button>
</div>
</div>
</div>
<div class="p-5">
<div class="relative h-64">
<canvas id="trendChart"></canvas>
</div>
</div>
</div>
{{-- Grafik Jenis Pelanggaran --}}
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
<div class="flex flex-wrap items-center justify-between gap-3 px-5 py-4 border-b border-gray-100">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-orange-500 to-red-500 flex items-center justify-center text-white shadow-sm">
<i class="fa-solid fa-chart-column"></i>
</div>
<div>
<h3 class="text-sm font-bold text-gray-800">Pelanggaran per Jenis</h3>
<p class="text-[11px] text-gray-400">Jenis pelanggaran paling banyak</p>
</div>
</div>
<div class="flex items-center gap-1 rounded-xl bg-gray-100 p-1" x-data="{ typePeriod: 'today' }">
<button type="button" @click="typePeriod = 'today'; setTypePeriod('today')" class="rounded-lg px-3 py-1.5 text-[11px] font-bold transition"
:class="typePeriod === 'today' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'">Hari Ini</button>
<button type="button" @click="typePeriod = 'week'; setTypePeriod('week')" class="rounded-lg px-3 py-1.5 text-[11px] font-bold transition"
:class="typePeriod === 'week' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'">7 Hari</button>
<button type="button" @click="typePeriod = 'month'; setTypePeriod('month')" class="rounded-lg px-3 py-1.5 text-[11px] font-bold transition"
:class="typePeriod === 'month' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'">Bulan Ini</button>
</div>
</div>
<div class="p-5">
<div class="relative h-64">
<canvas id="typeChart"></canvas>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{{-- Calendar --}}
<div class="lg:col-span-2 bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden"
@@ -340,5 +412,131 @@
};
}
</script>
<script src="/vendor/chartjs/chart.umd.min.js"></script>
<script>
function trendToggle() {
return {
period: 7,
total7: {{ $trendTotal7 }},
total14: {{ $trendTotal14 }},
total30: {{ $trendTotal30 }},
labels7: @json($dailyLabels7),
data7: @json($dailyData7),
labels14: @json($dailyLabels14),
data14: @json($dailyData14),
labels30: @json($dailyLabels30),
data30: @json($dailyData30),
chart: null,
init() {
const ctx = document.getElementById('trendChart');
if (!ctx || typeof Chart === 'undefined') return;
const g = ctx.getContext('2d');
const grad = g.createLinearGradient(0, 0, 0, 260);
grad.addColorStop(0, 'rgba(37, 99, 235, 0.28)');
grad.addColorStop(1, 'rgba(37, 99, 235, 0.02)');
this.chart = new Chart(ctx, {
type: 'line',
data: {
labels: this.labels7,
datasets: [
{
label: 'Pelanggaran',
data: this.data7,
borderColor: '#2563eb',
backgroundColor: grad,
fill: true,
tension: 0.4,
borderWidth: 2.5,
pointRadius: 4,
pointBackgroundColor: '#2563eb',
pointBorderColor: '#fff',
pointBorderWidth: 2,
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: '#0f172a',
padding: 10,
cornerRadius: 10,
titleFont: { weight: 'bold' },
callbacks: {
label: (ctx) => ctx.parsed.y + ' pelanggaran'
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: { precision: 0, color: '#94a3b8' },
grid: { color: '#f1f5f9' }
},
x: {
ticks: { color: '#94a3b8', maxRotation: 0, autoSkip: true, maxTicksLimit: 15 },
grid: { display: false }
}
}
}
});
},
setPeriod(n) {
this.period = n;
if (!this.chart) return;
this.chart.data.labels = n === 7 ? this.labels7 : (n === 14 ? this.labels14 : this.labels30);
this.chart.data.datasets[0].data = n === 7 ? this.data7 : (n === 14 ? this.data14 : this.data30);
this.chart.update();
},
};
}
{{-- Grafik batang: pelanggaran per jenis --}}
const typeCtx = document.getElementById('typeChart');
let typeChart = null;
if (typeCtx && typeof Chart !== 'undefined') {
typeChart = new Chart(typeCtx, {
type: 'bar',
data: {
labels: @json($typeNames),
datasets: [
{ label: 'Hari Ini', data: @json($typeToday), backgroundColor: 'rgba(249, 115, 22, 0.85)', borderRadius: 8, barThickness: 18 },
{ label: '7 Hari', data: @json($typeWeek), backgroundColor: 'rgba(59, 130, 246, 0.85)', borderRadius: 8, barThickness: 18, hidden: true },
{ label: 'Bulan Ini', data: @json($typeMonth), backgroundColor: 'rgba(139, 92, 246, 0.85)', borderRadius: 8, barThickness: 18, hidden: true }
]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: '#0f172a',
callbacks: { label: (ctx) => ctx.parsed.x + ' pelanggaran' }
}
},
scales: {
x: { beginAtZero: true, ticks: { precision: 0, color: '#94a3b8' }, grid: { color: '#f1f5f9' } },
y: { ticks: { color: '#64748b', font: { size: 11 } }, grid: { display: false } }
}
}
});
}
window.setTypePeriod = function (p) {
if (!typeChart) return;
const map = { today: 0, week: 1, month: 2 };
Object.keys(map).forEach(k => {
typeChart.data.datasets[map[k]].hidden = k !== p;
});
typeChart.update();
};
</script>
@endpush
@endsection
+10 -50
View File
@@ -1,50 +1,10 @@
@extends('layouts.app')
@section('title', 'Akses Ditolak')
@section('content')
<div class="min-h-[70vh] flex items-center justify-center">
<div class="text-center max-w-md mx-auto px-4">
{{-- Icon --}}
<div class="mx-auto w-20 h-20 rounded-2xl bg-gradient-to-br from-red-50 to-red-100 border border-red-200 flex items-center justify-center shadow-sm mb-6">
<i class="fa-solid fa-shield-halved text-3xl text-red-400"></i>
</div>
{{-- Code --}}
<div class="text-7xl font-black text-red-200 tracking-tight mb-2">403</div>
{{-- Title --}}
<h1 class="text-xl font-bold text-gray-900 mb-2">Akses Ditolak</h1>
{{-- Message --}}
<p class="text-sm text-gray-500 leading-relaxed mb-8">
@if($exception->getMessage() && $exception->getMessage() !== '')
{{ $exception->getMessage() }}
@else
Anda tidak memiliki izin untuk mengakses halaman ini.
@endif
</p>
{{-- Actions --}}
<div class="flex items-center justify-center gap-3">
<a href="{{ url()->previous() !== url()->current() ? url()->previous() : route('dashboard') }}"
class="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-semibold text-gray-700 bg-white border border-gray-200 rounded-xl hover:bg-gray-50 transition shadow-sm">
<i class="fa-solid fa-arrow-left text-xs"></i>
Kembali
</a>
<a href="{{ route('dashboard') }}"
class="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-semibold text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition shadow-sm">
<i class="fa-solid fa-house text-xs"></i>
Dashboard
</a>
</div>
{{-- Decorative dots --}}
<div class="mt-12 flex items-center justify-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full bg-red-200"></span>
<span class="w-1.5 h-1.5 rounded-full bg-red-300"></span>
<span class="w-1.5 h-1.5 rounded-full bg-red-400"></span>
</div>
</div>
</div>
@endsection
@include('errors._error', [
'code' => '403',
'title' => 'Akses Ditolak',
'message' => 'Anda tidak memiliki izin untuk mengakses halaman ini. Jika menurut Anda ini keliru, silakan hubungi administrator.',
'icon' => 'fa-lock',
'iconFrom' => '#ef4444', 'iconTo' => '#dc2626', 'iconShadow' => 'rgba(239, 68, 68, 0.35)',
'codeFrom' => '#f87171', 'codeTo' => '#dc2626',
'ctaFrom' => '#ef4444', 'ctaTo' => '#dc2626', 'ctaShadow' => 'rgba(239, 68, 68, 0.35)',
'showContact' => true,
])
+9 -36
View File
@@ -1,36 +1,9 @@
@extends('layouts.app')
@section('title', 'Halaman Tidak Ditemukan')
@section('content')
<div class="min-h-[70vh] flex items-center justify-center">
<div class="text-center max-w-md mx-auto px-4">
<div class="mx-auto w-20 h-20 rounded-2xl bg-gradient-to-br from-amber-50 to-amber-100 border border-amber-200 flex items-center justify-center shadow-sm mb-6">
<i class="fa-solid fa-map text-3xl text-amber-400"></i>
</div>
<div class="text-7xl font-black text-amber-200 tracking-tight mb-2">404</div>
<h1 class="text-xl font-bold text-gray-900 mb-2">Halaman Tidak Ditemukan</h1>
<p class="text-sm text-gray-500 leading-relaxed mb-8">Halaman yang Anda cari mungkin telah dipindah atau tidak tersedia.</p>
<div class="flex items-center justify-center gap-3">
<a href="{{ url()->previous() !== url()->current() ? url()->previous() : route('dashboard') }}"
class="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-semibold text-gray-700 bg-white border border-gray-200 rounded-xl hover:bg-gray-50 transition shadow-sm">
<i class="fa-solid fa-arrow-left text-xs"></i>
Kembali
</a>
<a href="{{ route('dashboard') }}"
class="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-semibold text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition shadow-sm">
<i class="fa-solid fa-house text-xs"></i>
Dashboard
</a>
</div>
<div class="mt-12 flex items-center justify-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full bg-amber-200"></span>
<span class="w-1.5 h-1.5 rounded-full bg-amber-300"></span>
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>
</div>
</div>
</div>
@endsection
@include('errors._error', [
'code' => '404',
'title' => 'Halaman Tidak Ditemukan',
'message' => 'Halaman yang Anda cari mungkin telah dipindah, dihapus, atau alamatnya salah. Coba periksa kembali atau kembali ke beranda.',
'icon' => 'fa-map',
'iconFrom' => '#f59e0b', 'iconTo' => '#f97316', 'iconShadow' => 'rgba(245, 158, 11, 0.35)',
'codeFrom' => '#fbbf24', 'codeTo' => '#f97316',
'ctaFrom' => '#f59e0b', 'ctaTo' => '#f97316', 'ctaShadow' => 'rgba(249, 115, 22, 0.35)',
])
+9
View File
@@ -0,0 +1,9 @@
@include('errors._error', [
'code' => '419',
'title' => 'Sesi Berakhir',
'message' => 'Sesi Anda telah berakhir atau tidak valid. Silakan muat ulang halaman dan coba lagi.',
'icon' => 'fa-hourglass-half',
'iconFrom' => '#f59e0b', 'iconTo' => '#d97706', 'iconShadow' => 'rgba(245, 158, 11, 0.35)',
'codeFrom' => '#fbbf24', 'codeTo' => '#d97706',
'ctaFrom' => '#f59e0b', 'ctaTo' => '#d97706', 'ctaShadow' => 'rgba(245, 158, 11, 0.35)',
])
+10
View File
@@ -0,0 +1,10 @@
@include('errors._error', [
'code' => '500',
'title' => 'Terjadi Kesalahan',
'message' => 'Maaf, terjadi kendala pada server kami. Silakan muat ulang halaman atau coba lagi beberapa saat. Jika terus berlanjut, hubungi administrator.',
'icon' => 'fa-triangle-exclamation',
'iconFrom' => '#8b5cf6', 'iconTo' => '#7c3aed', 'iconShadow' => 'rgba(139, 92, 246, 0.35)',
'codeFrom' => '#a78bfa', 'codeTo' => '#7c3aed',
'ctaFrom' => '#8b5cf6', 'ctaTo' => '#7c3aed', 'ctaShadow' => 'rgba(139, 92, 246, 0.35)',
'showContact' => true,
])
+10
View File
@@ -0,0 +1,10 @@
@include('errors._error', [
'code' => '503',
'title' => 'Sedang Pemeliharaan',
'message' => 'Sistem sedang dalam pemeliharaan. Silakan kembali beberapa saat lagi. Terima kasih atas kesabaran Anda.',
'icon' => 'fa-screwdriver-wrench',
'iconFrom' => '#0ea5e9', 'iconTo' => '#0284c7', 'iconShadow' => 'rgba(14, 165, 233, 0.35)',
'codeFrom' => '#38bdf8', 'codeTo' => '#0284c7',
'ctaFrom' => '#0ea5e9', 'ctaTo' => '#0284c7', 'ctaShadow' => 'rgba(14, 165, 233, 0.35)',
'showBack' => false,
])
+174
View File
@@ -0,0 +1,174 @@
{{-- Template halaman error premium v2 (standalone, CSS murni — tanpa CDN) --}}
@php
$appName = \App\Models\Setting::getValue('app_name', 'Aplikasi Ketertiban');
$schoolName = \App\Models\Setting::getValue('school_name', 'SMKN 1 Wonorejo');
@endphp
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $code }} - {{ $title }} | {{ $appName }}</title>
<link rel="stylesheet" href="/vendor/font-awesome/css/all.min.css">
<link rel="icon" href="/favicon.ico">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body {
min-height: 100vh; display: flex; align-items: center; justify-content: center;
padding: 20px; position: relative; overflow: hidden;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
background:
radial-gradient(1200px 600px at 80% -10%, {{ $ctaFrom }}22, transparent 60%),
radial-gradient(900px 500px at -10% 110%, {{ $ctaTo }}1f, transparent 60%),
linear-gradient(135deg, #f8fafc 0%, #eef4ff 50%, #f4f8ff 100%);
}
{{-- Pola grid halus --}}
.bg-grid {
position: absolute; inset: 0; pointer-events: none; opacity: .5;
background-image:
linear-gradient(rgba(100,116,139,.06) 1px, transparent 1px),
linear-gradient(90deg, rgba(100,116,139,.06) 1px, transparent 1px);
background-size: 44px 44px;
mask-image: radial-gradient(ellipse at center, #000 30%, transparent 75%);
-webkit-mask-image: radial-gradient(ellipse at center, #000 30%, transparent 75%);
}
{{-- Orb mengambang --}}
.orb { position: absolute; border-radius: 9999px; filter: blur(70px); pointer-events: none; animation: drift 14s ease-in-out infinite; }
.orb-1 { width: 420px; height: 420px; top: -120px; right: -100px; background: {{ $ctaFrom }}38; }
.orb-2 { width: 480px; height: 480px; bottom: -160px; left: -120px; background: {{ $ctaTo }}33; animation-delay: -5s; }
.orb-3 { width: 220px; height: 220px; top: 40%; left: 55%; background: {{ $codeFrom }}26; animation-delay: -9s; }
@keyframes drift {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(30px, -30px) scale(1.08); }
}
{{-- Titik dekoratif --}}
.dots {
position: absolute; inset: 0; pointer-events: none; opacity: .35;
background-image: radial-gradient(rgba(148,163,184,.35) 1.2px, transparent 1.2px);
background-size: 26px 26px;
mask-image: radial-gradient(ellipse at 50% 40%, #000 10%, transparent 70%);
-webkit-mask-image: radial-gradient(ellipse at 50% 40%, #000 10%, transparent 70%);
}
{{-- Kartu glass + border gradien berputar --}}
.card {
position: relative; z-index: 10; width: 100%; max-width: 500px;
background: rgba(255,255,255,.72); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);
border-radius: 30px; padding: 44px 36px; text-align: center;
box-shadow: 0 30px 70px -20px rgba(15,23,42,.25), 0 0 0 1px rgba(255,255,255,.6) inset;
animation: fadeUp .55s cubic-bezier(.22,.9,.3,1.2) both;
}
.card::before {
content: ""; position: absolute; inset: -2px; border-radius: 32px; z-index: -1;
background: conic-gradient(from var(--angle, 0deg),
transparent 0%, {{ $ctaTo }} 12%, transparent 26%,
transparent 50%, {{ $ctaFrom }} 62%, transparent 78%);
animation: spin 7s linear infinite;
}
@property --angle { syntax: '<angle>'; initial-value: 0deg; inherits: false; }
@keyframes spin { to { --angle: 360deg; } }
@keyframes fadeUp { from { opacity: 0; transform: translateY(22px) scale(.98); } to { opacity: 1; transform: none; } }
.icon-box {
width: 92px; height: 92px; margin: 0 auto 26px; position: relative;
border-radius: 26px; display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, {{ $iconFrom }}, {{ $iconTo }});
box-shadow: 0 16px 34px {{ $iconShadow }}, 0 0 0 6px rgba(255,255,255,.7);
animation: floaty 3.2s ease-in-out infinite;
}
.icon-box::after {
content: ""; position: absolute; inset: -14px; border-radius: 34px;
background: linear-gradient(135deg, {{ $iconFrom }}33, transparent 60%);
filter: blur(8px); z-index: -1;
}
.icon-box i { font-size: 34px; color: #fff; filter: drop-shadow(0 3px 6px rgba(0,0,0,.18)); }
@keyframes floaty { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-8px); } }
{{-- Angka besar berlapis --}}
.code-wrap { position: relative; margin-bottom: 14px; }
.code {
position: relative; z-index: 2; font-size: 108px; font-weight: 900; letter-spacing: -4px; line-height: 1;
background: linear-gradient(135deg, {{ $codeFrom }}, {{ $codeTo }});
-webkit-background-clip: text; background-clip: text; color: transparent;
filter: drop-shadow(0 10px 24px {{ $iconShadow }});
}
.code-ghost {
position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
font-size: 190px; font-weight: 900; letter-spacing: -6px; line-height: 1;
color: transparent; -webkit-text-stroke: 1.5px {{ $codeFrom }}1f;
pointer-events: none; user-select: none;
}
h1 { font-size: 21px; font-weight: 800; color: #0f172a; margin-bottom: 10px; }
.msg { font-size: 14px; color: #64748b; line-height: 1.7; margin-bottom: 34px; }
.actions { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
padding: 13px 22px; font-size: 14px; font-weight: 700; border-radius: 16px;
text-decoration: none; border: 2px solid transparent; cursor: pointer;
transition: transform .15s ease, box-shadow .15s ease, background .15s ease, border-color .15s ease;
animation: fadeUp .55s cubic-bezier(.22,.9,.3,1.2) both;
}
.btn:hover { transform: translateY(-2px); }
.btn:active { transform: translateY(0) scale(.98); }
.btn-ghost { background: rgba(255,255,255,.9); color: #334155; border-color: #e2e8f0; animation-delay: .08s; }
.btn-ghost:hover { background: #fff; border-color: #cbd5e1; }
.btn-primary {
background: linear-gradient(135deg, {{ $ctaFrom }}, {{ $ctaTo }}); color: #fff;
box-shadow: 0 12px 28px {{ $ctaShadow }};
}
.btn-primary:hover { filter: brightness(1.06); box-shadow: 0 16px 34px {{ $ctaShadow }}; }
.btn-contact { background: #eff6ff; color: #2563eb; border-color: #dbeafe; animation-delay: .16s; }
.btn-contact:hover { background: #dbeafe; }
.footer { text-align: center; margin-top: 26px; font-size: 12px; color: #94a3b8; animation: fadeUp .55s both .22s; }
.footer i { margin-right: 5px; color: #cbd5e1; }
@media (max-width: 480px) {
.card { padding: 34px 22px; }
.code { font-size: 84px; letter-spacing: -3px; }
.code-ghost { font-size: 140px; }
.actions { flex-direction: column; }
.btn { width: 100%; }
}
</style>
</head>
<body>
<div class="bg-grid"></div>
<div class="orb orb-1"></div>
<div class="orb orb-2"></div>
<div class="orb orb-3"></div>
<div class="dots"></div>
<div class="card">
<div class="icon-box"><i class="fa-solid {{ $icon }}"></i></div>
<div class="code-wrap">
<div class="code-ghost">{{ $code }}</div>
<div class="code">{{ $code }}</div>
</div>
<h1>{{ $title }}</h1>
<p class="msg">{{ $message }}</p>
<div class="actions">
@if($showBack ?? true)
<button onclick="history.length > 1 ? history.back() : window.location.href='{{ url('/') }}'" class="btn btn-ghost">
<i class="fa-solid fa-arrow-left" style="font-size:12px"></i> Kembali
</button>
@endif
<a href="{{ url('/') }}" class="btn btn-primary">
<i class="fa-solid fa-house" style="font-size:12px"></i> Ke Beranda
</a>
@if($showContact ?? false)
<a href="mailto:admin@smkn1-wonorejo.sch.id" class="btn btn-contact">
<i class="fa-solid fa-headset" style="font-size:12px"></i> Hubungi Admin
</a>
@endif
</div>
</div>
<p class="footer"><i class="fa-solid fa-shield-halved"></i>{{ $appName }} • {{ $schoolName }}</p>
</body>
</html>
+445
View File
@@ -0,0 +1,445 @@
@extends('layouts.app')
@section('title', 'Registrasi Wajah')
@section('content')
<div class="pb-10">
{{-- ===== HEADER PREMIUM ===== --}}
<div class="relative overflow-hidden rounded-3xl bg-gradient-to-r from-indigo-600 via-violet-500 to-fuchsia-400 shadow-xl shadow-indigo-500/20 px-6 py-6 mb-6">
<div class="absolute -top-16 -right-16 w-56 h-56 rounded-full bg-white/10 blur-2xl"></div>
<div class="absolute -bottom-20 left-1/3 w-48 h-48 rounded-full bg-fuchsia-300/20 blur-2xl"></div>
<div class="absolute top-0 right-24 w-24 h-24 rounded-full border border-white/10"></div>
<div class="relative flex items-center gap-4">
<div class="w-14 h-14 rounded-2xl bg-white/15 backdrop-blur flex items-center justify-center shadow-inner ring-1 ring-white/20">
<i class="fa-solid fa-user-plus text-white text-2xl"></i>
</div>
<div class="flex-1">
<div class="flex items-center gap-2">
<h2 class="text-xl font-extrabold text-white tracking-tight">Registrasi Wajah Siswa</h2>
<span class="text-[10px] font-bold px-2 py-0.5 rounded-full bg-white/20 text-white/90 tracking-wider uppercase">Face ID</span>
</div>
<p class="text-sm text-white/75 mt-0.5">Daftarkan foto wajah ke siswa yang sudah ada di database — Master Data</p>
</div>
</div>
</div>
{{-- ===== LANGKAH ===== --}}
<div class="flex items-center justify-center gap-2 mb-6">
@foreach (['Cari Siswa', 'Ambil Foto', 'Simpan'] as $i => $step)
@php($active = ($i === 0) || ($selected && $i === 1))
<div class="flex items-center gap-2">
<div class="flex items-center gap-2">
<div class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold transition-all duration-300 {{ $active ? 'bg-indigo-500 text-white shadow-md shadow-indigo-500/30' : 'bg-gray-100 text-gray-400' }}">
{{ $i + 1 }}
</div>
<span class="text-xs font-semibold {{ $active ? 'text-indigo-600' : 'text-gray-300' }}">{{ $step }}</span>
</div>
@if ($i < 2)
<i class="fa-solid fa-chevron-right text-[10px] text-gray-300"></i>
@endif
</div>
@endforeach
</div>
<div class="grid lg:grid-cols-5 gap-6 items-start">
{{-- ===== KIRI: CARI + DAFTAR SISWA ===== --}}
<div class="lg:col-span-3 space-y-5">
{{-- Cari --}}
<form method="GET" action="{{ route('face.register') }}" class="flex items-center gap-3">
<div class="relative flex-1">
<i class="fa-solid fa-magnifying-glass absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none"></i>
<input type="text" name="q" value="{{ $q }}" placeholder="Cari NISN / nomor / nama siswa..."
class="w-full pl-11 pr-4 py-3 border border-gray-200 rounded-2xl text-sm bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400 transition shadow-sm">
</div>
<button class="px-6 py-3 text-sm font-bold text-white bg-gradient-to-r from-indigo-500 to-violet-500 rounded-2xl hover:from-indigo-600 hover:to-violet-600 active:scale-95 transition shadow-lg shadow-indigo-500/25 inline-flex items-center gap-2">
<i class="fa-solid fa-magnifying-glass"></i> Cari
</button>
</form>
{{-- Daftar siswa --}}
<div class="bg-white rounded-3xl shadow-sm ring-1 ring-gray-100 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-50 flex items-center justify-between">
<p class="text-sm font-bold text-gray-700 flex items-center gap-2">
<i class="fa-solid fa-users text-indigo-500"></i> Daftar Siswa
<span class="text-[10px] font-bold px-2 py-0.5 rounded-full bg-indigo-50 text-indigo-600">{{ $students->total() }} siswa</span>
</p>
<span class="text-[11px] text-gray-400 font-medium">Status wajah: <span class="text-emerald-600 font-bold">Terdaftar</span> / <span class="text-gray-500 font-bold">Belum</span></span>
</div>
@if ($students->count())
{{-- Desktop: tabel --}}
<div class="hidden md:block overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="bg-gray-50/70 text-left text-[11px] text-gray-400 uppercase tracking-wider">
<th class="px-6 py-3 font-bold">Nama</th>
<th class="px-4 py-3 font-bold">NISN</th>
<th class="px-4 py-3 font-bold">Kelas</th>
<th class="px-4 py-3 font-bold">Status Wajah</th>
<th class="px-6 py-3 font-bold text-right">Aksi</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
@foreach ($students as $student)
@php($registered = ($enrolledMap[(string) $student->id] ?? 0) > 0)
<tr class="hover:bg-indigo-50/30 transition {{ $selected && $selected->id === $student->id ? 'bg-indigo-50/50' : '' }}">
<td class="px-6 py-3.5">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-xl bg-gradient-to-br from-indigo-500 to-violet-500 text-white flex items-center justify-center font-bold text-xs shadow-sm shrink-0">
{{ strtoupper(substr($student->full_name, 0, 1)) }}
</div>
<span class="font-semibold text-gray-800">{{ $student->full_name }}</span>
</div>
</td>
<td class="px-4 py-3.5 text-gray-500 font-medium">{{ $student->nisn }}</td>
<td class="px-4 py-3.5">
<span class="text-xs font-bold px-2.5 py-1 rounded-full bg-gray-100 text-gray-600">{{ $student->class_name }}</span>
</td>
<td class="px-4 py-3.5">
@if ($registered)
<span class="inline-flex items-center gap-1 text-xs font-bold px-2.5 py-1 rounded-full bg-emerald-50 text-emerald-600">
<i class="fa-solid fa-check"></i> Terdaftar
</span>
@else
<span class="inline-flex items-center gap-1 text-xs font-bold px-2.5 py-1 rounded-full bg-gray-100 text-gray-500">
<i class="fa-solid fa-circle-minus"></i> Belum
</span>
@endif
</td>
<td class="px-6 py-3.5 text-right">
<a href="{{ route('face.register', ['student_id' => $student->id, 'q' => $q]) }}"
class="inline-flex items-center gap-1.5 text-xs font-bold px-3.5 py-2 rounded-xl border transition {{ $registered ? 'text-indigo-600 border-indigo-200 bg-white hover:bg-indigo-50' : 'text-white bg-gradient-to-r from-indigo-500 to-violet-500 border-transparent hover:from-indigo-600 hover:to-violet-600 shadow-md shadow-indigo-500/20' }}">
<i class="fa-solid fa-camera"></i> {{ $registered ? 'Perbarui' : 'Registrasi' }}
</a>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
{{-- Mobile: kartu (tanpa scroll samping) --}}
<div class="md:hidden divide-y divide-gray-50">
@foreach ($students as $student)
@php($registered = ($enrolledMap[(string) $student->id] ?? 0) > 0)
<div class="p-4 flex items-center gap-3 {{ $selected && $selected->id === $student->id ? 'bg-indigo-50/50' : '' }}">
<div class="w-11 h-11 rounded-2xl bg-gradient-to-br from-indigo-500 to-violet-500 text-white flex items-center justify-center font-bold text-sm shadow-sm shrink-0">
{{ strtoupper(substr($student->full_name, 0, 1)) }}
</div>
<div class="flex-1 min-w-0">
<p class="font-semibold text-gray-800 text-sm truncate">{{ $student->full_name }}</p>
<p class="text-xs text-gray-400 mt-0.5 truncate">{{ $student->nisn }} · {{ $student->class_name }}</p>
<div class="mt-1.5">
@if ($registered)
<span class="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-600">
<i class="fa-solid fa-check"></i> Wajah Terdaftar
</span>
@else
<span class="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-0.5 rounded-full bg-gray-100 text-gray-500">
<i class="fa-solid fa-circle-minus"></i> Belum Terdaftar
</span>
@endif
</div>
</div>
<a href="{{ route('face.register', ['student_id' => $student->id, 'q' => $q]) }}"
class="shrink-0 inline-flex items-center gap-1.5 text-xs font-bold px-3.5 py-2.5 rounded-xl transition {{ $registered ? 'text-indigo-600 bg-indigo-50 hover:bg-indigo-100' : 'text-white bg-gradient-to-r from-indigo-500 to-violet-500 hover:from-indigo-600 hover:to-violet-600 shadow-md shadow-indigo-500/20' }}">
<i class="fa-solid fa-camera"></i> {{ $registered ? 'Perbarui' : 'Registrasi' }}
</a>
</div>
@endforeach
</div>
@else
<div class="py-12 text-center">
<i class="fa-solid fa-user-slash text-3xl text-gray-200 mb-3 block"></i>
<p class="text-gray-400 text-sm font-medium">Siswa tidak ditemukan</p>
<p class="text-xs text-gray-300 mt-1">Coba kata kunci lain</p>
</div>
@endif
<div class="px-6 py-4 border-t border-gray-50">{{ $students->links() }}</div>
</div>
</div>
{{-- ===== KANAN: KAMERA + FORM ===== --}}
<div class="lg:col-span-2 space-y-5 lg:sticky lg:top-6">
@if ($selected)
<div class="bg-white rounded-3xl shadow-lg shadow-indigo-500/5 ring-1 ring-gray-100 overflow-hidden" x-data="faceRegister()">
{{-- Siswa terpilih --}}
<div class="bg-gradient-to-r from-indigo-500 via-violet-500 to-fuchsia-400 px-6 py-4 flex items-center gap-3">
<div class="w-12 h-12 rounded-2xl bg-white/20 backdrop-blur text-white flex items-center justify-center font-extrabold text-lg shadow-inner ring-1 ring-white/20">
{{ strtoupper(substr($selected->full_name, 0, 1)) }}
</div>
<div class="flex-1 min-w-0">
<p class="text-white font-bold truncate">{{ $selected->full_name }}</p>
<p class="text-indigo-50/90 text-xs">{{ $selected->nisn }} · {{ $selected->class_name }}</p>
</div>
<a href="{{ route('face.register', ['q' => $q]) }}" class="w-9 h-9 rounded-full bg-white/15 backdrop-blur ring-1 ring-white/25 text-white flex items-center justify-center hover:bg-white/25 active:scale-90 transition" title="Ganti siswa">
<i class="fa-solid fa-xmark"></i>
</a>
</div>
<form action="{{ route('face.register.store') }}" method="POST" enctype="multipart/form-data"
class="p-6 space-y-5" @submit.prevent="if (shots.length) $el.submit()">
@csrf
<input type="hidden" name="student_id" value="{{ $selected->id }}">
<input type="hidden" name="q" value="{{ $q }}">
<input type="file" id="photos" name="photos[]" multiple accept="image/*" class="hidden" @change="onGallery">
{{-- Viewfinder kamera --}}
<div class="relative aspect-[4/3] rounded-2xl overflow-hidden bg-gradient-to-br from-gray-900 to-gray-950 ring-1 ring-gray-800/60 shadow-2xl shadow-gray-900/20">
{{-- Grid --}}
<div class="absolute inset-0 pointer-events-none opacity-20">
<div class="absolute left-1/3 top-0 bottom-0 w-px bg-white/30"></div>
<div class="absolute left-2/3 top-0 bottom-0 w-px bg-white/30"></div>
<div class="absolute top-1/3 left-0 right-0 h-px bg-white/30"></div>
<div class="absolute top-2/3 left-0 right-0 h-px bg-white/30"></div>
</div>
{{-- Belum buka kamera --}}
<template x-if="!cameraOn && !shots.length">
<div class="absolute inset-0 flex flex-col items-center justify-center text-center p-6">
<div class="w-16 h-16 rounded-2xl bg-gradient-to-br from-indigo-500/20 to-fuchsia-400/10 ring-1 ring-white/15 backdrop-blur flex items-center justify-center mb-3 shadow-xl">
<i class="fa-solid fa-video text-white text-2xl"></i>
</div>
<p class="text-white font-bold">Buka Kamera</p>
<p class="text-gray-400 text-xs mt-1">Pastikan wajah siswa terlihat jelas</p>
<button type="button" @click="startCamera()"
class="mt-4 inline-flex items-center gap-2 px-6 py-2.5 text-sm font-bold text-white bg-gradient-to-r from-indigo-500 to-fuchsia-500 rounded-xl hover:from-indigo-600 hover:to-fuchsia-600 active:scale-95 transition shadow-lg shadow-indigo-500/30">
<i class="fa-solid fa-camera"></i> Buka Kamera
</button>
</div>
</template>
<video x-ref="video" x-show="cameraOn" autoplay playsinline muted class="absolute inset-0 w-full h-full object-cover"></video>
<canvas x-ref="overlay" x-show="cameraOn" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
{{-- Sudah ada foto & kamera mati --}}
<template x-if="shots.length && !cameraOn">
<div class="absolute inset-0 bg-gray-950/80 backdrop-blur-sm flex flex-col items-center justify-center text-center">
<i class="fa-solid fa-circle-check text-emerald-400 text-4xl mb-2"></i>
<p class="text-white font-bold text-sm" x-text="shots.length + ' dari 3 foto diambil'"></p>
<button type="button" @click="startCamera()" class="mt-3 text-xs font-bold text-indigo-300 hover:text-white transition">Tambah foto lagi</button>
</div>
</template>
{{-- Corner brackets --}}
<template x-if="cameraOn">
<div class="absolute inset-3 pointer-events-none">
<div class="absolute top-0 left-0 w-7 h-7 border-t-[3px] border-l-[3px] border-white/70 rounded-tl-2xl"></div>
<div class="absolute top-0 right-0 w-7 h-7 border-t-[3px] border-r-[3px] border-white/70 rounded-tr-2xl"></div>
<div class="absolute bottom-0 left-0 w-7 h-7 border-b-[3px] border-l-[3px] border-white/70 rounded-bl-2xl"></div>
<div class="absolute bottom-0 right-0 w-7 h-7 border-b-[3px] border-r-[3px] border-white/70 rounded-br-2xl"></div>
</div>
</template>
{{-- Status pill + ganti kamera --}}
<template x-if="cameraOn">
<div class="absolute top-3 inset-x-3 flex items-center justify-between">
<span id="faceHint"
class="text-[10px] font-bold tracking-wider px-3 py-1.5 rounded-full backdrop-blur-md transition-all duration-300 bg-red-500/80 text-white shadow-lg shadow-red-500/30">CARI WAJAH…</span>
<button type="button" @click="switchCamera()"
class="w-8 h-8 rounded-full bg-white/10 backdrop-blur ring-1 ring-white/20 text-white flex items-center justify-center hover:bg-white/20 active:scale-90 transition shadow-lg">
<i class="fa-solid fa-camera-rotate text-xs"></i>
</button>
</div>
</template>
</div>
{{-- Tombol kamera --}}
<div class="flex items-center gap-2 flex-wrap">
<template x-if="cameraOn && shots.length < 3">
<button type="button" id="btnCapture" @click="capture()" disabled
class="inline-flex items-center gap-2 px-4 py-2.5 text-xs font-bold text-white bg-gradient-to-r from-emerald-500 to-teal-500 rounded-xl hover:from-emerald-600 hover:to-teal-600 active:scale-95 transition shadow-md shadow-emerald-500/25 disabled:opacity-40 disabled:cursor-not-allowed">
<i class="fa-solid fa-camera"></i> <span x-text="'Ambil Foto ' + (shots.length + 1)"></span>
</button>
</template>
<button type="button" x-show="cameraOn" @click="stopCamera()"
class="inline-flex items-center gap-2 px-3 py-2.5 text-xs font-medium text-gray-500 bg-white border border-gray-200 rounded-xl hover:bg-gray-50 transition">
<i class="fa-solid fa-xmark"></i> Tutup
</button>
<label class="inline-flex items-center gap-2 px-3 py-2.5 text-xs font-medium text-gray-600 bg-white border border-gray-200 rounded-xl hover:bg-gray-50 transition cursor-pointer">
<i class="fa-solid fa-folder-open"></i> Galeri
<input type="file" class="hidden" accept="image/*" @change="onGalleryAlt">
</label>
</div>
<p x-show="cameraError" class="text-xs font-semibold text-red-500" x-text="cameraError"></p>
{{-- Hasil tangkapan --}}
<div>
<p class="text-[11px] font-bold text-gray-400 uppercase tracking-wider mb-2">Foto Diambil (1–3)</p>
<div class="grid grid-cols-3 gap-2.5">
<template x-for="(shot, i) in shots" :key="i">
<div class="relative aspect-square rounded-xl overflow-hidden border border-gray-200 group shadow-sm">
<img :src="shot.url" class="w-full h-full object-cover">
<button type="button" @click="removeShot(i)" title="Hapus foto ini"
class="absolute top-1.5 right-1.5 w-7 h-7 bg-red-500 text-white rounded-full flex items-center justify-center text-xs hover:bg-red-600 active:scale-90 transition shadow-md md:opacity-0 md:group-hover:opacity-100">
<i class="fa-solid fa-xmark"></i>
</button>
<span class="absolute bottom-1 left-1 text-[9px] font-bold px-1.5 py-0.5 rounded bg-black/60 text-white" x-text="'Foto ' + (i + 1)"></span>
</div>
</template>
<template x-for="n in (3 - shots.length)" :key="'ph' + n">
<div class="aspect-square rounded-xl border-2 border-dashed border-gray-200 flex items-center justify-center text-gray-300 bg-gray-50/50">
<i class="fa-solid fa-image text-xl"></i>
</div>
</template>
<template x-if="!shots.length">
<div class="col-span-3 py-6 text-center text-xs text-gray-400 rounded-xl border border-dashed border-gray-200">
<i class="fa-solid fa-camera-retro text-2xl mb-2 block text-gray-300"></i>
Belum ada foto — ambil dari kamera atau galeri
</div>
</template>
</div>
<p class="mt-2 text-[11px] text-gray-400">Disarankan 3: frontal netral, sedikit miring, kondisi berbeda. Foto buram/gelap/wajah kecil otomatis ditolak sistem.</p>
<p x-show="formError" class="mt-2 text-xs font-semibold text-red-500" x-text="formError"></p>
@error('photos.*') <p class="mt-1.5 text-xs text-red-500 flex items-center gap-1"><i class="fa-solid fa-circle-exclamation"></i> {{ $message }}</p> @enderror
</div>
{{-- Submit --}}
<button type="submit" :disabled="!shots.length"
class="w-full inline-flex items-center justify-center gap-2 px-6 py-3.5 text-sm font-bold text-white bg-gradient-to-r from-indigo-500 to-violet-500 rounded-2xl hover:from-indigo-600 hover:to-violet-600 active:scale-[0.98] transition shadow-lg shadow-indigo-500/25 disabled:opacity-40 disabled:cursor-not-allowed">
<i class="fa-solid fa-user-plus"></i> Simpan Registrasi Wajah
</button>
</form>
</div>
@else
{{-- Placeholder: belum pilih siswa --}}
<div class="bg-white rounded-3xl shadow-sm ring-1 ring-gray-100 p-10 text-center">
<div class="w-16 h-16 mx-auto rounded-2xl bg-gradient-to-br from-indigo-100 to-violet-100 flex items-center justify-center mb-4">
<i class="fa-solid fa-hand-pointer text-indigo-400 text-2xl"></i>
</div>
<p class="font-bold text-gray-700">Pilih Siswa Terlebih Dahulu</p>
<p class="text-xs text-gray-400 mt-1.5 max-w-xs mx-auto">Cari siswa di kolom pencarian, lalu klik tombol <b>Registrasi</b> untuk mulai mengambil foto wajah</p>
</div>
@endif
@if (!$faceidConfigured)
<div class="p-4 rounded-2xl bg-red-50 border border-red-200 text-sm text-red-600 flex items-start gap-3">
<i class="fa-solid fa-triangle-exclamation mt-0.5"></i> Microservice FaceID belum dikonfigurasi (FACEID_BASE_URL / FACEID_API_KEY di .env).
</div>
@endif
</div>
</div>
</div>
@endsection
@push('scripts')
<script type="module" src="/vendor/mediapipe/facefinder.js"></script>
<script>
function faceRegister() {
return {
cameraOn: false, cameraError: null, formError: null, detectorError: false, facing: 'environment',
shots: [],
init() {
window.addEventListener('beforeunload', () => this.stopCamera());
window.addEventListener('face-detector-error', () => {
this.detectorError = true;
const btn = document.getElementById('btnCapture');
if (btn) { btn.disabled = false; btn.classList.remove('opacity-40', 'cursor-not-allowed'); }
const hint = document.getElementById('faceHint');
if (hint) {
hint.textContent = 'DETEKSI OFF — FOTO TETAP BISA';
hint.className = 'text-[10px] font-bold tracking-wider px-3 py-1.5 rounded-full backdrop-blur-md transition-all duration-300 bg-amber-500/80 text-white shadow-lg shadow-amber-500/30';
}
});
},
async startCamera() {
this.cameraError = null;
if (!navigator.mediaDevices?.getUserMedia) {
this.cameraError = 'Perangkat ini tidak mendukung kamera langsung — gunakan tombol Galeri.';
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: this.facing, width: { ideal: 1280 } },
audio: false,
});
this.$refs.video.srcObject = stream;
this.cameraOn = true;
await this.$refs.video.play().catch(() => {});
if (window.__faceLoop) window.__faceLoop.start(this.$refs.video, this.$refs.overlay);
} catch (e) {
this.cameraError = 'Kamera tidak bisa diakses — izinkan akses kamera di browser, atau gunakan tombol Galeri.';
}
},
stopCamera() {
if (window.__faceLoop) window.__faceLoop.stop();
const v = this.$refs.video;
if (v && v.srcObject) {
v.srcObject.getTracks().forEach(t => t.stop());
v.srcObject = null;
}
this.cameraOn = false;
},
async switchCamera() {
const prev = this.facing;
this.facing = this.facing === 'user' ? 'environment' : 'user';
this.stopCamera();
this.cameraOn = false;
await this.startCamera();
if (this.cameraError) {
this.cameraError = null;
this.facing = prev;
this.stopCamera();
this.cameraOn = false;
await this.startCamera();
}
},
capture() {
if (!this.detectorError && window.__faceLoop && !window.__faceLoop.detected) {
this.cameraError = 'Tidak ada wajah terdeteksi — arahkan kamera ke wajah siswa.';
return;
}
const v = this.$refs.video;
if (!v || !v.srcObject || v.videoWidth === 0) return;
if (this.shots.length >= 3) return;
const canvas = document.createElement('canvas');
canvas.width = v.videoWidth;
canvas.height = v.videoHeight;
canvas.getContext('2d').drawImage(v, 0, 0);
canvas.toBlob((blob) => {
if (!blob) return;
this.addShot(new File([blob], 'foto-' + Date.now() + '.jpg', { type: 'image/jpeg' }));
}, 'image/jpeg', 0.92);
},
addShot(file) {
if (this.shots.length >= 3) return;
this.shots.push({ url: URL.createObjectURL(file), file });
this.formError = null;
this.syncInput();
if (this.shots.length >= 3) this.stopCamera();
},
removeShot(i) {
URL.revokeObjectURL(this.shots[i].url);
this.shots.splice(i, 1);
this.syncInput();
},
syncInput() {
const input = document.getElementById('photos');
if (!input || !window.DataTransfer) return;
const dt = new DataTransfer();
this.shots.forEach(s => dt.items.add(s.file));
input.files = dt.files;
},
onGallery(e) {
Array.from(e.target.files).slice(0, 3 - this.shots.length).forEach(f => this.addShot(f));
e.target.value = '';
},
onGalleryAlt(e) {
this.onGallery(e);
},
};
}
</script>
@endpush
+747
View File
@@ -0,0 +1,747 @@
@extends('layouts.app')
@section('title', 'Scan Wajah')
@section('content')
<div class="max-w-3xl mx-auto pb-10">
{{-- ===== HEADER PREMIUM ===== --}}
<div class="relative overflow-hidden rounded-3xl bg-gradient-to-r from-blue-600 via-indigo-500 to-cyan-400 shadow-xl shadow-blue-500/20 px-6 py-6 mb-6">
{{-- Dekorasi --}}
<div class="absolute -top-16 -right-16 w-56 h-56 rounded-full bg-white/10 blur-2xl"></div>
<div class="absolute -bottom-20 left-1/3 w-48 h-48 rounded-full bg-cyan-300/20 blur-2xl"></div>
<div class="absolute top-0 right-24 w-24 h-24 rounded-full border border-white/10"></div>
<div class="relative flex items-center gap-4">
<div class="w-14 h-14 rounded-2xl bg-white/15 backdrop-blur flex items-center justify-center shadow-inner ring-1 ring-white/20">
<i class="fa-solid fa-camera-retro text-white text-2xl"></i>
</div>
<div class="flex-1">
<div class="flex items-center gap-2">
<h2 class="text-xl font-extrabold text-white tracking-tight">Scan Wajah Siswa</h2>
<span class="text-[10px] font-bold px-2 py-0.5 rounded-full bg-white/20 text-white/90 tracking-wider uppercase">Face ID</span>
</div>
<p class="text-sm text-white/75 mt-0.5">Hadapkan kamera ke wajah siswa — sistem mengenali identitasnya</p>
</div>
</div>
</div>
{{-- ===== LANGKAH PROGRES ===== --}}
<div class="flex items-center justify-center gap-2 mb-6" x-data="{ step: 1 }" x-init="step = result ? 3 : (preview || loading ? 2 : 1)"
x-effect="step = result ? 3 : (preview || loading ? 2 : 1)">
<template x-for="(s, i) in ['Ambil Foto', 'Verifikasi', 'Catat Pelanggaran']" :key="i">
<div class="flex items-center gap-2">
<div class="flex items-center gap-2">
<div class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold transition-all duration-300"
:class="step > i ? 'bg-blue-500 text-white shadow-md shadow-blue-500/30' : (step === i + 1 ? 'bg-blue-100 text-blue-600 ring-2 ring-blue-500/40' : 'bg-gray-100 text-gray-400')">
<i :class="step > i ? 'fa-solid fa-check' : ''" x-show="step > i"></i>
<span x-show="step <= i" x-text="i + 1"></span>
</div>
<span class="text-xs font-semibold hidden sm:block transition-colors" :class="step === i + 1 ? 'text-blue-600' : (step > i ? 'text-gray-500' : 'text-gray-300')" x-text="s"></span>
</div>
<i x-show="i < 2" class="fa-solid fa-chevron-right text-[10px] text-gray-300"></i>
</div>
</template>
</div>
<div class="space-y-6" x-data="faceScan()">
{{-- ===== VIEWFINDER KAMERA ===== --}}
<div class="relative">
<div class="relative aspect-[4/3] rounded-3xl overflow-hidden bg-gradient-to-br from-gray-900 to-gray-950 shadow-2xl shadow-gray-900/30 ring-1 ring-gray-800/60"
:class="autoScanning ? 'ring-2 ring-cyan-400/70 shadow-cyan-500/10' : ''">
{{-- Grid rule-of-thirds --}}
<div class="absolute inset-0 pointer-events-none opacity-20">
<div class="absolute left-1/3 top-0 bottom-0 w-px bg-white/30"></div>
<div class="absolute left-2/3 top-0 bottom-0 w-px bg-white/30"></div>
<div class="absolute top-1/3 left-0 right-0 h-px bg-white/30"></div>
<div class="absolute top-2/3 left-0 right-0 h-px bg-white/30"></div>
</div>
{{-- Belum buka kamera --}}
<template x-if="!cameraOn && !preview && !loading">
<div class="absolute inset-0 flex flex-col items-center justify-center p-8 text-center">
<div class="absolute -top-10 -left-10 w-40 h-40 rounded-full bg-blue-500/10 blur-2xl"></div>
<div class="absolute -bottom-12 -right-8 w-44 h-44 rounded-full bg-cyan-400/10 blur-2xl"></div>
<div class="relative w-20 h-20 rounded-3xl bg-gradient-to-br from-blue-500/20 to-cyan-400/10 ring-1 ring-white/15 backdrop-blur flex items-center justify-center mb-4 shadow-xl">
<i class="fa-solid fa-video text-white text-3xl"></i>
</div>
<p class="relative text-white font-bold text-lg">Buka Kamera</p>
<p class="relative text-gray-400 text-sm mt-1 max-w-xs">Izinkan akses kamera untuk deteksi wajah real-time</p>
<button type="button" @click="startCamera()"
class="relative mt-5 inline-flex items-center gap-2.5 px-7 py-3 text-sm font-bold text-white bg-gradient-to-r from-blue-500 to-cyan-400 rounded-2xl hover:from-blue-600 hover:to-cyan-500 active:scale-95 transition shadow-lg shadow-blue-500/30">
<i class="fa-solid fa-camera"></i> Buka Kamera
</button>
</div>
</template>
{{-- Video live --}}
<video x-ref="video" x-show="cameraOn && !preview" autoplay playsinline muted
class="absolute inset-0 w-full h-full object-cover"></video>
<canvas x-ref="overlay" x-show="cameraOn && !preview" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
{{-- Hasil tangkapan --}}
<template x-if="preview">
<img :src="preview" class="absolute inset-0 w-full h-full object-cover">
</template>
{{-- Loading scan --}}
<template x-if="loading">
<div class="absolute inset-0 bg-gray-950/70 backdrop-blur-sm flex flex-col items-center justify-center">
<div class="relative">
<div class="w-16 h-16 rounded-full border-4 border-white/10 border-t-cyan-400 animate-spin"></div>
<div class="absolute inset-0 flex items-center justify-center">
<i class="fa-solid fa-camera-retro text-cyan-300 text-xl animate-pulse"></i>
</div>
</div>
<p class="text-white font-semibold mt-4 text-sm">Mengenali wajah...</p>
<p class="text-gray-400 text-xs mt-1">Membandingkan dengan database siswa</p>
</div>
</template>
{{-- Corner brackets viewfinder --}}
<template x-if="cameraOn && !preview">
<div class="absolute inset-3 pointer-events-none">
<div class="absolute top-0 left-0 w-8 h-8 border-t-[3px] border-l-[3px] border-white/70 rounded-tl-2xl"></div>
<div class="absolute top-0 right-0 w-8 h-8 border-t-[3px] border-r-[3px] border-white/70 rounded-tr-2xl"></div>
<div class="absolute bottom-0 left-0 w-8 h-8 border-b-[3px] border-l-[3px] border-white/70 rounded-bl-2xl"></div>
<div class="absolute bottom-0 right-0 w-8 h-8 border-b-[3px] border-r-[3px] border-white/70 rounded-br-2xl"></div>
</div>
</template>
{{-- Status pill + kamera switch (overlay atas) --}}
<template x-if="cameraOn && !preview">
<div class="absolute top-4 inset-x-4 flex items-center justify-between">
<span id="faceHint"
class="text-[11px] font-bold tracking-wider px-3.5 py-1.5 rounded-full backdrop-blur-md transition-all duration-300 bg-red-500/80 text-white shadow-lg shadow-red-500/30">CARI WAJAH…</span>
<div class="flex items-center gap-2">
<span x-show="autoScanOn"
class="flex items-center gap-1.5 text-[10px] font-bold tracking-widest px-2.5 py-1.5 rounded-full bg-cyan-500/90 text-white shadow-lg shadow-cyan-500/30">
<span class="w-1.5 h-1.5 rounded-full bg-white animate-pulse"></span> AUTO
</span>
<button type="button" @click="switchCamera()"
class="w-9 h-9 rounded-full bg-white/10 backdrop-blur ring-1 ring-white/20 text-white flex items-center justify-center hover:bg-white/20 active:scale-90 transition shadow-lg">
<i class="fa-solid fa-camera-rotate text-sm"></i>
</button>
</div>
</div>
</template>
{{-- Shutter + tutup kamera (overlay bawah) --}}
<template x-if="cameraOn && !preview">
<div class="absolute bottom-4 inset-x-0 flex items-center justify-center">
<div class="flex items-center gap-6">
<button type="button" @click="stopCamera()"
class="w-11 h-11 rounded-full bg-white/10 backdrop-blur ring-1 ring-white/20 text-white/80 flex items-center justify-center hover:bg-white/20 active:scale-90 transition" title="Tutup kamera">
<i class="fa-solid fa-xmark"></i>
</button>
<button type="button" id="btnCapture" @click="capture()" disabled
class="w-[72px] h-[72px] rounded-full bg-white/15 backdrop-blur ring-4 ring-white/60 flex items-center justify-center hover:ring-white/80 active:scale-95 transition disabled:opacity-50 disabled:ring-white/30">
<span class="w-14 h-14 rounded-full bg-gradient-to-br from-emerald-400 to-emerald-600 shadow-lg shadow-emerald-500/40 ring-2 ring-white/50"></span>
</button>
<label class="w-11 h-11 rounded-full bg-white/10 backdrop-blur ring-1 ring-white/20 text-white/80 flex items-center justify-center hover:bg-white/20 active:scale-90 transition cursor-pointer" title="Pilih dari galeri">
<i class="fa-solid fa-images"></i>
<input type="file" class="hidden" accept="image/*" @change="onFile">
</label>
</div>
</div>
</template>
</div>
{{-- Tracker sinyal liveness live --}}
<div x-show="cameraOn && !preview" class="flex items-center justify-center gap-2 mt-3 flex-wrap">
<span class="text-[10px] font-bold tracking-wider px-2.5 py-1 rounded-full transition-all duration-300" :class="signalsLive.blink ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-100 text-gray-400'">
<i class="fa-solid fa-eye mr-1"></i>Kedip <span x-show="signalsLive.blink">✓</span>
</span>
<span class="text-[10px] font-bold tracking-wider px-2.5 py-1 rounded-full transition-all duration-300" :class="signalsLive.smile ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-100 text-gray-400'">
<i class="fa-solid fa-face-smile mr-1"></i>Senyum <span x-show="signalsLive.smile">✓</span>
</span>
<span class="text-[10px] font-bold tracking-wider px-2.5 py-1 rounded-full transition-all duration-300" :class="signalsLive.depth ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-100 text-gray-400'">
<i class="fa-solid fa-cube mr-1"></i>3D <span x-show="signalsLive.depth">✓</span>
</span>
<span class="text-[10px] font-bold tracking-wider px-2.5 py-1 rounded-full transition-all duration-300" :class="signalsLive.motion ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-100 text-gray-400'">
<i class="fa-solid fa-wind mr-1"></i>Gerak <span x-show="signalsLive.motion">✓</span>
</span>
</div>
{{-- Petunjuk kecil --}}
<div class="flex items-center justify-center gap-2 mt-3 text-[11px] text-gray-400 min-h-[18px]">
<i class="fa-solid fa-lightbulb text-amber-400"></i>
<span x-show="cameraOn && !preview && !engineOk" class="text-red-500 font-bold"><i class="fa-solid fa-triangle-exclamation mr-1"></i>Mesin deteksi tidak termuat — muat ulang halaman</span>
<span x-show="!preview && !autoScanning && !autoNoMatch && !needBlink && !nudge && engineOk">Scan otomatis aktif — arahkan kamera ke wajah, hasil muncul sendiri</span>
<span x-show="autoScanning" class="text-cyan-500 font-semibold"><i class="fa-solid fa-circle-notch fa-spin mr-1"></i>Mencocokkan dengan database…</span>
<span x-show="autoNoMatch && !autoScanning && !preview && !result" class="text-amber-500 font-semibold"><i class="fa-solid fa-eye-slash mr-1"></i>Belum dikenali — posisikan wajah lebih jelas</span>
<span x-show="needBlink && !autoScanning && !preview && !result" class="text-cyan-500 font-semibold"><i class="fa-solid fa-eye mr-1"></i>Kedipkan mata / tersenyum / gerakkan kepala sebentar…</span>
<span x-show="nudge && !autoScanning && !preview && !result" class="text-cyan-600 font-bold animate-pulse"><i class="fa-solid fa-hand mr-1"></i>Silakan berkedip / tersenyum / gelengkan kepala</span>
<span x-show="preview && !result">Foto diambil — sentuh <b class="text-gray-500">Scan Lagi</b> untuk mengambil ulang</span>
</div>
</div>
{{-- Tombol aksi saat preview (setelah capture / dari galeri) --}}
<template x-if="preview && !loading">
<div class="flex items-center justify-center gap-3">
<button type="button" @click="captureAgain()"
class="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-semibold text-gray-600 bg-white border border-gray-200 rounded-2xl hover:bg-gray-50 active:scale-95 transition shadow-sm">
<i class="fa-solid fa-rotate text-gray-400"></i> Ambil Ulang
</button>
<button type="button" @click="scan()"
class="inline-flex items-center gap-2 px-6 py-2.5 text-sm font-bold text-white bg-gradient-to-r from-emerald-500 to-teal-500 rounded-2xl hover:from-emerald-600 hover:to-teal-600 active:scale-95 transition shadow-lg shadow-emerald-500/25">
<i class="fa-solid fa-camera-retro"></i> Scan Sekarang
</button>
</div>
</template>
{{-- Error kamera --}}
<div x-show="cameraError" class="p-4 rounded-2xl bg-red-50 border border-red-200 text-sm text-red-600 flex items-start gap-3">
<i class="fa-solid fa-triangle-exclamation mt-0.5"></i> <span x-text="cameraError"></span>
</div>
<div x-show="error && !cameraError" class="p-4 rounded-2xl bg-amber-50 border border-amber-200 text-sm text-amber-700 flex items-start gap-3">
<i class="fa-solid fa-circle-exclamation mt-0.5"></i> <span x-text="error"></span>
</div>
<div x-show="livenessOff" class="p-4 rounded-2xl bg-orange-50 border border-orange-200 text-sm font-semibold text-orange-600 flex items-start gap-3">
<i class="fa-solid fa-triangle-exclamation mt-0.5"></i> Mesin liveness tidak aktif di perangkat ini — hasil scan memerlukan konfirmasi manual staff.
</div>
{{-- ===== HASIL: SISWA DIKENALI ===== --}}
<template x-if="result && result.matched && result.candidates.length">
<div class="overflow-hidden rounded-3xl bg-white shadow-xl shadow-emerald-500/5 ring-1 ring-emerald-100">
<div class="relative overflow-hidden bg-gradient-to-r from-emerald-500 to-teal-400 px-6 py-4 flex items-center gap-3">
<div class="absolute -top-8 -right-8 w-32 h-32 rounded-full bg-white/10 blur-xl pointer-events-none"></div>
<div class="absolute bottom-0 right-16 w-20 h-20 rounded-full border border-white/15 pointer-events-none"></div>
<div class="w-12 h-12 rounded-2xl bg-white/20 backdrop-blur flex items-center justify-center shadow-inner ring-1 ring-white/30">
<i class="fa-solid fa-circle-check text-white text-xl"></i>
</div>
<div class="flex-1">
<p class="text-white font-bold text-base">Siswa Dikenali!</p>
<p class="text-emerald-50/90 text-xs">Identitas cocok dengan database siswa</p>
</div>
<div class="flex flex-col items-end gap-1.5">
<span x-show="!livenessWarn" class="flex items-center gap-1 text-[10px] font-bold tracking-wider px-2.5 py-1 rounded-full bg-emerald-700/40 text-white">
<i class="fa-solid fa-eye"></i> LIVENESS OK · <span x-text="(signalCount || 1) + ' sinyal'"></span>
</span>
<span class="inline-flex items-center gap-1.5 text-[10px] font-bold tracking-wider px-3 py-1.5 rounded-full bg-white/15 text-white ring-1 ring-white/30 backdrop-blur">
<i class="fa-solid fa-user-check"></i> TERVERIFIKASI
</span>
</div>
</div>
<div class="p-6 space-y-5">
<p x-show="result.ambiguous" class="p-3.5 rounded-2xl bg-amber-50 border border-amber-200 text-xs font-semibold text-amber-700 flex items-center gap-2">
<i class="fa-solid fa-triangle-exclamation"></i> Hasil ragu (kemungkinan kembar) — pilih siswa yang benar di bawah
</p>
<p x-show="livenessWarn" class="p-3.5 rounded-2xl bg-amber-50 border border-amber-200 text-xs font-semibold text-amber-700 flex items-center gap-2">
<i class="fa-solid fa-triangle-exclamation"></i> Liveness tidak terverifikasi — pastikan siswa asli berada di depan kamera (bukan foto).
</p>
<label x-show="livenessWarn" class="flex items-center gap-2.5 p-3.5 rounded-2xl bg-amber-50/60 border border-amber-200 text-xs font-semibold text-amber-800 cursor-pointer">
<input type="checkbox" x-model="confirmLiveness" class="w-4 h-4 rounded accent-amber-600">
Saya konfirmasi siswa asli berada di depan kamera saat scan ini
</label>
{{-- Kandidat --}}
<div class="space-y-3">
<template x-for="c in result.candidates" :key="c.id">
<div class="rounded-2xl p-4 transition-all duration-200 cursor-pointer border-2"
:class="c.id === chosenId ? 'border-emerald-400 bg-emerald-50/50 shadow-lg shadow-emerald-500/10' : 'border-gray-100 bg-white hover:border-gray-200'"
@click="result.candidates.length > 1 && (chosenId = c.id)">
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-blue-500 to-indigo-500 text-white flex items-center justify-center font-extrabold text-lg shadow-md shrink-0"
x-text="c.full_name.charAt(0).toUpperCase()"></div>
<div class="flex-1 min-w-0">
<p class="font-bold text-gray-800 truncate" x-text="c.full_name"></p>
<p class="text-xs text-gray-500 mt-0.5" x-text="c.nisn + ' · ' + c.class_name"></p>
</div>
<div class="relative w-14 h-14 shrink-0">
<svg class="w-14 h-14 -rotate-90" viewBox="0 0 56 56">
<circle cx="28" cy="28" r="24" fill="none" stroke="#e2e8f0" stroke-width="5"></circle>
<circle cx="28" cy="28" r="24" fill="none" stroke="#10b981" stroke-width="5" stroke-linecap="round"
:stroke-dasharray="150.8" :stroke-dashoffset="150.8 * (1 - c.score)"></circle>
</svg>
<div class="absolute inset-0 flex items-center justify-center text-[11px] font-black" :class="(c.score*100) >= 95 ? 'text-emerald-600' : 'text-amber-600'" x-text="Math.round(c.score*100) + '%'"></div>
</div>
</div>
<div class="flex items-center gap-2 mt-3 pt-3 border-t border-gray-50" x-show="result.candidates.length > 1">
<span class="text-xs text-gray-400"><i class="fa-solid fa-hand-pointer mr-1"></i>Pilih kandidat yang benar:</span>
<span class="text-[11px] font-bold px-2.5 py-1 rounded-lg"
:class="c.id === chosenId ? 'bg-emerald-500 text-white' : 'bg-gray-100 text-gray-500'"
x-text="c.id === chosenId ? '✓ Dipilih' : 'Ketuk untuk pilih'"></span>
</div>
</div>
</template>
</div>
{{-- Profil + poin --}}
<div class="grid grid-cols-3 gap-3">
<div class="relative overflow-hidden rounded-2xl bg-gradient-to-br from-indigo-500 to-violet-600 p-3.5 text-center shadow-md shadow-indigo-500/20">
<div class="absolute right-0 top-0 w-10 h-10 opacity-15"><i class="fa-solid fa-chart-simple text-3xl"></i></div>
<p class="text-[10px] font-bold text-indigo-100 uppercase tracking-wider">Total Poin</p>
<p class="text-xl font-extrabold text-white mt-1" x-text="selectedCandidate ? selectedCandidate.total_points : 0"></p>
</div>
<div class="rounded-2xl bg-gray-50 p-3.5 text-center">
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Kelas</p>
<p class="text-sm font-bold text-gray-700 mt-1 truncate px-1" x-text="selectedCandidate ? (selectedCandidate.class_name || '—') : '—'"></p>
</div>
<div class="rounded-2xl bg-gray-50 p-3.5 text-center">
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider">NISN</p>
<p class="text-sm font-bold text-gray-700 mt-1 truncate px-1" x-text="selectedCandidate ? (selectedCandidate.nisn || '—') : '—'"></p>
</div>
</div>
{{-- Pelanggaran terakhir --}}
<div x-show="selectedCandidate && selectedCandidate.recent_violations.length" class="rounded-2xl border border-gray-100 p-4">
<p class="text-[11px] font-bold text-gray-400 uppercase tracking-wider mb-3 flex items-center gap-2">
<i class="fa-solid fa-clock-rotate-left"></i> Pelanggaran Terakhir
</p>
<div class="space-y-2.5">
<template x-for="(v, i) in selectedCandidate.recent_violations" :key="i">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<div class="w-7 h-7 rounded-lg bg-red-50 text-red-500 flex items-center justify-center shrink-0">
<i class="fa-solid fa-gavel text-[10px]"></i>
</div>
<span class="text-sm text-gray-600 truncate" x-text="v.date + ' — ' + v.type"></span>
</div>
<span class="text-xs font-bold px-2 py-0.5 rounded-full bg-red-50 text-red-600 shrink-0" x-text="'+' + v.points + ' poin'"></span>
</div>
</template>
</div>
</div>
{{-- Aksi --}}
<div class="flex flex-col sm:flex-row items-stretch gap-3 pt-1">
<a :href="(livenessWarn && !confirmLiveness) ? null : '{{ route('violations.create') }}?student_id=' + chosenId + '&name=' + encodeURIComponent(chosenName) + '&info=' + encodeURIComponent(chosenInfo)"
:class="(livenessWarn && !confirmLiveness) ? 'opacity-40 pointer-events-none' : ''"
class="flex-1 inline-flex items-center justify-center gap-2 px-6 py-3.5 text-sm font-bold text-white bg-gradient-to-r from-blue-500 to-indigo-500 rounded-2xl hover:from-blue-600 hover:to-indigo-600 active:scale-[0.98] transition shadow-lg shadow-blue-500/25">
<i class="fa-solid fa-plus"></i> Tambah Pelanggaran
</a>
<button type="button" @click="reset()"
class="inline-flex items-center justify-center gap-2 px-5 py-3.5 text-sm font-semibold text-gray-600 bg-white border border-gray-200 rounded-2xl hover:bg-gray-50 active:scale-[0.98] transition">
<i class="fa-solid fa-rotate text-gray-400"></i> Scan Lagi
</button>
</div>
</div>
</div>
</template>
{{-- ===== HASIL: TIDAK DIKENALI ===== --}}
<template x-if="result && !result.matched">
<div class="overflow-hidden rounded-3xl bg-white shadow-xl ring-1 ring-amber-100">
<div class="bg-gradient-to-r from-amber-500 to-orange-400 px-6 py-4 flex items-center gap-3">
<div class="w-11 h-11 rounded-2xl bg-white/20 backdrop-blur flex items-center justify-center shadow-inner">
<i class="fa-solid fa-user-slash text-white text-lg"></i>
</div>
<div class="flex-1">
<p class="text-white font-bold text-base">Siswa Belum Terdaftar</p>
<p class="text-amber-50/90 text-xs">Wajah tidak dikenali di database siswa</p>
</div>
</div>
<div class="p-6">
<div class="rounded-2xl bg-amber-50/60 border border-amber-100 p-4 text-sm text-amber-700 mb-5 flex items-start gap-3">
<i class="fa-solid fa-circle-info mt-0.5"></i>
<span>Wajah tidak dikenali — simpan foto ini atas nama siswa agar bisa dikenali di kemudian hari.</span>
</div>
{{-- Sukses daftar --}}
<template x-if="regDone">
<div class="rounded-2xl bg-emerald-50 border border-emerald-200 p-4 mb-4">
<p class="text-sm font-bold text-emerald-700 flex items-center gap-2"><i class="fa-solid fa-circle-check"></i> Wajah <span x-text="regName"></span> berhasil terdaftar (1 foto)</p>
<p class="text-xs text-emerald-600 mt-1">Scan ulang sekarang untuk verifikasi. Tambahkan foto lain di halaman Registrasi Wajah untuk akurasi lebih baik.</p>
</div>
</template>
{{-- Form simpan cepat --}}
<template x-if="!regDone">
<div class="rounded-2xl border border-gray-100 p-4 mb-4">
<div class="flex items-center gap-3 mb-3">
<img :src="lastScanPreview" class="w-14 h-14 rounded-xl object-cover border border-gray-200 shadow-sm">
<div>
<p class="text-sm font-bold text-gray-700">Simpan foto ini atas nama siswa:</p>
<p class="text-xs text-gray-400">Foto hasil scan akan dipakai sebagai referensi wajah</p>
</div>
</div>
<div class="relative">
<i class="fa-solid fa-magnifying-glass absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none"></i>
<input type="text" x-model="regQuery" @input.debounce="searchStudent" placeholder="Cari NISN / nama siswa..."
class="w-full pl-9 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-amber-500/20 focus:border-amber-400 transition">
</div>
<div x-show="regResults.length > 0" class="mt-1.5 max-h-44 overflow-y-auto rounded-xl border border-gray-200 bg-white shadow-lg">
<template x-for="s in regResults" :key="s.id">
<button type="button" @click="selectRegStudent(s)"
class="w-full text-left px-3.5 py-2.5 hover:bg-amber-50 transition flex items-center gap-2.5 border-b border-gray-50 last:border-0">
<div class="w-8 h-8 rounded-full bg-amber-100 text-amber-600 flex items-center justify-center font-bold text-xs shrink-0" x-text="s.full_name.charAt(0)"></div>
<div class="min-w-0">
<p class="text-sm font-semibold text-gray-700 truncate" x-text="s.full_name"></p>
<p class="text-[11px] text-gray-400" x-text="s.nisn + ' · ' + (s.class_name || '')"></p>
</div>
</button>
</template>
</div>
<div x-show="regStudentId && !regResults.length" class="mt-3 flex items-center justify-between gap-3 p-3 rounded-xl bg-amber-50 border border-amber-200">
<p class="text-sm font-semibold text-amber-800"><i class="fa-solid fa-user-check mr-1.5"></i><span x-text="regName"></span></p>
<button type="button" @click="regStudentId = null; regQuery = ''" class="text-xs text-gray-400 hover:text-gray-600">ubah</button>
</div>
<p x-show="regError" class="mt-2 text-xs font-semibold text-red-500" x-text="regError"></p>
<button type="button" @click="saveReg()" :disabled="!regStudentId || regSaving"
class="mt-3 w-full inline-flex items-center justify-center gap-2 px-5 py-3 text-sm font-bold text-white bg-gradient-to-r from-amber-500 to-orange-500 rounded-xl hover:from-amber-600 hover:to-orange-600 active:scale-[0.98] transition shadow-lg shadow-amber-500/25 disabled:opacity-40 disabled:cursor-not-allowed">
<i class="fa-solid fa-floppy-disk" x-show="!regSaving"></i>
<i class="fa-solid fa-spinner fa-spin" x-show="regSaving"></i>
<span x-text="regSaving ? 'Menyimpan...' : 'Simpan Wajah untuk Siswa Ini'"></span>
</button>
<a :href="'{{ route('face.register') }}?q=' + encodeURIComponent(queryHint)"
class="mt-2.5 block text-center text-xs font-semibold text-gray-400 hover:text-amber-600 transition">
Atau buka halaman Registrasi Wajah (ambil 3 foto) →
</a>
</div>
</template>
<div class="flex flex-col sm:flex-row items-stretch gap-3">
<button type="button" @click="reset()"
class="flex-1 inline-flex items-center justify-center gap-2 px-6 py-3.5 text-sm font-bold text-white bg-gradient-to-r from-blue-500 to-indigo-500 rounded-2xl hover:from-blue-600 hover:to-indigo-600 active:scale-[0.98] transition shadow-lg shadow-blue-500/25">
<i class="fa-solid fa-rotate"></i> Scan Siswa Lain
</button>
</div>
</div>
</div>
</template>
</div>
@if (!$faceidConfigured)
<div class="mt-6 p-4 rounded-2xl bg-red-50 border border-red-200 text-sm text-red-600 flex items-start gap-3">
<i class="fa-solid fa-triangle-exclamation mt-0.5"></i> Microservice FaceID belum dikonfigurasi (FACEID_BASE_URL / FACEID_API_KEY di .env). Fitur scan nonaktif sementara — gunakan input manual.
</div>
@endif
</div>
@endsection
@push('scripts')
<script type="module" src="/vendor/mediapipe/facefinder.js"></script>
<script>
function faceScan() {
return {
preview: null, file: null, loading: false, error: null, cameraError: null,
result: null, chosenId: null, cameraOn: false, detectorError: false, facing: 'environment',
autoScanOn: false, autoTimer: null, autoBusy: false, autoScanning: false, autoNoMatch: false, autoAttempts: 0,
needBlink: false, livenessWarn: false, noBlinkTicks: 0, nudge: false, confirmLiveness: false, signalCount: 0,
livenessOff: false, engineOk: true, signalTimer: null, signalsLive: { blink: false, smile: false, depth: false, motion: false },
lastScanFile: null, lastScanPreview: null, regQuery: '', regResults: [], regStudentId: null, regName: '', regSaving: false, regDone: false, regError: null,
get chosenName() { return this._chosen()?.full_name || ''; },
get chosenInfo() {
const c = this._chosen();
return c ? (c.nisn + ' - ' + (c.class_name || '')) : '';
},
get selectedCandidate() { return this._chosen() || null; },
get queryHint() { return this.selectedCandidate ? this.selectedCandidate.full_name : ''; },
_chosen() {
return (this.result?.candidates || []).find(x => x.id === this.chosenId) || this.result?.candidates?.[0] || null;
},
init() {
window.addEventListener('beforeunload', () => this.stopCamera());
window.addEventListener('face-detector-error', () => {
this.detectorError = true;
const btn = document.getElementById('btnCapture');
if (btn) { btn.disabled = false; btn.classList.remove('opacity-50', 'disabled:ring-white/30'); }
const hint = document.getElementById('faceHint');
if (hint) {
hint.textContent = 'DETEKSI OFF — FOTO TETAP BISA';
hint.className = 'text-[11px] font-bold tracking-wider px-3.5 py-1.5 rounded-full backdrop-blur-md transition-all duration-300 bg-amber-500/80 text-white shadow-lg shadow-amber-500/30';
}
});
window.addEventListener('face-liveness-off', () => {
this.livenessOff = true;
});
},
async startCamera() {
this.cameraError = null;
if (!navigator.mediaDevices?.getUserMedia) {
this.cameraError = 'Perangkat ini tidak mendukung kamera langsung — gunakan ikon galeri di viewfinder.';
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: this.facing, width: { ideal: 1280 } },
audio: false,
});
this.$refs.video.srcObject = stream;
this.cameraOn = true;
this.engineOk = !!window.__faceLoop;
await this.$refs.video.play().catch(() => {});
if (window.__faceLoop) window.__faceLoop.start(this.$refs.video, this.$refs.overlay);
this.needBlink = false; this.livenessWarn = false; this.noBlinkTicks = 0; this.nudge = false; this.confirmLiveness = false;
this.startSignalPoll();
this.startAutoScan();
} catch (e) {
this.cameraError = 'Kamera tidak bisa diakses — izinkan akses kamera di browser, atau gunakan ikon galeri di viewfinder.';
}
},
stopCamera() {
this.stopAutoScan();
this.stopSignalPoll();
if (window.__faceLoop) window.__faceLoop.stop();
const v = this.$refs.video;
if (v && v.srcObject) {
v.srcObject.getTracks().forEach(t => t.stop());
v.srcObject = null;
}
this.cameraOn = false;
},
async switchCamera() {
const prev = this.facing;
this.facing = this.facing === 'user' ? 'environment' : 'user';
this.stopCamera();
this.cameraOn = false;
await this.startCamera();
if (this.cameraError) {
this.cameraError = null;
this.facing = prev;
this.stopCamera();
this.cameraOn = false;
await this.startCamera();
}
},
// ===== AUTO-SCAN: cocokkan frame live dengan database tanpa tombol =====
startSignalPoll() {
if (this.signalTimer) return;
this.signalTimer = setInterval(() => {
const s = window.__faceLoop?.signals;
if (s) this.signalsLive = { ...s };
}, 500);
},
stopSignalPoll() {
if (this.signalTimer) { clearInterval(this.signalTimer); this.signalTimer = null; }
},
startAutoScan() {
if (this.autoTimer) return;
this.autoScanOn = true;
this.autoAttempts = 0;
this.autoNoMatch = false;
// Tick pertama segera (setelah kamera stabil), lalu tiap 1.2 detik
setTimeout(() => this.autoTick(), 400);
this.autoTimer = setInterval(() => this.autoTick(), 1200);
},
stopAutoScan() {
if (this.autoTimer) { clearInterval(this.autoTimer); this.autoTimer = null; }
this.autoScanOn = false;
this.autoBusy = false;
this.autoScanning = false;
},
grabFrame() {
return new Promise((resolve) => {
const v = this.$refs.video;
if (!v || !v.videoWidth) { resolve(null); return; }
// Downscale ke maks 960px + kompresi 0.82 → upload kecil, matching tetap akurat
const maxDim = 960;
const scale = Math.min(1, maxDim / Math.max(v.videoWidth, v.videoHeight));
const canvas = document.createElement('canvas');
canvas.width = Math.round(v.videoWidth * scale);
canvas.height = Math.round(v.videoHeight * scale);
canvas.getContext('2d').drawImage(v, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
if (!blob) { resolve(null); return; }
resolve(new File([blob], 'auto-' + Date.now() + '.jpg', { type: 'image/jpeg' }));
}, 'image/jpeg', 0.82);
});
},
async autoTick() {
if (this.autoBusy || this.loading || this.result || this.preview || !this.cameraOn) return;
// Tanpa wajah di frame → tunggu (kecuali deteksi otomatis sedang error)
if (!this.detectorError && window.__faceLoop && !window.__faceLoop.detected) {
this.autoNoMatch = true;
return;
}
this.autoBusy = true;
this.autoScanning = true;
const file = await this.grabFrame();
if (!file) { this.autoBusy = false; this.autoScanning = false; return; }
this.lastScanFile = file;
if (this.lastScanPreview) URL.revokeObjectURL(this.lastScanPreview);
this.lastScanPreview = URL.createObjectURL(file);
const fd = new FormData();
fd.append('photo', file);
fetch('{{ route('face.scan.verify') }}', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content },
body: fd,
})
.then(r => r.json())
.then(d => {
if (!d.ok) { this.autoNoMatch = true; return; }
this.autoAttempts++;
const top = d.candidates?.[0];
// Ragu (kemungkinan kembar) → tampilkan kandidat untuk dipilih
if (d.matched && d.ambiguous) {
this._maybeFinishAuto(d);
return;
}
// Cocok kuat → langsung tampilkan hasil
if (d.matched && top && top.score >= 0.85) {
this._maybeFinishAuto(d);
return;
}
this.autoNoMatch = this.autoAttempts >= 3;
if (this.autoAttempts >= 3) {
// Belum dikenal → tampilkan kartu simpan-cepat (registrasi atas nama siswa)
this.result = { matched: false, candidates: [] };
}
})
.catch(() => {})
.finally(() => {
this.autoBusy = false;
setTimeout(() => { this.autoScanning = false; }, 500);
});
},
_maybeFinishAuto(d) {
const loop = window.__faceLoop;
// Penerimaan: kedip / senyum (sinyal kuat). Depth & motion hanya info (foto dipegang ikut bergerak)
if (loop && loop.livenessAvailable && loop.signalCount > 0) {
this._finishAuto(d);
return;
}
// Mesin liveness tidak aktif → jangan auto-accept; wajib konfirmasi staff
if (loop && !loop.livenessAvailable) {
this.livenessWarn = true;
this._finishAuto(d);
return;
}
// Wajah cocok tapi belum ada sinyal → minta gerakan dulu (anti foto diam)
this.needBlink = true;
this.noBlinkTicks++;
if (this.noBlinkTicks >= 3) this.nudge = true;
if (this.noBlinkTicks >= 6) {
// Terlalu lama tanpa liveness → tampilkan dengan peringatan + konfirmasi staff
this.livenessWarn = true;
this._finishAuto(d);
}
},
_finishAuto(d) {
this.result = d;
this.signalCount = window.__faceLoop ? window.__faceLoop.signalCount : 0;
if (d.candidates.length) this.chosenId = d.candidates[0].id;
this.stopAutoScan();
this.stopCamera();
},
capture() {
this.stopAutoScan();
if (!this.detectorError && window.__faceLoop && !window.__faceLoop.detected) {
this.error = 'Tidak ada wajah terdeteksi — hadapkan kamera ke wajah siswa.';
return;
}
const v = this.$refs.video;
if (!v || !v.srcObject || v.videoWidth === 0) return;
const maxDim = 960;
const scale = Math.min(1, maxDim / Math.max(v.videoWidth, v.videoHeight));
const canvas = document.createElement('canvas');
canvas.width = Math.round(v.videoWidth * scale);
canvas.height = Math.round(v.videoHeight * scale);
canvas.getContext('2d').drawImage(v, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
if (!blob) return;
this.file = new File([blob], 'scan-' + Date.now() + '.jpg', { type: 'image/jpeg' });
this.preview = URL.createObjectURL(this.file);
this.result = null; this.chosenId = null; this.error = null;
this.stopCamera();
this.scan();
}, 'image/jpeg', 0.85);
},
captureAgain() {
this.preview = null; this.file = null; this.result = null;
this.startCamera();
},
onFile(e) {
const f = e.target.files[0];
if (!f) return;
this.file = f;
this.preview = URL.createObjectURL(f);
this.result = null; this.chosenId = null; this.error = null;
},
scan() {
if (!this.file) { this.error = 'Ambil foto wajah dulu.'; return; }
this.lastScanFile = this.file;
this.lastScanPreview = this.preview;
this.loading = true; this.error = null; this.result = null;
const fd = new FormData();
fd.append('photo', this.file);
fetch('{{ route('face.scan.verify') }}', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content },
body: fd,
})
.then(r => r.json())
.then(d => {
if (!d.ok) {
this.error = d.error === 'faceid_down'
? 'Layanan FaceID sedang mati — gunakan input manual.'
: 'Verifikasi gagal: ' + (d.error || 'unknown');
return;
}
this.result = d;
if (d.candidates.length) this.chosenId = d.candidates[0].id;
})
.catch(() => { this.error = 'Terjadi kesalahan jaringan.'; })
.finally(() => { this.loading = false; });
},
searchStudent() {
if (this.regQuery.length < 2) { this.regResults = []; return; }
fetch('{{ route('api.students.search') }}?q=' + encodeURIComponent(this.regQuery))
.then(r => r.json())
.then(data => { this.regResults = data; });
},
selectRegStudent(s) {
this.regStudentId = s.id;
this.regName = s.full_name;
this.regQuery = s.full_name;
this.regResults = [];
},
saveReg() {
if (!this.regStudentId || !this.lastScanFile) return;
this.regSaving = true; this.regError = null;
const fd = new FormData();
fd.append('student_id', this.regStudentId);
fd.append('photo', this.lastScanFile);
fetch('{{ route('face.register.quick') }}', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content },
body: fd,
})
.then(r => r.json())
.then(d => {
if (!d.ok) {
this.regError = d.detail && d.detail.length
? 'Foto ditolak quality gate: ' + d.detail.map(x => x.reason).join(', ')
: 'Registrasi gagal: ' + (d.error || 'unknown');
return;
}
this.regDone = true;
})
.catch(() => { this.regError = 'Terjadi kesalahan jaringan.'; })
.finally(() => { this.regSaving = false; });
},
reset() {
this.preview = null; this.file = null; this.result = null;
this.chosenId = null; this.error = null; this.cameraError = null;
this.needBlink = false; this.livenessWarn = false; this.noBlinkTicks = 0; this.nudge = false; this.confirmLiveness = false;
this.regQuery = ''; this.regResults = []; this.regStudentId = null; this.regName = ''; this.regSaving = false; this.regDone = false; this.regError = null;
if (window.__faceLoop) window.__faceLoop.resetLiveness();
this.stopCamera();
},
};
}
</script>
@endpush
+43 -17
View File
@@ -4,6 +4,15 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
{{-- PWA --}}
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#2563eb">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Ketertiban">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<link rel="icon" href="/icons/icon-192.png">
@php
$schoolName = App\Models\Setting::getValue('school_name', 'Ketertiban');
$appName = App\Models\Setting::getValue('app_name', 'Aplikasi Ketertiban');
@@ -17,19 +26,19 @@
<meta property="og:site_name" content="{{ $appName }}">
<meta property="og:type" content="website">
<meta property="og:url" content="{{ url()->current() }}">
@if($logoPath)
<meta property="og:image" content="{{ asset('storage/' . $logoPath) }}">
<meta property="og:image:width" content="256">
<meta property="og:image:height" content="256">
@else
<meta property="og:image" content="{{ asset('favicon.ico') }}">
@endif
<meta name="twitter:card" content="summary">
<meta property="og:image" content="{{ asset('og-image.png') }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="{{ $appName }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="@yield('title', $appName) - {{ $schoolName }}">
<meta name="twitter:description" content="Aplikasi Ketertiban Siswa {{ $schoolName }}.">
<meta name="twitter:image" content="{{ asset('og-image.png') }}">
<link rel="icon" type="image/png" href="{{ $logoPath ? asset('storage/' . $logoPath) : asset('favicon.ico') }}">
<link rel="shortcut icon" type="image/png" href="{{ $logoPath ? asset('storage/' . $logoPath) : asset('favicon.ico') }}">
{{-- Favicon mengikuti logo sekolah --}}
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" type="image/png" sizes="192x192" href="/logo.png">
<link rel="shortcut icon" href="/favicon.ico">
@vite(['resources/css/app.css', 'resources/js/app.js'])
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
@@ -112,7 +121,7 @@
<body class="h-full antialiased">
<div x-data="notifications()" class="min-h-screen flex">
{{-- Sidebar --}}
<aside :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'" class="fixed inset-y-0 left-0 z-50 w-64 bg-gradient-to-b from-white via-blue-50/30 to-white border-r border-gray-200 lg:translate-x-0 lg:static lg:inset-auto transition-transform duration-200 ease-in-out flex flex-col shadow-sm">
<aside :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'" class="fixed inset-y-0 left-0 z-50 w-64 bg-white border-r border-gray-200 lg:translate-x-0 lg:static lg:inset-auto transition-transform duration-200 ease-in-out flex flex-col shadow-lg lg:shadow-sm">
<div class="flex items-start justify-between h-auto px-4 pt-4 pb-3 border-b border-gray-200">
<a href="{{ route('dashboard') }}" class="min-w-0">
<div class="flex items-center gap-2.5 mb-2">
@@ -142,6 +151,9 @@
@canPermission('input-violations')
<x-nav-item href="{{ route('violations.create') }}" icon="plus-circle" :active="request()->routeIs('violations.create')">Input Pelanggaran</x-nav-item>
@endcanPermission
@canPermission('face-scan')
<x-nav-item href="{{ route('face.scan') }}" icon="face-scan" :active="request()->routeIs('face.scan')">Scan Wajah</x-nav-item>
@endcanPermission
@canPermission('view-violations')
<x-nav-item href="{{ route('violations.index') }}" icon="exclamation-triangle" :active="request()->routeIs('violations.index')">Data Pelanggaran</x-nav-item>
@endcanPermission
@@ -159,8 +171,13 @@
<div class="pt-4 mt-4 border-t border-gray-200">
<p class="px-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Master Data</p>
</div>
@canPermission('face-register')
<x-nav-item href="{{ route('face.register') }}" icon="user-plus" :active="request()->routeIs('face.register')">Registrasi Wajah</x-nav-item>
@endcanPermission
<x-nav-item href="{{ route('settings.categories') }}" icon="tag" :active="request()->routeIs('settings.categories*')">Kategori Pelanggaran</x-nav-item>
<x-nav-item href="{{ route('settings.violation-types') }}" icon="list" :active="request()->routeIs('settings.violation-types*')">Jenis Pelanggaran</x-nav-item>
<x-nav-item href="{{ route('settings.handling-types') }}" icon="hand-holding-heart" :active="request()->routeIs('settings.handling-types*')">Jenis Penanganan</x-nav-item>
<x-nav-item href="{{ route('settings.homeroom') }}" icon="chalkboard-user" :active="request()->routeIs('settings.homeroom*')">Wali Kelas</x-nav-item>
<x-nav-item href="{{ route('settings.thresholds') }}" icon="chart-bar" :active="request()->routeIs('settings.thresholds*')">Ambang SP</x-nav-item>
@endcanPermission
@@ -200,7 +217,7 @@
</aside>
{{-- Overlay for mobile --}}
<div @click="sidebarOpen = false" x-show="sidebarOpen" class="fixed inset-0 z-40 bg-gray-600 bg-opacity-50 lg:hidden" style="display: none;"></div>
<div @click="sidebarOpen = false" x-show="sidebarOpen" class="fixed inset-0 z-40 bg-transparent lg:hidden" style="display: none;"></div>
{{-- Main Content --}}
<div class="flex-1 flex flex-col min-w-0">
@@ -255,11 +272,11 @@
<button @click="showUserMenu = !showUserMenu"
class="flex items-center gap-3 pl-3 pr-2 py-2 rounded-xl hover:bg-gray-50 transition group">
<div class="hidden sm:block text-right">
<p class="text-sm font-semibold text-gray-900 group-hover:text-blue-600 transition">{{ auth()->user()->name }}</p>
<p class="text-xs text-gray-400 capitalize">{{ auth()->user()->role }}</p>
<p class="text-sm font-semibold text-gray-900 group-hover:text-blue-600 transition">{{ auth()->user()?->name ?? '' }}</p>
<p class="text-xs text-gray-400 capitalize">{{ auth()->user()?->role ?? '' }}</p>
</div>
<div class="w-9 h-9 rounded-xl bg-gradient-to-br from-blue-500 to-sky-400 flex items-center justify-center text-white text-sm font-bold shadow-sm">
{{ strtoupper(substr(auth()->user()->name, 0, 1)) }}
{{ strtoupper(substr(auth()->user()?->name ?? '', 0, 1)) }}
</div>
<i class="fa-solid fa-chevron-down"></i>
</button>
@@ -274,8 +291,8 @@
x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 mt-2 w-56 bg-white rounded-2xl shadow-lg border border-gray-200 overflow-hidden z-50" style="display: none;">
<div class="px-4 py-3 border-b border-gray-100">
<p class="text-sm font-semibold text-gray-900">{{ auth()->user()->name }}</p>
<p class="text-xs text-gray-400 capitalize">{{ auth()->user()->role }}</p>
<p class="text-sm font-semibold text-gray-900">{{ auth()->user()?->name ?? '' }}</p>
<p class="text-xs text-gray-400 capitalize">{{ auth()->user()?->role ?? '' }}</p>
</div>
<div class="py-1">
<a href="{{ route('profile.index') }}"
@@ -435,5 +452,14 @@
return result.isConfirmed;
};
</script>
{{-- PWA: Service Worker --}}
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.catch(err => console.error('SW registration failed:', err));
});
}
</script>
</body>
</html>
@@ -0,0 +1,445 @@
@extends('layouts.app')
@section('title', 'Jenis Penanganan')
@section('content')
<div x-data="{
showModal: false,
isEditing: false,
editId: null,
formName: '',
formIcon: 'fa-clipboard-list',
formColor: '#64748b',
formSort: 0,
openCreate() {
this.isEditing = false; this.editId = null;
this.formName = ''; this.formIcon = 'fa-clipboard-list'; this.formColor = '#64748b'; this.formSort = 0;
this.showModal = true;
},
openEdit(t) {
this.isEditing = true; this.editId = t.id;
this.formName = t.name; this.formIcon = t.icon; this.formColor = t.color; this.formSort = t.sort_order;
this.showModal = true;
},
colors: ['#3b82f6','#4f46e5','#14b8a6','#10b981','#f59e0b','#f97316','#ef4444','#8b5cf6','#64748b','#0ea5e9'],
icons: ['fa-comment','fa-file-pen','fa-hand-holding-heart','fa-phone-volume','fa-house-chimney','fa-ban','fa-handshake-angle','fa-file-signature','fa-file-circle-exclamation','fa-clipboard-list','fa-gavel','fa-user-check','fa-chalkboard-user','fa-bullhorn']
}">
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<nav class="flex items-center gap-1.5 text-sm text-gray-400 mb-1">
<a href="{{ route('settings.index') }}" class="hover:text-gray-600 transition">Pengaturan</a>
<span class="text-gray-300">/</span>
<span class="text-gray-700 font-medium">Jenis Penanganan</span>
</nav>
<h1 class="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight">Jenis Penanganan</h1>
<p class="text-sm text-gray-500 mt-1">Kelola jenis tindak lanjut yang tersedia di detail pelanggaran</p>
</div>
<button @click="openCreate()"
class="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-bold text-white bg-gradient-to-r from-amber-500 to-orange-600 rounded-xl shadow-md shadow-orange-200 hover:-translate-y-0.5 transition-all active:scale-95 self-start sm:self-auto">
<i class="fa-solid fa-plus"></i> Tambah Jenis
</button>
</div>
@if(session('success'))
<div class="mb-4 px-4 py-3 rounded-xl bg-emerald-50 border border-emerald-200 text-sm font-semibold text-emerald-700">
<i class="fa-solid fa-circle-check mr-1.5"></i>{{ session('success') }}
</div>
@endif
@if(session('error'))
<div class="mb-4 px-4 py-3 rounded-xl bg-red-50 border border-red-200 text-sm font-semibold text-red-700">
<i class="fa-solid fa-circle-exclamation mr-1.5"></i>{{ session('error') }}
</div>
@endif
@if($errors->any())
<div class="mb-4 px-4 py-3 rounded-xl bg-red-50 border border-red-200 text-sm text-red-700">
@foreach($errors->all() as $e)
<p><i class="fa-solid fa-circle-exclamation mr-1.5"></i>{{ $e }}</p>
@endforeach
</div>
@endif
@php
$typeReorderData = $types->map(fn($t) => [
'id' => $t->id, 'name' => $t->name, 'icon' => $t->icon, 'color' => $t->color,
'sort_order' => $t->sort_order, 'is_active' => $t->is_active, 'is_system' => $t->is_system,
])->values();
@endphp
<script>
window.typeReorderData = @json($typeReorderData);
function typeReorder() {
return {
types: window.typeReorderData || [],
dragIndex: null,
dropTarget: null,
saving: false,
saved: false,
dragStart(i) { this.dragIndex = i; },
dragOver(i) { this.dropTarget = i; },
dragEnd() { this.dragIndex = null; this.dropTarget = null; },
drop(i) {
if (this.dragIndex === null || this.dragIndex === i) { this.dragEnd(); return; }
const [moved] = this.types.splice(this.dragIndex, 1);
this.types.splice(i, 0, moved);
this.dragEnd();
this.saveOrder();
},
saveOrder() {
this.saving = true;
this.saved = false;
fetch('{{ route('settings.handling-types.reorder') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') || {}).content || ''
},
body: JSON.stringify({ order: this.types.map(t => t.id) })
})
.then(r => r.json())
.then(() => { this.saving = false; this.saved = true; setTimeout(() => this.saved = false, 2000); })
.catch(() => { this.saving = false; });
}
};
}
</script>
<div x-data="typeReorder()" class="bg-white rounded-3xl shadow-sm border border-gray-200 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-100 flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-2 text-xs text-gray-400">
<i class="fa-solid fa-grip-vertical text-gray-300"></i>
<span class="font-semibold">Seret baris untuk mengubah urutan</span>
</div>
<div class="flex items-center gap-2">
<span x-show="saving" class="inline-flex items-center gap-1.5 text-xs font-semibold text-blue-600">
<i class="fa-solid fa-circle-notch fa-spin"></i> Menyimpan...
</span>
<span x-show="saved" x-transition class="inline-flex items-center gap-1.5 text-xs font-bold text-emerald-600">
<i class="fa-solid fa-circle-check"></i> Urutan tersimpan
</span>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-100">
<thead>
<tr class="bg-gray-50/80">
<th class="px-5 py-3.5 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Urutan</th>
<th class="px-5 py-3.5 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Jenis Penanganan</th>
<th class="px-5 py-3.5 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Warna</th>
<th class="px-5 py-3.5 text-center text-xs font-semibold text-gray-400 uppercase tracking-wider">Aktif</th>
<th class="px-5 py-3.5 text-right text-xs font-semibold text-gray-400 uppercase tracking-wider">Aksi</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
<template x-for="(type, i) in types" :key="type.id">
<tr draggable="true"
@dragstart="dragStart(i)"
@dragover.prevent="dragOver(i)"
@dragenter.prevent
@drop.prevent="drop(i)"
@dragend="dragEnd()"
class="hover:bg-gray-50/50 transition cursor-grab active:cursor-grabbing"
:class="dragIndex === i ? 'opacity-40 bg-amber-50/40' : (dropTarget === i && dragIndex !== null ? 'ring-2 ring-inset ring-amber-300 bg-amber-50/30' : '')">
<td class="px-5 py-4 whitespace-nowrap">
<div class="flex items-center gap-2">
<i class="fa-solid fa-grip-vertical text-gray-300"></i>
<span class="inline-flex items-center justify-center w-7 h-7 rounded-lg bg-gray-100 text-xs font-bold text-gray-500" x-text="i + 1"></span>
</div>
</td>
<td class="px-5 py-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl flex items-center justify-center shadow-sm"
:style="'background: linear-gradient(135deg, ' + type.color + ', ' + type.color + 'cc);'">
<i :class="'fa-solid ' + type.icon" class="text-white text-sm"></i>
</div>
<div>
<p class="text-sm font-bold text-gray-900" x-text="type.name"></p>
<p class="text-[11px] text-gray-400">
<span x-show="type.is_system" class="inline-flex items-center gap-1 text-amber-600 font-semibold">
<i class="fa-solid fa-lock text-[9px]"></i> Sistem
</span>
<span x-show="!type.is_system" class="text-gray-400">Kustom</span>
</p>
</div>
</div>
</td>
<td class="px-5 py-4">
<span class="inline-flex items-center gap-2 text-xs font-mono text-gray-500">
<span class="w-5 h-5 rounded-full border border-gray-200 shadow-sm" :style="'background-color: ' + type.color"></span>
<span x-text="type.color"></span>
</span>
</td>
<td class="px-5 py-4 text-center">
<form :action="'/settings/handling-types/' + type.id" method="POST">
@csrf @method('PUT')
<input type="hidden" name="name" :value="type.name">
<input type="hidden" name="icon" :value="type.icon">
<input type="hidden" name="color" :value="type.color">
<input type="hidden" name="sort_order" :value="type.sort_order">
<input type="hidden" name="is_active" :value="type.is_active ? 0 : 1">
<button type="submit"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-200"
:class="type.is_active ? 'bg-emerald-500' : 'bg-gray-300'">
<span class="inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform duration-200"
:class="type.is_active ? 'translate-x-5' : 'translate-x-0.5'"></span>
</button>
</form>
</td>
<td class="px-5 py-4 text-right whitespace-nowrap">
<div class="flex items-center justify-end gap-1.5">
<button @click="openEdit(type)"
class="w-8 h-8 rounded-lg inline-flex items-center justify-center text-gray-400 hover:text-blue-600 hover:bg-blue-50 transition" title="Edit">
<i class="fa-solid fa-pen text-xs"></i>
</button>
<template x-if="!type.is_system">
<form :action="'/settings/handling-types/' + type.id" method="POST" class="inline"
x-data x-on:submit.prevent="if(await window.confirmSwal({text:'Hapus jenis penanganan ini?'})) $el.submit()">
@csrf @method('DELETE')
<button type="submit"
class="w-8 h-8 rounded-lg inline-flex items-center justify-center text-gray-400 hover:text-red-600 hover:bg-red-50 transition" title="Hapus">
<i class="fa-solid fa-trash-can text-xs"></i>
</button>
</form>
</template>
<template x-if="type.is_system">
<span class="w-8 h-8 inline-flex items-center justify-center text-gray-200" title="Terkunci sistem">
<i class="fa-solid fa-lock text-xs"></i>
</span>
</template>
</div>
</td>
</tr>
</template>
<tr x-show="types.length === 0">
<td colspan="5" class="px-5 py-12 text-center">
<div class="w-14 h-14 rounded-2xl bg-gray-50 border border-gray-100 flex items-center justify-center mx-auto mb-3">
<i class="fa-solid fa-hand-holding-heart text-gray-300 text-xl"></i>
</div>
<p class="text-sm font-medium text-gray-500">Belum ada jenis penanganan</p>
<p class="text-xs text-gray-400 mt-1">Tambahkan jenis penanganan pertama</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
{{-- Peran Penanganan --}}
<div class="mt-6 bg-white rounded-3xl shadow-sm border border-gray-200 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-100 flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-2xl bg-gradient-to-br from-blue-500 to-sky-400 flex items-center justify-center shadow-sm">
<i class="fa-solid fa-user-tag text-white text-sm"></i>
</div>
<div>
<h3 class="text-sm font-black text-gray-900">Peran Penanganan</h3>
<p class="text-xs text-gray-400">Pilihan peran di form tambah penanganan</p>
</div>
</div>
</div>
<div class="p-6">
<form action="{{ route('settings.handling-roles.store') }}" method="POST" class="flex flex-col sm:flex-row gap-3 mb-5">
@csrf
<input type="text" name="role" required maxlength="100" placeholder="Tambah peran baru..."
class="flex-1 px-4 py-3 border border-gray-200 rounded-xl text-sm bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-amber-500/20 focus:border-amber-500 transition">
<button type="submit"
class="inline-flex items-center justify-center gap-2 px-5 py-3 text-sm font-bold text-white bg-gradient-to-r from-amber-500 to-orange-600 rounded-xl shadow-md shadow-orange-200 hover:brightness-105 transition active:scale-95">
<i class="fa-solid fa-plus"></i> Tambah Peran
</button>
</form>
@php
$roleReorderData = $roles;
@endphp
<script>
window.roleReorderData = @json($roleReorderData);
function roleReorder() {
return {
roles: window.roleReorderData || [],
dragIndex: null,
dropTarget: null,
saving: false,
saved: false,
dragStart(i) { this.dragIndex = i; },
dragOver(i) { this.dropTarget = i; },
dragEnd() { this.dragIndex = null; this.dropTarget = null; },
drop(i) {
if (this.dragIndex === null || this.dragIndex === i) { this.dragEnd(); return; }
const [moved] = this.roles.splice(this.dragIndex, 1);
this.roles.splice(i, 0, moved);
this.dragEnd();
this.saveOrder();
},
saveOrder() {
this.saving = true;
this.saved = false;
fetch('{{ route('settings.handling-roles.reorder') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') || {}).content || ''
},
body: JSON.stringify({ order: this.roles })
})
.then(r => r.json())
.then(() => { this.saving = false; this.saved = true; setTimeout(() => this.saved = false, 2000); })
.catch(() => { this.saving = false; });
}
};
}
</script>
<div class="flex items-center justify-between mb-3">
<p class="text-xs font-semibold text-gray-400 flex items-center gap-1.5">
<i class="fa-solid fa-grip-vertical text-gray-300"></i> Seret untuk mengubah urutan
</p>
<div class="flex items-center gap-2">
<span x-show="saving" class="inline-flex items-center gap-1.5 text-xs font-semibold text-blue-600">
<i class="fa-solid fa-circle-notch fa-spin"></i> Menyimpan...
</span>
<span x-show="saved" x-transition class="inline-flex items-center gap-1.5 text-xs font-bold text-emerald-600">
<i class="fa-solid fa-circle-check"></i> Tersimpan
</span>
</div>
</div>
<div x-data="roleReorder()">
<div class="space-y-2">
<template x-for="(role, i) in roles" :key="role">
<div draggable="true"
@dragstart="dragStart(i)"
@dragover.prevent="dragOver(i)"
@dragenter.prevent
@drop.prevent="drop(i)"
@dragend="dragEnd()"
class="flex items-center gap-3 rounded-2xl border border-gray-200 bg-gray-50/60 px-4 py-3 cursor-grab active:cursor-grabbing transition"
:class="dragIndex === i ? 'opacity-40 bg-amber-50/40' : (dropTarget === i && dragIndex !== null ? 'ring-2 ring-inset ring-amber-300 bg-amber-50/30' : 'hover:bg-gray-50')">
<i class="fa-solid fa-grip-vertical text-gray-300"></i>
<div class="w-8 h-8 rounded-xl bg-gradient-to-br from-blue-500 to-sky-400 flex items-center justify-center text-white shadow-sm shrink-0">
<i class="fa-solid fa-user-tag text-xs"></i>
</div>
<span class="flex-1 text-sm font-bold text-gray-700" x-text="role"></span>
<span class="text-[10px] font-bold text-gray-300" x-text="i + 1"></span>
<form :action="'/settings/handling-roles/' + encodeURIComponent(role)" method="POST" class="inline"
x-data x-on:submit.prevent="if(await window.confirmSwal({text:'Hapus peran ' + role + '?'})) $el.submit()">
@csrf @method('DELETE')
<button type="submit"
class="w-8 h-8 rounded-lg flex items-center justify-center text-gray-300 hover:text-red-500 hover:bg-red-50 transition" title="Hapus">
<i class="fa-solid fa-trash-can text-xs"></i>
</button>
</form>
</div>
</template>
</div>
<p x-show="roles.length === 0" class="text-sm text-gray-400">Belum ada peran. Tambahkan peran pertama di atas.</p>
</div>
<div x-show="showModal"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-50 overflow-y-auto" style="display: none;">
<div class="flex items-center justify-center min-h-screen px-4 py-4">
<div class="fixed inset-0 bg-gray-900/40 backdrop-blur-sm"></div>
<div class="relative bg-white rounded-3xl shadow-2xl border border-gray-200 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div class="px-6 py-4 border-b border-gray-100 flex items-center justify-between sticky top-0 bg-white z-10">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-2xl bg-gradient-to-br from-amber-500 to-orange-600 flex items-center justify-center shadow-sm">
<i class="fa-solid fa-hand-holding-heart text-white text-sm"></i>
</div>
<div>
<h3 class="text-sm font-black text-gray-900" x-text="isEditing ? 'Edit Jenis Penanganan' : 'Tambah Jenis Penanganan'"></h3>
<p class="text-xs text-gray-400">Tampil di dropdown penanganan pelanggaran</p>
</div>
</div>
<button @click="showModal = false" class="w-8 h-8 rounded-lg flex items-center justify-center text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<form :action="isEditing ? `/settings/handling-types/${editId}` : '{{ route('settings.handling-types.store') }}'"
method="POST" class="p-6">
@csrf
<template x-if="isEditing">
<input type="hidden" name="_method" value="PUT">
</template>
<div class="space-y-4">
{{-- Nama --}}
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1.5">Nama Jenis <span class="text-red-500">*</span></label>
<input type="text" x-model="formName" name="name" required maxlength="255"
class="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-amber-500/20 focus:border-amber-500 transition"
placeholder="contoh: Surat Panggilan">
</div>
{{-- Ikon --}}
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1.5">Ikon <span class="text-red-500">*</span></label>
<div class="flex items-center gap-3 mb-2">
<div class="w-10 h-10 rounded-xl flex items-center justify-center shadow-sm"
:style="'background: linear-gradient(135deg, ' + formColor + ', ' + formColor + 'cc);'">
<i :class="'fa-solid ' + formIcon" class="text-white text-sm"></i>
</div>
<input type="text" x-model="formIcon" name="icon" required
class="flex-1 px-4 py-2.5 border border-gray-200 rounded-xl text-sm font-mono bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-amber-500/20 focus:border-amber-500 transition"
placeholder="fa-clipboard-list">
</div>
<div class="flex flex-wrap gap-1.5">
<template x-for="ic in icons" :key="ic">
<button type="button" @click="formIcon = ic"
class="w-9 h-9 rounded-lg flex items-center justify-center border transition"
:class="formIcon === ic ? 'border-amber-400 bg-amber-50 text-amber-600 shadow-sm' : 'border-gray-200 text-gray-400 hover:border-gray-300 hover:text-gray-600'">
<i :class="'fa-solid ' + ic" class="text-sm"></i>
</button>
</template>
</div>
</div>
{{-- Warna --}}
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1.5">Warna <span class="text-red-500">*</span></label>
<div class="flex items-center gap-3">
<input type="color" x-model="formColor" name="color" required
class="w-12 h-10 rounded-xl border border-gray-200 cursor-pointer bg-white p-1">
<div class="flex flex-wrap gap-1.5">
<template x-for="c in colors" :key="c">
<button type="button" @click="formColor = c"
class="w-8 h-8 rounded-lg border-2 transition"
:class="formColor === c ? 'border-gray-800 scale-110' : 'border-transparent hover:scale-110'"
:style="'background-color: ' + c"></button>
</template>
</div>
<span class="text-xs font-mono text-gray-400" x-text="formColor"></span>
</div>
</div>
{{-- Urutan --}}
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1.5">Urutan</label>
<input type="number" x-model="formSort" name="sort_order" min="0" max="999"
class="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-amber-500/20 focus:border-amber-500 transition">
<p class="mt-1 text-[11px] text-gray-400">Angka kecil tampil lebih dulu di dropdown</p>
</div>
</div>
<div class="flex items-center justify-end gap-3 mt-6 pt-4 border-t border-gray-100">
<button type="button" @click="showModal = false"
class="px-5 py-2.5 text-sm font-semibold text-gray-600 bg-white border border-gray-200 rounded-xl hover:bg-gray-50 transition">
Batal
</button>
<button type="submit"
class="px-5 py-2.5 text-sm font-black text-white bg-gradient-to-r from-amber-500 to-orange-600 rounded-xl hover:brightness-105 transition shadow-md shadow-orange-200 inline-flex items-center gap-2 active:scale-95">
<i class="fa-solid fa-floppy-disk text-xs"></i>
Simpan
</button>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection
+218
View File
@@ -0,0 +1,218 @@
@extends('layouts.app')
@section('title', 'Wali Kelas')
@section('content')
@php
$totalClasses = $classes->count();
$assignedCount = $classes->filter(fn ($c) => $c->homeroom_teacher_id)->count();
$unassignedCount = $totalClasses - $assignedCount;
@endphp
<div x-data="{ changed: {} }" class="pb-8">
{{-- ===== HEADER HERO ===== --}}
<div class="relative overflow-hidden rounded-3xl bg-gradient-to-r from-emerald-600 via-teal-500 to-cyan-400 shadow-xl shadow-emerald-500/20 px-6 py-6 mb-6">
<div class="absolute -top-16 -right-16 w-56 h-56 rounded-full bg-white/10 blur-2xl"></div>
<div class="absolute -bottom-20 left-1/3 w-48 h-48 rounded-full bg-cyan-300/20 blur-2xl"></div>
<div class="absolute top-0 right-24 w-24 h-24 rounded-full border border-white/10"></div>
<div class="relative flex items-center gap-4">
<div class="w-14 h-14 rounded-2xl bg-white/15 backdrop-blur flex items-center justify-center shadow-inner ring-1 ring-white/20">
<i class="fa-solid fa-chalkboard-user text-white text-2xl"></i>
</div>
<div class="flex-1 min-w-0">
<nav class="flex items-center gap-1.5 text-xs text-white/60 mb-1">
<a href="{{ route('settings.index') }}" class="hover:text-white transition">Pengaturan</a>
<span>/</span>
<span class="text-white/80 font-medium">Master Data</span>
</nav>
<div class="flex items-center gap-2">
<h1 class="text-xl font-extrabold text-white tracking-tight">Wali Kelas</h1>
<span class="text-[10px] font-bold px-2 py-0.5 rounded-full bg-white/20 text-white/90 tracking-wider uppercase">Penugasan</span>
</div>
<p class="text-sm text-white/75 mt-0.5">Tetapkan wali kelas untuk setiap kelas — wali kelas hanya melihat kelas yang diwalikan</p>
</div>
</div>
</div>
@if(session('success'))
<div class="mb-5 px-4 py-3 rounded-2xl bg-emerald-50 border border-emerald-200 text-sm font-semibold text-emerald-700 flex items-center gap-2.5">
<div class="w-7 h-7 rounded-lg bg-emerald-100 flex items-center justify-center">
<i class="fa-solid fa-check text-emerald-500 text-xs"></i>
</div>
{{ session('success') }}
</div>
@endif
{{-- ===== STATISTIK ===== --}}
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
<div class="relative overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-gray-100 p-4">
<div class="absolute right-0 top-0 w-14 h-14 opacity-10"><i class="fa-solid fa-school text-emerald-600 text-5xl"></i></div>
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Total Kelas</p>
<p class="text-2xl font-black text-gray-800 mt-1">{{ $totalClasses }}</p>
</div>
<div class="relative overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-gray-100 p-4">
<div class="absolute right-0 top-0 w-14 h-14 opacity-10"><i class="fa-solid fa-user-check text-emerald-600 text-5xl"></i></div>
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Sudah Ada Wali</p>
<p class="text-2xl font-black text-emerald-600 mt-1">{{ $assignedCount }}</p>
</div>
<div class="relative overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-gray-100 p-4">
<div class="absolute right-0 top-0 w-14 h-14 opacity-10"><i class="fa-solid fa-user-slash text-amber-500 text-5xl"></i></div>
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Belum Ada Wali</p>
<p class="text-2xl font-black text-amber-500 mt-1">{{ $unassignedCount }}</p>
</div>
<div class="relative overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-gray-100 p-4">
<div class="absolute right-0 top-0 w-14 h-14 opacity-10"><i class="fa-solid fa-users text-emerald-600 text-5xl"></i></div>
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Guru Wali Kelas</p>
<p class="text-2xl font-black text-gray-800 mt-1">{{ $teachers->count() }}</p>
</div>
</div>
@if($teachers->isEmpty())
<div class="mb-5 px-4 py-3.5 rounded-2xl bg-amber-50 border border-amber-200 text-sm text-amber-700 flex items-center gap-2.5">
<i class="fa-solid fa-triangle-exclamation"></i>
<span>Belum ada user dengan peran <strong>Wali Kelas</strong>. Buat dulu di menu <a href="{{ route('users.index') }}" class="font-bold underline">Pengguna</a>.</span>
</div>
@endif
{{-- ===== DAFTAR KELAS ===== --}}
<div class="bg-white rounded-3xl shadow-sm ring-1 ring-gray-100 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-50 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-xl bg-gradient-to-br from-emerald-500 to-teal-500 text-white flex items-center justify-center shadow-sm">
<i class="fa-solid fa-building-columns text-sm"></i>
</div>
<div>
<h3 class="text-sm font-bold text-gray-800">Penugasan Wali Kelas</h3>
<p class="text-[11px] text-gray-400">Kelas tanpa wali ditandai — wali kelas murni tidak bisa melihat data sebelum ditetapkan</p>
</div>
</div>
<span class="text-[11px] font-semibold text-gray-400">{{ $unassignedCount > 0 ? $unassignedCount . ' kelas belum ada wali' : 'Semua kelas sudah ada wali 🎉' }}</span>
</div>
{{-- Desktop: tabel --}}
<div class="hidden md:block overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="bg-gray-50/70 text-left text-[11px] text-gray-400 uppercase tracking-wider">
<th class="px-6 py-3 font-bold">Kelas</th>
<th class="px-4 py-3 font-bold">Jurusan</th>
<th class="px-4 py-3 font-bold">Wali Kelas</th>
<th class="px-6 py-3 font-bold text-right">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
@foreach($classes as $class)
<tr class="hover:bg-emerald-50/20 transition {{ $class->homeroom_teacher_id ? '' : 'bg-amber-50/20' }}">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-500 to-teal-500 text-white flex items-center justify-center shadow-sm shrink-0">
<i class="fa-solid fa-users text-xs"></i>
</div>
<div>
<p class="text-sm font-bold text-gray-900">{{ $class->name }}</p>
<p class="text-[11px] text-gray-400">{{ $class->level }} • {{ $class->students()->count() }} siswa</p>
</div>
</div>
</td>
<td class="px-4 py-4">
<span class="inline-flex items-center px-2.5 py-1 text-[11px] font-semibold bg-gray-100 text-gray-600 rounded-lg border border-gray-200">
{{ $class->department_name ?? '-' }}
</span>
</td>
<td class="px-4 py-4">
<form action="{{ route('settings.homeroom.update') }}" method="POST" x-data="{ teacherId: {{ $class->homeroom_teacher_id ?? 'null' }} }"
@change="changed[{{ $class->id }}] = true">
@csrf @method('PUT')
<input type="hidden" name="class_id" value="{{ $class->id }}">
<div class="flex items-center gap-2">
<div class="relative flex-1 min-w-[220px]">
<i class="fa-solid fa-chalkboard-user absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-400 text-xs pointer-events-none"></i>
<select name="teacher_id" x-model="teacherId"
class="w-full pl-9 pr-8 py-2.5 border border-gray-200 rounded-xl text-sm bg-white focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-400 transition appearance-none shadow-sm">
<option value="">— Belum ada —</option>
@foreach($teachers as $t)
<option value="{{ $t->id }}" @selected($class->homeroom_teacher_id === $t->id)>{{ $t->name }}</option>
@endforeach
</select>
<i class="fa-solid fa-chevron-down absolute right-3.5 top-1/2 -translate-y-1/2 text-xs text-gray-400 pointer-events-none"></i>
</div>
</div>
<div class="mt-2" x-show="changed[{{ $class->id }}]" x-transition style="display: none;">
<button type="submit"
class="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-bold text-white bg-gradient-to-r from-emerald-500 to-teal-500 rounded-xl hover:from-emerald-600 hover:to-teal-600 active:scale-95 transition shadow-md shadow-emerald-500/25">
<i class="fa-solid fa-floppy-disk text-[10px]"></i> Simpan
</button>
</div>
</form>
</td>
<td class="px-6 py-4 text-right whitespace-nowrap">
@if($class->homeroomTeacher)
<span class="inline-flex items-center gap-1.5 px-3 py-1 text-[11px] font-bold text-emerald-700 bg-emerald-50 border border-emerald-200 rounded-full">
<span class="w-1.5 h-1.5 bg-emerald-500 rounded-full"></span>
{{ $class->homeroomTeacher->name }}
</span>
@else
<span class="inline-flex items-center gap-1.5 px-3 py-1 text-[11px] font-bold text-amber-600 bg-amber-50 border border-amber-200 rounded-full">
<span class="w-1.5 h-1.5 bg-amber-500 rounded-full"></span>
Belum ada
</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
{{-- Mobile: kartu --}}
<div class="md:hidden divide-y divide-gray-50">
@foreach($classes as $class)
<div class="p-4 space-y-3 {{ $class->homeroom_teacher_id ? '' : 'bg-amber-50/20' }}">
<div class="flex items-center gap-3">
<div class="w-11 h-11 rounded-2xl bg-gradient-to-br from-emerald-500 to-teal-500 text-white flex items-center justify-center shadow-sm shrink-0">
<i class="fa-solid fa-users text-sm"></i>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-bold text-gray-900">{{ $class->name }}</p>
<p class="text-[11px] text-gray-400">{{ $class->level }} • {{ $class->students()->count() }} siswa • {{ $class->department_name ?? '-' }}</p>
</div>
@if($class->homeroomTeacher)
<span class="inline-flex items-center gap-1 px-2.5 py-1 text-[10px] font-bold text-emerald-700 bg-emerald-50 border border-emerald-200 rounded-full shrink-0">
<span class="w-1.5 h-1.5 bg-emerald-500 rounded-full"></span>
Ada wali
</span>
@else
<span class="inline-flex items-center gap-1 px-2.5 py-1 text-[10px] font-bold text-amber-600 bg-amber-50 border border-amber-200 rounded-full shrink-0">
<span class="w-1.5 h-1.5 bg-amber-500 rounded-full"></span>
Kosong
</span>
@endif
</div>
<form action="{{ route('settings.homeroom.update') }}" method="POST" x-data="{ teacherId: {{ $class->homeroom_teacher_id ?? 'null' }} }"
@change="changed[{{ $class->id }}] = true">
@csrf @method('PUT')
<input type="hidden" name="class_id" value="{{ $class->id }}">
<div class="relative">
<i class="fa-solid fa-chalkboard-user absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-400 text-xs pointer-events-none"></i>
<select name="teacher_id" x-model="teacherId"
class="w-full pl-9 pr-8 py-2.5 border border-gray-200 rounded-xl text-sm bg-white focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-400 transition appearance-none shadow-sm">
<option value="">— Belum ada —</option>
@foreach($teachers as $t)
<option value="{{ $t->id }}" @selected($class->homeroom_teacher_id === $t->id)>{{ $t->name }}</option>
@endforeach
</select>
<i class="fa-solid fa-chevron-down absolute right-3.5 top-1/2 -translate-y-1/2 text-xs text-gray-400 pointer-events-none"></i>
</div>
<div class="mt-2" x-show="changed[{{ $class->id }}]" x-transition style="display: none;">
<button type="submit"
class="w-full inline-flex items-center justify-center gap-1.5 px-4 py-2.5 text-xs font-bold text-white bg-gradient-to-r from-emerald-500 to-teal-500 rounded-xl hover:from-emerald-600 hover:to-teal-600 active:scale-95 transition shadow-md shadow-emerald-500/25">
<i class="fa-solid fa-floppy-disk text-[10px]"></i> Simpan Wali Kelas
</button>
</div>
</form>
</div>
@endforeach
</div>
</div>
</div>
@endsection
+10
View File
@@ -77,6 +77,16 @@
</div>
</div>
<hr class="border-gray-200">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Template Notifikasi WhatsApp Orang Tua</label>
<textarea name="wa_notification_template" rows="8"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 font-mono text-xs">{{ old('wa_notification_template', $settings->get('wa_notification_template')?->value ?? '') }}</textarea>
<p class="text-xs text-gray-400 mt-1.5">Placeholder: <code class="text-blue-600">{nama_siswa} {nisn} {kelas} {jenis} {tanggal} {waktu} {poin} {total_poin} {lokasi} {deskripsi} {sekolah}</code>.<br>
Akhiri pesan dengan <strong class="text-gray-500">Tim Ketertiban</strong> sebagai penanda pengirim. Kosongkan untuk memakai template bawaan.</p>
</div>
<button type="submit" class="w-full px-4 py-2.5 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition">
Simpan Pengaturan
</button>
@@ -32,6 +32,7 @@
'pelanggaran' => ['Pelanggaran', 'fa-triangle-exclamation', 'red'],
'data' => ['Data Master', 'fa-database', 'violet'],
'presensi' => ['Presensi', 'fa-clipboard-check', 'emerald'],
'wajah' => ['Face ID', 'fa-fingerprint', 'indigo'],
'master-data' => ['Master Data', 'fa-gear', 'slate'],
'administrasi' => ['Administrasi', 'fa-shield-halved', 'amber'],
];
@@ -61,6 +62,9 @@
'bk' => ['BK', 'indigo'],
'wali_kelas' => ['Wali Kelas', 'emerald'],
'staff' => ['Staff', 'amber'],
'kepala_sekolah' => ['Kepala Sekolah', 'amber'],
'waka_kesiswaan' => ['Waka Kesiswaan', 'rose'],
'ketua_tim' => ['Ketua Tim', 'teal'],
'other' => ['Guest', 'gray'],
];
$rl = $roleLabels[$role] ?? [ucfirst($role), 'gray'];
+4 -4
View File
@@ -134,10 +134,10 @@
</td>
{{-- Pelanggaran --}}
<td class="px-5 py-4 text-center whitespace-nowrap">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full
{{ ($s->violations_count ?? 0) > 0 ? 'bg-orange-50 text-orange-700 border border-orange-200' : 'bg-gray-50 text-gray-400 border border-gray-200' }}">
<i class="fa-solid {{ ($s->violations_count ?? 0) > 0 ? 'fa-exclamation' : 'fa-check' }} text-[10px]"></i>
{{ $s->violations_count ?? 0 }}x
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-bold rounded-full
{{ $s->violations_count > 3 ? 'bg-red-50 text-red-700 border border-red-200' : ($s->violations_count > 0 ? 'bg-orange-50 text-orange-700 border border-orange-200' : 'bg-gray-50 text-gray-400 border border-gray-200') }}">
<i class="fa-solid {{ $s->violations_count > 0 ? 'fa-exclamation' : 'fa-check' }} text-[10px]"></i>
{{ $s->violations_count }}x
</span>
</td>
{{-- Total Poin --}}
+181 -106
View File
@@ -14,7 +14,7 @@
background: #e5e7eb;
}
.sp-progress-bar {
background: linear-gradient(90deg, #dbeafe 0%, #93c5fd 40%, #60a5fa 70%, #3b82f6 100%);
background: linear-gradient(90deg, #fbbf24 0%, #f97316 40%, #ef4444 70%, #dc2626 100%);
}
</style>
@endpush
@@ -39,172 +39,228 @@
</div>
{{-- ===== HERO CARD ===== --}}
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden mb-6">
<div class="bg-gradient-to-r from-blue-500 via-blue-400 to-sky-300 h-24 sm:h-32"></div>
<div class="relative px-6 pb-6">
<div class="flex flex-col sm:flex-row sm:items-end -mt-12 sm:-mt-16 mb-5 gap-4">
<div class="w-20 h-20 sm:w-24 sm:h-24 rounded-2xl bg-white shadow-lg border-2 border-white flex items-center justify-center flex-shrink-0">
<div class="relative overflow-hidden rounded-3xl bg-gradient-to-br from-blue-600 via-blue-500 to-sky-400 p-6 sm:p-8 text-white shadow-xl shadow-blue-200/60 mb-6">
<div class="pointer-events-none absolute -top-20 -right-16 h-64 w-64 rounded-full bg-white/10 blur-2xl"></div>
<div class="pointer-events-none absolute -bottom-24 -left-12 h-72 w-72 rounded-full bg-sky-300/20 blur-3xl"></div>
<div class="pointer-events-none absolute inset-0 opacity-[0.06]"
style="background-image: radial-gradient(circle at 25% 40%, #fff 1.5px, transparent 1.5px); background-size: 22px 22px;"></div>
<div class="relative z-10 flex flex-col gap-5 lg:flex-row lg:items-center">
<div class="relative flex-shrink-0">
<div class="w-24 h-24 sm:w-28 sm:h-28 rounded-3xl bg-white/15 backdrop-blur-sm ring-1 ring-white/30 flex items-center justify-center overflow-hidden shadow-lg">
@if($student->photo_path)
<img src="{{ Storage::url($student->photo_path) }}" alt="{{ $student->full_name }}"
class="w-full h-full rounded-2xl object-cover">
class="w-full h-full object-cover">
@else
<span class="text-3xl sm:text-4xl font-bold text-blue-600">{{ strtoupper(substr($student->full_name, 0, 1)) }}</span>
<span class="text-4xl sm:text-5xl font-black text-white">{{ strtoupper(substr($student->full_name, 0, 1)) }}</span>
@endif
</div>
<div class="flex-1 min-w-0 pt-5 sm:pt-0">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h2 class="text-2xl sm:text-3xl lg:text-4xl font-bold text-gray-900 truncate leading-tight tracking-tight">{{ $student->full_name }}</h2>
@if($student->is_active)
<span class="absolute -bottom-1 -right-1 w-6 h-6 rounded-full bg-emerald-400 border-4 border-white shadow"></span>
@endif
</div>
<div class="flex-1 min-w-0">
<nav class="flex items-center gap-1.5 text-xs text-white/60 mb-1.5">
<a href="{{ route('students.index') }}" class="hover:text-white transition">Data Siswa</a>
<span>/</span>
<span class="text-white/80 font-medium truncate max-w-[200px]">{{ $student->full_name }}</span>
</nav>
<h2 class="text-2xl sm:text-3xl lg:text-4xl font-black text-white truncate leading-tight tracking-tight">{{ $student->full_name }}</h2>
<div class="flex flex-wrap items-center gap-2 mt-3">
<span class="inline-flex items-center gap-1.5 px-3.5 py-1.5 text-xs font-mono font-semibold bg-gray-100 text-blue-700 rounded-full border border-blue-200">
<i class="fa-solid fa-id-card text-blue-400"></i>
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-mono font-semibold bg-white/15 rounded-full ring-1 ring-white/25 backdrop-blur-sm">
<i class="fa-solid fa-id-card text-white/70"></i>
{{ $student->nisn ?? '—' }}
</span>
<span class="inline-flex items-center gap-1.5 px-3.5 py-1.5 text-xs font-medium bg-emerald-50 text-emerald-700 rounded-full border border-emerald-200">
<i class="fa-solid fa-building text-emerald-400"></i>
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-emerald-400/20 text-emerald-100 rounded-full ring-1 ring-emerald-300/30">
<i class="fa-solid fa-building text-emerald-200"></i>
{{ $student->class_name ?? '—' }}
</span>
<span class="inline-flex items-center gap-1.5 px-3.5 py-1.5 text-xs font-medium bg-purple-50 text-purple-700 rounded-full border border-purple-200">
<i class="fa-solid fa-graduation-cap text-purple-400"></i>
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-purple-400/20 text-purple-100 rounded-full ring-1 ring-purple-300/30">
<i class="fa-solid fa-graduation-cap text-purple-200"></i>
{{ $student->department_name ?? $student->department_code ?? '—' }}
</span>
</div>
</div>
<div class="flex items-center gap-2">
@if($student->is_active)
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-emerald-50 text-emerald-700 border border-emerald-200 rounded-full">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
Aktif
</span>
@else
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-gray-100 text-gray-600 border border-gray-200 rounded-full">
<span class="w-1.5 h-1.5 rounded-full bg-gray-400"></span>
@if(!$student->is_active)
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-gray-500/30 text-gray-100 rounded-full ring-1 ring-gray-400/30">
<span class="w-1.5 h-1.5 rounded-full bg-gray-300"></span>
Tidak Aktif
</span>
@endif
</div>
</div>
</div>
<a href="{{ route('students.index') }}"
class="inline-flex items-center justify-center gap-1.5 px-4 py-2.5 text-sm font-bold text-white bg-white/10 ring-1 ring-white/25 rounded-xl hover:bg-white/20 transition backdrop-blur-sm shrink-0 self-start lg:self-center">
<i class="fa-solid fa-arrow-left text-xs"></i>
Kembali
</a>
</div>
{{-- SP Level Badge --}}
@if($currentSpLevel)
<div class="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold mb-4"
style="background-color: {{ $currentSpLevel->color }}15; color: {{ $currentSpLevel->color }}; border: 1px solid {{ $currentSpLevel->color }}30;">
<div class="relative z-10 mt-5 inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-bold"
style="background-color: {{ $currentSpLevel->color }}22; color: #fff; border: 1px solid {{ $currentSpLevel->color }}66;">
<i class="fa-solid fa-triangle-exclamation"></i>
{{ $currentSpLevel->name }} — {{ $currentSpLevel->min_points }}+ poin
</div>
@endif
{{-- Gradient Stat Cards --}}
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div class="relative overflow-hidden rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 p-4 shadow-sm">
{{-- Stat Cards --}}
<div class="relative z-10 grid grid-cols-2 sm:grid-cols-4 gap-3 mt-6">
<div class="relative overflow-hidden rounded-2xl bg-white/10 backdrop-blur-sm ring-1 ring-white/20 p-4 shadow-lg">
<div class="absolute right-0 top-0 w-12 h-12 opacity-15">
<svg class="w-full h-full text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div>
<p class="text-[10px] font-semibold text-white/70 uppercase tracking-wider">Total Poin</p>
<p class="text-xl font-bold text-white mt-1">{{ $totalPoints }}</p>
<p class="text-[10px] font-bold text-white/70 uppercase tracking-wider">Total Poin</p>
<p class="text-2xl font-black text-white mt-1">{{ $totalPoints }}</p>
</div>
<div class="relative overflow-hidden rounded-xl bg-gradient-to-br from-orange-500 to-red-600 p-4 shadow-sm">
<div class="relative overflow-hidden rounded-2xl bg-white/10 backdrop-blur-sm ring-1 ring-white/20 p-4 shadow-lg">
<div class="absolute right-0 top-0 w-12 h-12 opacity-15">
<svg class="w-full h-full text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div>
<p class="text-[10px] font-semibold text-white/70 uppercase tracking-wider">Pelanggaran</p>
<p class="text-xl font-bold text-white mt-1">{{ $violationCount }}<span class="text-sm font-medium text-white/60">x</span></p>
<p class="text-[10px] font-bold text-white/70 uppercase tracking-wider">Pelanggaran</p>
<p class="text-2xl font-black text-white mt-1">{{ $violationCount }}<span class="text-sm font-semibold text-white/60">x</span></p>
</div>
<div class="relative overflow-hidden rounded-xl bg-gradient-to-br from-violet-500 to-violet-700 p-4 shadow-sm">
<div class="relative overflow-hidden rounded-2xl bg-white/10 backdrop-blur-sm ring-1 ring-white/20 p-4 shadow-lg">
<div class="absolute right-0 top-0 w-12 h-12 opacity-15">
<svg class="w-full h-full text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
</div>
<p class="text-[10px] font-semibold text-white/70 uppercase tracking-wider">SP Terbit</p>
<p class="text-xl font-bold text-white mt-1">{{ $activeSpLetters }}</p>
<p class="text-[10px] font-bold text-white/70 uppercase tracking-wider">SP Terbit</p>
<p class="text-2xl font-black text-white mt-1">{{ $activeSpLetters }}</p>
</div>
<div class="relative overflow-hidden rounded-xl bg-gradient-to-br from-teal-500 to-teal-700 p-4 shadow-sm">
<div class="relative overflow-hidden rounded-2xl bg-white/10 backdrop-blur-sm ring-1 ring-white/20 p-4 shadow-lg">
<div class="absolute right-0 top-0 w-12 h-12 opacity-15">
<svg class="w-full h-full text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div>
<p class="text-[10px] font-semibold text-white/70 uppercase tracking-wider">Terakhir</p>
<p class="text-sm font-bold text-white mt-1">
<p class="text-[10px] font-bold text-white/70 uppercase tracking-wider">Terakhir</p>
<p class="text-base font-black text-white mt-1.5">
@if($lastViolation) {{ $lastViolation->violation_date->diffForHumans() }} @else — @endif
</p>
</div>
</div>
</div>
</div>
{{-- ===== SP PROGRESS ===== --}}
@if($spThresholds->count() > 0)
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 mb-6">
<div class="flex items-center gap-3 mb-5">
<div class="w-8 h-8 rounded-lg bg-blue-50 flex items-center justify-center">
<i class="fa-solid fa-chart-line text-blue-500 text-sm"></i>
@php
$lastMin = $spThresholds->last()->min_points;
$progressPercent = min(100, ($totalPoints / $lastMin) * 100);
@endphp
<div class="bg-white rounded-3xl shadow-sm border border-gray-200 overflow-hidden mb-6">
{{-- Header --}}
<div class="px-6 py-5 border-b border-gray-100 flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-2xl bg-gradient-to-br from-amber-500 to-red-500 flex items-center justify-center shadow-md shadow-orange-200/50">
<i class="fa-solid fa-triangle-exclamation text-white text-sm"></i>
</div>
<div>
<h3 class="text-sm font-semibold text-gray-900">Progress Ambang SP</h3>
<p class="text-xs text-gray-400">{{ $totalPoints }} / {{ $nextSpThreshold ? $nextSpThreshold->min_points : $spThresholds->last()->min_points }} poin</p>
<h3 class="text-sm font-black text-gray-900">Progress Ambang SP</h3>
<p class="text-xs text-gray-400">Jarak menuju surat peringatan</p>
</div>
</div>
<div class="text-right shrink-0">
<p class="text-2xl font-black text-gray-900 leading-none">{{ $totalPoints }}<span class="text-sm font-bold text-gray-400"> poin</span></p>
<p class="text-[10px] text-gray-400 mt-1">maks. {{ $lastMin }} poin</p>
</div>
</div>
@php
$maxPoints = $nextSpThreshold ? $nextSpThreshold->min_points : $spThresholds->last()->min_points;
$progressPercent = $totalPoints >= $spThresholds->last()->min_points ? 100 : min(100, ($totalPoints / $maxPoints) * 100);
@endphp
<div class="relative h-3 bg-gray-100 rounded-full overflow-hidden mb-4">
{{-- Body --}}
<div class="p-6">
{{-- Bar + milestone --}}
<div class="relative h-4 bg-gray-100 rounded-full overflow-hidden shadow-inner">
<div class="sp-progress-bar h-full rounded-full transition-all duration-500" style="width: {{ $progressPercent }}%"></div>
{{-- Tick marker di atas bar --}}
@foreach($spThresholds as $threshold)
@php $markerLeft = min(100, ($threshold->min_points / $lastMin) * 100); @endphp
<div class="absolute top-0 bottom-0 w-0.5 bg-white/80" style="left: {{ $markerLeft }}%"></div>
@endforeach
</div>
<div class="relative flex justify-between">
{{-- Label marker --}}
<div class="relative h-11 mt-2">
@foreach($spThresholds as $threshold)
@php $tsReached = $totalPoints >= $threshold->min_points; @endphp
<div class="flex flex-col items-center" style="flex:1">
<div class="flex items-center gap-1.5 mb-1.5">
<span class="w-3 h-3 rounded-full {{ $tsReached ? 'shadow-sm' : 'bg-gray-300' }}" style="background-color: {{ $tsReached ? $threshold->color : '' }}"></span>
<span class="text-xs font-semibold {{ $tsReached ? '' : 'text-gray-400' }}" style="color: {{ $tsReached ? $threshold->color : '' }}">{{ $threshold->min_points }}</span>
</div>
<span class="text-[10px] {{ $tsReached ? 'font-medium' : 'text-gray-400' }}" style="color: {{ $tsReached ? $threshold->color : '' }}">{{ $threshold->name }}</span>
@php
$tsReached = $totalPoints >= $threshold->min_points;
$markerLeft = min(100, ($threshold->min_points / $lastMin) * 100);
@endphp
<div class="absolute top-0 flex flex-col items-center -translate-x-1/2" style="left: {{ $markerLeft }}%">
<span class="w-3 h-3 rounded-full mb-1 shadow-sm {{ $tsReached ? '' : 'bg-gray-300' }}"
style="background-color: {{ $tsReached ? $threshold->color : '' }}"></span>
<span class="text-[11px] font-black leading-none {{ $tsReached ? '' : 'text-gray-400' }}"
style="color: {{ $tsReached ? $threshold->color : '' }}">{{ $threshold->min_points }}</span>
<span class="text-[9px] font-semibold mt-0.5 {{ $tsReached ? '' : 'text-gray-400' }}"
style="color: {{ $tsReached ? $threshold->color : '' }}">{{ $threshold->name }}</span>
</div>
@endforeach
</div>
{{-- Status box --}}
@if($currentSpLevel)
<div class="mt-5 p-4 rounded-xl flex items-start gap-3" style="background-color: {{ $currentSpLevel->color }}10; border: 1px solid {{ $currentSpLevel->color }}20;">
<div class="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0" style="background-color: {{ $currentSpLevel->color }}20;">
<i class="fa-solid fa-triangle-exclamation" style="color: {{ $currentSpLevel->color }}"></i>
<div class="mt-4 p-4 rounded-2xl flex items-start gap-3 border"
style="background-color: {{ $currentSpLevel->color }}10; border-color: {{ $currentSpLevel->color }}30;">
<div class="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 shadow-sm"
style="background: linear-gradient(135deg, {{ $currentSpLevel->color }}, {{ $currentSpLevel->color }}bb);">
<i class="fa-solid fa-triangle-exclamation text-white"></i>
</div>
<div>
<p class="text-sm font-semibold" style="color: {{ $currentSpLevel->color }}">{{ $currentSpLevel->name }} Sudah Tercapai</p>
<p class="text-xs text-gray-500 mt-0.5">Siswa telah mencapai ambang {{ $currentSpLevel->min_points }} poin. Surat peringatan telah diterbitkan.</p>
<div class="min-w-0">
<p class="text-sm font-black" style="color: {{ $currentSpLevel->color }}">{{ $currentSpLevel->name }} Sudah Tercapai</p>
<p class="text-xs text-gray-500 mt-1">Siswa telah mencapai ambang <strong>{{ $currentSpLevel->min_points }}</strong> poin.
@if($nextSpThreshold) Menuju {{ $nextSpThreshold->name }} ({{ $nextSpThreshold->min_points }} poin). @endif
</p>
</div>
<span class="ml-auto inline-flex items-center px-2.5 py-1 text-[10px] font-bold rounded-full shrink-0"
style="background-color: {{ $currentSpLevel->color }}20; color: {{ $currentSpLevel->color }};">
<i class="fa-solid fa-flag-checkered mr-1"></i> Tercapai
</span>
</div>
@elseif($nextSpThreshold && $violationCount > 0)
<div class="mt-5 p-4 rounded-xl bg-blue-50 border border-blue-100 flex items-start gap-3">
<div class="w-8 h-8 rounded-lg bg-blue-100 flex items-center justify-center flex-shrink-0">
<i class="fa-solid fa-circle-info text-blue-600"></i>
<div class="mt-4 p-4 rounded-2xl bg-gradient-to-r from-amber-50 to-orange-50 border border-amber-200 flex items-start gap-3">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-amber-500 to-orange-500 flex items-center justify-center flex-shrink-0 shadow-sm">
<i class="fa-solid fa-circle-info text-white"></i>
</div>
<div>
<p class="text-sm font-semibold text-blue-700">Menuju {{ $nextSpThreshold->name }}</p>
<p class="text-xs text-blue-500/70 mt-0.5">{{ $nextSpThreshold->min_points - $totalPoints }} poin lagi untuk mencapai {{ $nextSpThreshold->name }}</p>
<div class="min-w-0 flex-1">
<p class="text-sm font-black text-amber-700">Menuju {{ $nextSpThreshold->name }}</p>
<div class="flex items-center gap-2 mt-1.5">
<div class="flex-1 h-1.5 bg-amber-100 rounded-full overflow-hidden max-w-[160px]">
<div class="h-full bg-gradient-to-r from-amber-500 to-orange-500 rounded-full"
style="width: {{ min(100, ($totalPoints / $nextSpThreshold->min_points) * 100) }}%"></div>
</div>
<p class="text-xs font-bold text-amber-600">{{ $nextSpThreshold->min_points - $totalPoints }} poin lagi</p>
</div>
</div>
<span class="shrink-0 text-right">
<p class="text-lg font-black text-amber-600 leading-none">{{ $totalPoints }}/{{ $nextSpThreshold->min_points }}</p>
<p class="text-[9px] text-amber-400 font-semibold mt-0.5">menuju target</p>
</span>
</div>
@endif
</div>
</div>
@endif
{{-- ===== VIOLATIONS TIMELINE ===== --}}
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden mb-6">
<div class="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-xl bg-gradient-to-br from-orange-500 to-red-500 flex items-center justify-center shadow-sm">
<i class="fa-solid fa-triangle-exclamation text-white text-sm"></i>
<div class="relative overflow-hidden bg-gradient-to-r from-orange-500 via-red-500 to-red-600 px-6 py-5 flex flex-wrap items-center justify-between gap-3">
<div class="pointer-events-none absolute -top-10 -right-10 w-40 h-40 rounded-full bg-white/10 blur-2xl"></div>
<div class="pointer-events-none absolute inset-0 opacity-[0.06]"
style="background-image: radial-gradient(circle at 25% 40%, #fff 1.5px, transparent 1.5px); background-size: 20px 20px;"></div>
<div class="relative z-10 flex items-center gap-3.5">
<div class="w-11 h-11 rounded-2xl bg-white/15 flex items-center justify-center ring-1 ring-white/25 backdrop-blur-sm">
<i class="fa-solid fa-triangle-exclamation text-white text-lg"></i>
</div>
<div>
<h3 class="text-sm font-semibold text-gray-900">Riwayat Pelanggaran</h3>
<p class="text-xs text-gray-400">{{ $violationCount }} catatan pelanggaran</p>
<h3 class="text-base font-black text-white">Riwayat Pelanggaran</h3>
<p class="text-xs text-white/75">{{ $violationCount }} catatan pelanggaran</p>
</div>
</div>
<span class="relative z-10 inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-black text-white bg-white/15 ring-1 ring-white/25 rounded-xl backdrop-blur-sm">
<i class="fa-solid fa-star text-[10px]"></i>
Total +{{ $totalPoints }} poin
</span>
</div>
@forelse($student->violations as $v)
@if($student->violations->count() > 0)
<div class="relative pl-3">
<div class="absolute left-[26px] top-4 bottom-4 border-l-2 border-dashed border-gray-300"></div>
@foreach($student->violations as $v)
@php
$handleStatusLabels = [
'unhandled' => ['label' => 'Belum Ditangani', 'class' => 'bg-red-50 text-red-600 border-red-200'],
@@ -213,11 +269,11 @@
];
$hs = $handleStatusLabels[$v->handling_status] ?? ['label' => $v->handling_status, 'class' => 'bg-gray-50 text-gray-600'];
@endphp
<div class="violation-timeline relative pl-[52px] pr-6 py-4 {{ !$loop->last ? 'border-b border-gray-50' : '' }} hover:bg-gray-50/50 transition">
{{-- Timeline dot --}}
<div class="absolute left-[15px] top-[15px] w-[16px] h-[16px] rounded-full border-[3px] bg-white flex items-center justify-center"
style="border-color: {{ $v->violationType?->category?->color ?? '#9ca3af' }}">
<span class="w-[6px] h-[6px] rounded-full" style="background-color: {{ $v->violationType?->category?->color ?? '#9ca3af' }}"></span>
<div class="relative pl-20 pr-8 py-5 hover:bg-gray-50/40 transition">
{{-- Bubble ikon kategori --}}
<div class="absolute left-[7px] top-5 w-10 h-10 rounded-xl flex items-center justify-center shadow-md ring-4 ring-gray-50"
style="background: linear-gradient(135deg, {{ $v->violationType?->category?->color ?? '#6b7280' }}, {{ $v->violationType?->category?->color ?? '#6b7280' }}bb);">
<i class="fa-solid fa-triangle-exclamation text-white text-sm"></i>
</div>
<div class="space-y-2.5">
@@ -225,7 +281,12 @@
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-1.5">
<span class="text-sm font-bold text-gray-900">{{ $v->violationType->name ?? '—' }}</span>
<span class="text-sm font-black text-gray-900">{{ $v->violationType->name ?? '—' }}</span>
<span class="inline-flex items-center gap-1 px-2 py-0.5 text-[10px] font-bold rounded-full"
style="background-color: {{ $v->violationType?->category?->color ?? '#6b7280' }}15; color: {{ $v->violationType?->category?->color ?? '#6b7280' }}">
<span class="w-1 h-1 rounded-full" style="background-color: {{ $v->violationType?->category?->color ?? '#6b7280' }}"></span>
{{ $v->violationType?->category?->name ?? '—' }}
</span>
{{-- Verification badge --}}
@if($v->is_verified)
<span class="inline-flex items-center gap-1 px-2 py-0.5 text-[10px] font-medium bg-emerald-50 text-emerald-700 rounded-full border border-emerald-200">
@@ -246,8 +307,8 @@
{{-- Poin --}}
<div class="flex flex-col items-center flex-shrink-0 min-w-[72px]">
<span class="inline-flex items-center px-3 py-1 text-xs font-bold rounded-lg shadow-sm
{{ $v->points >= 50 ? 'bg-red-500 text-white' : ($v->points >= 15 ? 'bg-yellow-500 text-white' : 'bg-blue-500 text-white') }}">
<span class="inline-flex items-center px-3.5 py-1.5 text-sm font-black text-white rounded-xl shadow-md
{{ $v->points >= 50 ? 'bg-gradient-to-br from-red-500 to-red-600 shadow-red-200' : ($v->points >= 15 ? 'bg-gradient-to-br from-amber-500 to-orange-500 shadow-amber-200' : 'bg-gradient-to-br from-blue-500 to-blue-600 shadow-blue-200') }}">
+{{ $v->points }}
</span>
@if($v->sanction)
@@ -324,14 +385,16 @@
{{-- Detail link --}}
<div class="flex justify-end">
<a href="{{ route('violations.show', $v->id) }}"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-semibold text-blue-600 bg-blue-50 border border-blue-100 rounded-lg hover:bg-blue-100 transition shadow-sm">
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-bold text-blue-600 bg-blue-50 border border-blue-100 rounded-lg hover:bg-blue-100 transition shadow-sm">
Detail Pelanggaran
<i class="fa-solid fa-arrow-right text-[9px]"></i>
</a>
</div>
</div>
</div>
@empty
@endforeach
</div>
@else
<div class="py-16 text-center">
<div class="w-16 h-16 rounded-full bg-gray-50 border border-gray-100 flex items-center justify-center mx-auto mb-4">
<i class="fa-solid fa-face-smile text-gray-300 text-2xl"></i>
@@ -339,7 +402,7 @@
<h4 class="text-sm font-medium text-gray-500 mb-1">Belum Ada Pelanggaran</h4>
<p class="text-xs text-gray-400">Siswa ini belum memiliki catatan pelanggaran</p>
</div>
@endforelse
@endif
</div>
{{-- ===== SP LETTERS ===== --}}
@@ -435,6 +498,7 @@
// Kontak
$contact = [
['Telepon', $student->phone_number, 'fa-phone'],
['HP Orang Tua/Wali', $student->parent_phone, 'fa-user'],
['Email', $student->email, 'fa-envelope'],
];
// Sistem
@@ -574,26 +638,32 @@
class="fixed inset-0 z-50 overflow-y-auto" style="display: none;">
<div class="flex items-center justify-center min-h-screen px-4 py-4">
<div class="fixed inset-0 bg-gray-900/40 backdrop-blur-sm"></div>
<div class="relative bg-white rounded-2xl shadow-xl border border-gray-200 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
<div class="px-6 py-4 border-b border-gray-100 flex items-center justify-between sticky top-0 bg-white z-10">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center shadow-sm">
<i class="fa-solid fa-pen-to-square text-white text-sm"></i>
<div class="relative bg-white rounded-3xl shadow-2xl border border-gray-200 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
<div class="relative overflow-hidden bg-gradient-to-r from-blue-600 via-blue-500 to-sky-400 px-7 py-6 sticky top-0 z-10">
<div class="pointer-events-none absolute -top-10 -right-10 w-44 h-44 rounded-full bg-white/10 blur-2xl"></div>
<div class="pointer-events-none absolute inset-0 opacity-[0.06]"
style="background-image: radial-gradient(circle at 25% 40%, #fff 1.5px, transparent 1.5px); background-size: 20px 20px;"></div>
<div class="relative z-10 flex items-center justify-between gap-4">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-2xl bg-white/15 flex items-center justify-center ring-1 ring-white/25 backdrop-blur-sm">
<i class="fa-solid fa-pen-to-square text-white text-xl"></i>
</div>
<div>
<h3 class="text-sm font-semibold text-gray-900">Update Data Siswa</h3>
<p class="text-xs text-gray-400">Perbarui data diri {{ $student->full_name }}</p>
<h3 class="text-base font-black text-white">Update Data Siswa</h3>
<p class="text-xs text-white/75">Perbarui data diri {{ $student->full_name }}</p>
</div>
</div>
<button @click="showEditModal = false" class="w-8 h-8 rounded-lg flex items-center justify-center text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition">
<button @click="showEditModal = false"
class="w-9 h-9 rounded-xl bg-white/10 ring-1 ring-white/25 flex items-center justify-center text-white hover:bg-white/25 transition flex-shrink-0">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</div>
<form action="{{ route('students.update', $student->id) }}" method="POST" class="p-6">
<form action="{{ route('students.update', $student->id) }}" method="POST" class="p-7">
@csrf @method('PUT')
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">Nama Lengkap <span class="text-red-500">*</span></label>
<input type="text" name="full_name" value="{{ $student->full_name }}" required
@@ -638,6 +708,11 @@
<input type="text" name="phone_number" value="{{ $student->phone_number }}"
class="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">No. HP Orang Tua/Wali</label>
<input type="text" name="parent_phone" value="{{ $student->parent_phone }}" placeholder="08xxxxxxxxxx"
class="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input type="email" name="email" value="{{ $student->email }}"
+169 -70
View File
@@ -34,6 +34,9 @@
<option value="bk" @selected(request('role') == 'bk')>BK</option>
<option value="wali_kelas" @selected(request('role') == 'wali_kelas')>Wali Kelas</option>
<option value="staff" @selected(request('role') == 'staff')>Staff</option>
<option value="kepala_sekolah" @selected(request('role') == 'kepala_sekolah')>Kepala Sekolah</option>
<option value="waka_kesiswaan" @selected(request('role') == 'waka_kesiswaan')>Waka Kesiswaan</option>
<option value="ketua_tim" @selected(request('role') == 'ketua_tim')>Ketua Tim</option>
<option value="other" @selected(request('role') == 'other')>Other</option>
</select>
<div class="flex space-x-2">
@@ -68,11 +71,7 @@
<tr class="hover:bg-gray-50 transition {{ !$user->is_active ? 'opacity-60' : '' }}">
<td class="px-5 py-4">
<div class="flex items-center space-x-3">
<div class="w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold flex-shrink-0
@if($user->role === 'admin') bg-blue-100 text-blue-600
@elseif($user->role === 'bk') bg-blue-100 text-blue-600
@elseif($user->role === 'wali_kelas') bg-green-100 text-green-600
@else bg-gray-100 text-gray-600 @endif">
<div class="w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold flex-shrink-0 bg-blue-100 text-blue-600">
{{ strtoupper(substr($user->name, 0, 1)) }}
</div>
<div>
@@ -83,17 +82,27 @@
</td>
<td class="px-5 py-4 text-sm text-gray-700 font-mono">{{ $user->username }}</td>
<td class="px-5 py-4">
<span class="inline-flex items-center px-2.5 py-1 text-xs font-medium rounded-full
@if($user->role === 'admin') bg-blue-100 text-red-700
@elseif($user->role === 'bk') bg-blue-100 text-blue-700
@elseif($user->role === 'wali_kelas') bg-green-100 text-green-700
@else bg-gray-100 text-gray-700 @endif">
@if($user->role === 'admin') <i class="fa-solid fa-shield-halved mr-1"></i>
@elseif($user->role === 'bk') <i class="fa-solid fa-user-tie mr-1"></i>
@elseif($user->role === 'wali_kelas') <i class="fa-solid fa-chalkboard-user mr-1"></i>
@else <i class="fa-solid fa-user mr-1"></i> @endif
{{ ucfirst(str_replace('_', ' ', $user->role)) }}
<div class="flex flex-wrap gap-1.5">
@foreach($user->roleList() as $r)
@php
$roleBadges = [
'admin' => ['bg-blue-100 text-blue-700', 'fa-shield-halved'],
'bk' => ['bg-cyan-100 text-cyan-700', 'fa-user-tie'],
'wali_kelas' => ['bg-emerald-100 text-emerald-700', 'fa-chalkboard-user'],
'staff' => ['bg-violet-100 text-violet-700', 'fa-user-gear'],
'kepala_sekolah' => ['bg-amber-100 text-amber-700', 'fa-crown'],
'waka_kesiswaan' => ['bg-rose-100 text-rose-700', 'fa-user-graduate'],
'ketua_tim' => ['bg-teal-100 text-teal-700', 'fa-user-group'],
'other' => ['bg-gray-100 text-gray-700', 'fa-user'],
];
$rb = $roleBadges[$r] ?? ['bg-gray-100 text-gray-700', 'fa-user'];
@endphp
<span class="inline-flex items-center px-2.5 py-1 text-xs font-semibold rounded-full {{ $rb[0] }}">
<i class="fa-solid {{ $rb[1] }} mr-1"></i>
{{ ucfirst(str_replace('_', ' ', $r)) }}
</span>
@endforeach
</div>
</td>
<td class="px-5 py-4 text-center">
@if($user->is_active)
@@ -110,7 +119,7 @@
</td>
<td class="px-5 py-4 text-right">
<div class="flex items-center justify-end space-x-1">
<button @click="openEdit({{ $user->id }}, '{{ addslashes($user->name) }}', '{{ $user->username }}', '{{ $user->email }}', '{{ $user->role }}', {{ $user->is_active ? 'true' : 'false' }})"
<button @click="openEdit({{ $user->id }}, '{{ addslashes($user->name) }}', '{{ $user->username }}', '{{ $user->email }}', '{{ implode(',', $user->roleList()) }}', {{ $user->is_active ? 'true' : 'false' }})"
class="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition" title="Edit">
<i class="fa-solid fa-pen-to-square"></i>
</button>
@@ -153,95 +162,168 @@
</div>
</div>
{{-- Modal --}}
<div x-show="modalOpen" class="fixed inset-0 z-50 overflow-y-auto" style="display: none;">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
<div class="fixed inset-0 bg-gray-500 bg-opacity-50 transition-opacity"></div>
<div
class="relative inline-block align-bottom bg-white rounded-2xl shadow-xl border border-gray-200 text-left overflow-hidden transform transition-all sm:align-middle sm:max-w-lg sm:w-full">
<div class="px-6 py-5 border-b border-gray-100 flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-lg flex items-center justify-center" :class="isEditing ? 'bg-blue-100' : 'bg-blue-100'">
<i class="fa-solid" :class="isEditing ? 'fa-pen-to-square text-blue-600' : 'fa-user-plus text-blue-600'"></i>
{{-- Modal --}}
<div x-show="modalOpen"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-50 overflow-y-auto" style="display: none;">
<div class="flex items-center justify-center min-h-screen px-4 py-6">
<div class="fixed inset-0 bg-gray-900/50 backdrop-blur-sm"></div>
<div class="relative bg-white rounded-3xl shadow-2xl w-full max-w-xl mx-4 max-h-[92vh] overflow-y-auto">
{{-- Header hero --}}
<div class="relative overflow-hidden bg-gradient-to-r from-blue-600 via-blue-500 to-sky-400 px-7 py-6 sticky top-0 z-10">
<div class="pointer-events-none absolute -top-10 -right-10 w-44 h-44 rounded-full bg-white/10 blur-2xl"></div>
<div class="pointer-events-none absolute -bottom-14 -left-8 w-40 h-40 rounded-full bg-sky-300/20 blur-3xl"></div>
<div class="pointer-events-none absolute inset-0 opacity-[0.06]"
style="background-image: radial-gradient(circle at 25% 40%, #fff 1.5px, transparent 1.5px); background-size: 20px 20px;"></div>
<div class="relative z-10 flex items-center justify-between gap-4">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-2xl bg-white/15 flex items-center justify-center ring-1 ring-white/25 backdrop-blur-sm">
<i class="fa-solid text-white text-xl" :class="isEditing ? 'fa-pen-to-square' : 'fa-user-plus'"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900" x-text="isEditing ? 'Edit User' : 'Tambah User'"></h3>
<p class="text-sm text-gray-500" x-text="isEditing ? 'Ubah data pengguna' : 'Buat akun pengguna baru'"></p>
<h3 class="text-base font-black text-white" x-text="isEditing ? 'Edit User' : 'Tambah User'"></h3>
<p class="text-xs text-white/75" x-text="isEditing ? 'Ubah data pengguna' : 'Buat akun pengguna baru'"></p>
</div>
</div>
<button @click="modalOpen = false" class="text-gray-400 hover:text-gray-600 transition">
<i class="fa-solid fa-xmark text-xl"></i>
<button @click="modalOpen = false"
class="w-9 h-9 rounded-xl bg-white/10 ring-1 ring-white/25 flex items-center justify-center text-white hover:bg-white/25 transition flex-shrink-0">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</div>
{{-- Form --}}
<form :action="isEditing ? `/users/${editId}` : '{{ route('users.store') }}'" method="POST" class="p-6 space-y-4">
<form :action="isEditing ? `/users/${editId}` : '{{ route('users.store') }}'" method="POST" class="p-7">
@csrf
<input type="hidden" name="_method" :value="isEditing ? 'PUT' : 'POST'">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1.5">Nama Lengkap</label>
<div class="space-y-5">
{{-- Nama --}}
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">Nama Lengkap <span class="text-red-500">*</span></label>
<div class="relative">
<i class="fa-solid fa-user absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none"></i>
<input type="text" x-model="formName" name="name" required
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition"
class="w-full pl-11 pr-4 py-3 border-2 border-gray-200 rounded-2xl text-sm font-semibold bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/15 focus:border-blue-500 transition"
placeholder="Nama lengkap">
</div>
</div>
{{-- Username + Email --}}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">Username</label>
<label class="block text-sm font-bold text-gray-700 mb-2">Username <span class="text-red-500">*</span></label>
<div class="relative">
<i class="fa-solid fa-at absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none"></i>
<input type="text" x-model="formUsername" name="username" required
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition"
class="w-full pl-11 pr-4 py-3 border-2 border-gray-200 rounded-2xl text-sm font-semibold bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/15 focus:border-blue-500 transition"
placeholder="username">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">Email</label>
<label class="block text-sm font-bold text-gray-700 mb-2">Email <span class="text-red-500">*</span></label>
<div class="relative">
<i class="fa-solid fa-envelope absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none"></i>
<input type="email" x-model="formEmail" name="email" required
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition"
class="w-full pl-11 pr-4 py-3 border-2 border-gray-200 rounded-2xl text-sm font-semibold bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/15 focus:border-blue-500 transition"
placeholder="email@sekolah.sch.id">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">
Password <span x-text="isEditing ? '(kosongkan jika tidak diubah)' : ''" class="text-gray-400 font-normal"></span>
</label>
<input type="password" x-model="formPassword" name="password" :required="!isEditing" minlength="6"
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition"
placeholder="Min. 6 karakter">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">Role / Hak Akses</label>
<select x-model="formRole" name="role" required
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition bg-white">
<option value="bk">BK — Input & laporan</option>
<option value="admin">Admin — Full akses</option>
<option value="wali_kelas">Wali Kelas — Lihat kelas sendiri</option>
<option value="staff">Staff — Terbatas</option>
<option value="other">Other — Guest / Terbatas</option>
</select>
</div>
</div>
<div x-show="isEditing" class="flex items-center justify-between p-4 bg-gray-50 rounded-xl border border-gray-100">
{{-- Password --}}
<div>
<p class="text-sm font-medium text-gray-900">Akun Aktif</p>
<label class="block text-sm font-bold text-gray-700 mb-2">
Password <span class="text-red-500">*</span>
<span x-show="isEditing" class="text-xs font-normal text-gray-400">(kosongkan jika tidak diubah)</span>
</label>
<div class="relative">
<i class="fa-solid fa-lock absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none"></i>
<input :type="showPassword ? 'text' : 'password'" x-model="formPassword" name="password" :required="!isEditing" minlength="6"
class="w-full pl-11 pr-12 py-3 border-2 border-gray-200 rounded-2xl text-sm font-semibold bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/15 focus:border-blue-500 transition"
placeholder="Min. 6 karakter">
<button type="button" @click="showPassword = !showPassword"
class="absolute right-3.5 top-1/2 -translate-y-1/2 w-8 h-8 rounded-lg flex items-center justify-center text-gray-400 hover:text-blue-600 hover:bg-blue-50 transition">
<i class="fa-solid text-sm" :class="showPassword ? 'fa-eye-slash' : 'fa-eye'"></i>
</button>
</div>
</div>
{{-- Role (multi) --}}
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">Role / Hak Akses <span class="text-red-500">*</span> <span class="text-xs font-normal text-gray-400">(bisa pilih lebih dari satu)</span></label>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
<template x-for="r in [
{ v: 'admin', label: 'Admin', desc: 'Full akses', icon: 'fa-user-shield', color: '#2563eb' },
{ v: 'bk', label: 'BK', desc: 'Input & laporan', icon: 'fa-user-tie', color: '#14b8a6' },
{ v: 'wali_kelas', label: 'Wali Kelas', desc: 'Lihat kelas sendiri', icon: 'fa-chalkboard-user', color: '#10b981' },
{ v: 'staff', label: 'Staff', desc: 'Terbatas', icon: 'fa-user-gear', color: '#8b5cf6' },
{ v: 'kepala_sekolah', label: 'Kepala Sekolah', desc: 'Pimpinan / persetujuan', icon: 'fa-crown', color: '#f59e0b' },
{ v: 'waka_kesiswaan', label: 'Waka Kesiswaan', desc: 'Pengelola kesiswaan', icon: 'fa-user-graduate', color: '#f43f5e' },
{ v: 'ketua_tim', label: 'Ketua Tim', desc: 'Koordinator tim', icon: 'fa-user-group', color: '#14b8a6' },
{ v: 'other', label: 'Other', desc: 'Guest / Terbatas', icon: 'fa-user', color: '#64748b' },
]" :key="r.v">
<label class="flex items-center gap-3 rounded-2xl border-2 p-3 cursor-pointer transition-all duration-150 active:scale-[0.98]"
:class="formRoles.includes(r.v) ? 'border-transparent text-white shadow-md ring-2' : 'border-gray-200 bg-gray-50/50 hover:border-gray-300'"
:style="formRoles.includes(r.v) ? 'background: linear-gradient(135deg, ' + r.color + ', ' + r.color + 'bb);' : ''">
<input type="checkbox" :name="'roles[]'" :value="r.v" x-model="formRoles" class="sr-only">
<div class="w-9 h-9 rounded-xl flex items-center justify-center shrink-0"
:class="formRoles.includes(r.v) ? 'bg-white/20' : 'bg-white shadow-sm border border-gray-200'">
<i :class="'fa-solid ' + r.icon" class="text-sm" :class="formRoles.includes(r.v) ? 'text-white' : 'text-gray-400'"></i>
</div>
<div class="min-w-0">
<p class="text-sm font-bold" :class="formRoles.includes(r.v) ? 'text-white' : 'text-gray-800'" x-text="r.label"></p>
<p class="text-[10px]" :class="formRoles.includes(r.v) ? 'text-white/70' : 'text-gray-400'" x-text="r.desc"></p>
</div>
<i class="fa-solid fa-circle-check ml-auto text-base"
:class="formRoles.includes(r.v) ? 'text-white' : 'text-gray-200'"></i>
</label>
</template>
</div>
</div>
{{-- Aktif (edit) --}}
<div x-show="isEditing" x-transition class="flex items-center justify-between p-4 bg-gray-50 rounded-2xl border border-gray-100">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-xl bg-white shadow-sm border border-gray-200 flex items-center justify-center">
<i class="fa-solid fa-power-off text-emerald-500 text-xs"></i>
</div>
<div>
<p class="text-sm font-bold text-gray-900">Akun Aktif</p>
<p class="text-xs text-gray-500">Nonaktifkan untuk menonaktifkan akun</p>
</div>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" x-model="formActive" name="is_active" value="1" class="sr-only peer">
<div class="w-11 h-6 bg-gray-200 rounded-full peer peer-checked:after:translate-x-full peer-checked:bg-blue-600 after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all"></div>
<input type="checkbox" x-model="formActive" name="is_active" value="1" class="sr-only">
<span class="relative h-6 w-11 rounded-full transition-colors duration-200" :class="formActive ? 'bg-gradient-to-r from-emerald-500 to-teal-500' : 'bg-gray-300'">
<span class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform duration-200" :class="formActive ? 'translate-x-5' : ''"></span>
</span>
</label>
</div>
</div>
<div class="flex justify-end space-x-3 pt-2">
{{-- Footer --}}
<div class="flex items-center justify-end gap-3 mt-7 pt-5 border-t border-gray-100">
<button type="button" @click="modalOpen = false"
class="px-5 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-xl hover:bg-gray-50 transition">Batal</button>
class="px-6 py-3 text-sm font-bold text-gray-600 bg-white border-2 border-gray-200 rounded-2xl hover:bg-gray-50 transition">
Batal
</button>
<button type="submit"
class="px-5 py-2.5 text-sm font-medium text-white rounded-xl transition shadow-sm"
:class="isEditing ? 'bg-blue-600 hover:bg-blue-700' : 'bg-blue-600 hover:bg-blue-700'"
x-text="isEditing ? 'Simpan Perubahan' : 'Tambah User'"></button>
class="px-7 py-3 text-sm font-black text-white bg-gradient-to-r from-blue-600 to-sky-500 rounded-2xl shadow-lg shadow-blue-200 hover:-translate-y-0.5 hover:brightness-105 transition-all inline-flex items-center gap-2 active:scale-95">
<i class="fa-solid fa-floppy-disk"></i>
<span x-text="isEditing ? 'Simpan Perubahan' : 'Tambah User'"></span>
</button>
</div>
</form>
</div>
</div>
</div>
</div>
@push('scripts')
@@ -249,16 +331,33 @@
function userManager() {
return {
modalOpen: false, isEditing: false, editId: null,
formName: '', formUsername: '', formEmail: '', formPassword: '', formRole: 'bk', formActive: true,
formName: '', formUsername: '', formEmail: '', formPassword: '', formRoles: ['bk'], formActive: true,
showPassword: false,
get roleInfo() {
const map = {
admin: { icon: 'fa-user-shield', color: '#2563eb' },
bk: { icon: 'fa-user-tie', color: '#14b8a6' },
wali_kelas: { icon: 'fa-chalkboard-user', color: '#10b981' },
staff: { icon: 'fa-user-gear', color: '#8b5cf6' },
kepala_sekolah: { icon: 'fa-crown', color: '#f59e0b' },
waka_kesiswaan: { icon: 'fa-user-graduate', color: '#f43f5e' },
ketua_tim: { icon: 'fa-user-group', color: '#14b8a6' },
other: { icon: 'fa-user', color: '#64748b' },
};
const primary = this.formRoles[0] || 'bk';
return map[primary] || { icon: 'fa-user', color: '#64748b' };
},
openCreate() {
this.isEditing = false; this.editId = null;
this.formName = ''; this.formUsername = ''; this.formEmail = ''; this.formPassword = '';
this.formRole = 'bk'; this.formActive = true; this.modalOpen = true;
this.formRoles = ['bk']; this.formActive = true; this.showPassword = false; this.modalOpen = true;
},
openEdit(id, name, username, email, role, active) {
openEdit(id, name, username, email, rolesCsv, active) {
this.isEditing = true; this.editId = id;
this.formName = name; this.formUsername = username; this.formEmail = email;
this.formPassword = ''; this.formRole = role; this.formActive = active; this.modalOpen = true;
this.formPassword = '';
this.formRoles = rolesCsv ? rolesCsv.split(',') : ['bk'];
this.formActive = active; this.showPassword = false; this.modalOpen = true;
}
};
}
+24 -11
View File
@@ -44,6 +44,12 @@
<span class="text-[11px] font-bold text-gray-400 uppercase tracking-wider">Data Siswa</span>
</div>
@if (request()->has('student_id'))
<div class="mb-4 p-3 rounded-xl bg-cyan-50 border border-cyan-200 text-xs text-cyan-700 flex items-center gap-2">
<i class="fa-solid fa-fingerprint"></i> Siswa dipilih dari hasil Scan Wajah — periksa kembali sebelum menyimpan.
</div>
@endif
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1.5">Cari Siswa <span class="text-red-500">*</span></label>
<div class="relative">
@@ -122,7 +128,7 @@
<template x-for="(group, gi) in filtered" :key="gi">
<div>
<div class="sticky top-0 px-4 py-1.5 text-xs font-bold uppercase tracking-wider border-b flex items-center gap-1.5 z-10"
:style="'background:' + group.color + '12; color:' + group.color + '; border-color:' + group.color + '30'">
:style="'background:' + group.color + '; color:#fff; border-color:' + group.color">
<span class="w-2 h-2 rounded-full" :style="'background:' + group.color"></span>
<span x-text="group.label"></span>
<span class="ml-auto text-[10px] opacity-60" x-text="'(' + group.types.length + ')'"></span>
@@ -174,21 +180,19 @@
<div class="space-y-2">
<template x-for="(item, i) in selected" :key="item.id">
<div class="flex items-center justify-between p-3 rounded-xl border shadow-sm"
:style="'border-color:' + item.color + '30; background:' + item.color + '06'">
:style="'background:' + item.color + '; border-color:' + item.color">
<input type="hidden" :name="'violation_type_ids[' + i + ']'" :value="item.id">
<div class="flex items-center gap-2.5 min-w-0">
<span class="w-3 h-3 rounded-full flex-shrink-0" :style="'background:' + item.color"></span>
<span class="w-3 h-3 rounded-full flex-shrink-0 bg-white/40 ring-2 ring-white/50" :style="'background:' + item.color"></span>
<div class="min-w-0">
<p class="text-sm font-bold text-gray-900" x-text="item.name"></p>
<p class="text-xs text-gray-500" x-text="item.sanction || 'Tanpa sanksi'"></p>
<p class="text-sm font-bold text-white" x-text="item.name"></p>
<p class="text-xs text-white/75" x-text="item.sanction || 'Tanpa sanksi'"></p>
</div>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<span class="text-base font-bold"
:class="item.points >= 50 ? 'text-red-600' : (item.points >= 15 ? 'text-yellow-600' : 'text-blue-600')"
x-text="'+' + item.points"></span>
<span class="text-base font-bold text-white" x-text="'+' + item.points"></span>
<button type="button" @click="removeType(i)"
class="w-7 h-7 rounded-lg flex items-center justify-center text-gray-300 hover:text-red-500 hover:bg-red-50 transition">
class="w-7 h-7 rounded-lg flex items-center justify-center text-white/70 hover:text-white hover:bg-white/20 transition">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
@@ -384,10 +388,19 @@ function typeMultiSearch(groups) {
};
}
const facePrefill = (() => {
const p = new URLSearchParams(window.location.search);
const id = p.get('student_id');
if (!id) return null;
return { id, name: p.get('name') || '', info: p.get('info') || '' };
})();
function violationForm() {
return {
searchQuery: '', results: [],
selectedStudentId: null, selectedStudentName: '', selectedStudentInfo: '',
searchQuery: facePrefill ? facePrefill.name : '', results: [],
selectedStudentId: facePrefill ? facePrefill.id : null,
selectedStudentName: facePrefill ? facePrefill.name : '',
selectedStudentInfo: facePrefill ? facePrefill.info : '',
files: [],
searchStudents() {
+45 -18
View File
@@ -96,11 +96,29 @@
<th class="px-5 py-3.5 text-center text-xs font-semibold text-gray-400 uppercase tracking-wider">Verifikasi</th>
<th class="px-5 py-3.5 text-center text-xs font-semibold text-gray-400 uppercase tracking-wider hidden lg:table-cell">Penanganan</th>
<th class="px-5 py-3.5 text-left hidden lg:table-cell text-xs font-semibold text-gray-400 uppercase tracking-wider">Oleh</th>
<th class="px-5 py-3.5 text-center text-xs font-semibold text-gray-400 uppercase tracking-wider">Notif WA</th>
<th class="px-5 py-3.5 text-right text-xs font-semibold text-gray-400 uppercase tracking-wider">Aksi</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
@forelse($violations as $v)
@php
$statusStyles = [
'unhandled' => 'bg-red-50 text-red-700 border-red-200',
'in_progress' => 'bg-yellow-50 text-yellow-700 border-yellow-200',
'resolved' => 'bg-green-50 text-green-700 border-green-200',
];
$statusLabels = [
'unhandled' => 'Belum Ditangani',
'in_progress' => 'Dalam Proses',
'resolved' => 'Selesai',
];
$statusDots = [
'unhandled' => 'bg-red-500',
'in_progress' => 'bg-yellow-500',
'resolved' => 'bg-green-500',
];
@endphp
<tr class="hover:bg-gray-50/50 transition">
{{-- Tanggal --}}
<td class="px-5 py-4 whitespace-nowrap">
@@ -128,6 +146,12 @@
<span class="inline-flex items-center px-2 py-0.5 text-[10px] font-mono font-medium bg-gray-100 text-gray-600 rounded-full border border-gray-200">{{ $v->student->nisn ?? '—' }}</span>
<span class="inline-flex items-center px-2 py-0.5 text-[10px] font-medium bg-blue-50 text-blue-600 rounded-full border border-blue-200">{{ $v->student->class_name ?? '—' }}</span>
</div>
<div class="mt-1.5 lg:hidden">
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 text-[10px] font-medium border rounded-full {{ $statusStyles[$v->handling_status] ?? 'bg-gray-50 text-gray-700' }}">
<span class="w-1.5 h-1.5 rounded-full {{ $statusDots[$v->handling_status] ?? 'bg-gray-500' }}"></span>
{{ $statusLabels[$v->handling_status] ?? ucfirst($v->handling_status) }}
</span>
</div>
</div>
</div>
</td>
@@ -161,23 +185,6 @@
</td>
{{-- Penanganan --}}
<td class="px-5 py-4 text-center whitespace-nowrap hidden lg:table-cell">
@php
$statusStyles = [
'unhandled' => 'bg-red-50 text-red-700 border-red-200',
'in_progress' => 'bg-yellow-50 text-yellow-700 border-yellow-200',
'resolved' => 'bg-green-50 text-green-700 border-green-200',
];
$statusLabels = [
'unhandled' => 'Belum Ditangani',
'in_progress' => 'Dalam Proses',
'resolved' => 'Selesai',
];
$statusDots = [
'unhandled' => 'bg-red-500',
'in_progress' => 'bg-yellow-500',
'resolved' => 'bg-green-500',
];
@endphp
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium border rounded-full {{ $statusStyles[$v->handling_status] ?? 'bg-gray-50 text-gray-700' }}">
<span class="w-1.5 h-1.5 rounded-full {{ $statusDots[$v->handling_status] ?? 'bg-gray-500' }}"></span>
{{ $statusLabels[$v->handling_status] ?? ucfirst($v->handling_status) }}
@@ -192,6 +199,26 @@
<span class="text-sm text-gray-500 truncate max-w-[120px]">{{ $v->recorder->name ?? '-' }}</span>
</div>
</td>
{{-- Notif WA --}}
<td class="px-5 py-4 text-center whitespace-nowrap">
@if ($v->notifications_count > 0)
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium bg-green-50 text-green-700 border border-green-200 rounded-full" title="Notifikasi sudah dikirim">
<i class="fa-solid fa-circle-check text-[10px]"></i>
Terkirim
</span>
@elseif ($v->student?->parent_phone)
<a href="{{ route('violations.notify-wa', $v->id) }}" target="_blank" rel="noopener"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold text-white bg-gradient-to-r from-emerald-500 to-green-500 rounded-lg hover:from-emerald-600 hover:to-green-600 active:scale-95 transition shadow-sm shadow-emerald-500/20">
<i class="fa-brands fa-whatsapp text-[10px]"></i>
Kirim WA
</a>
@else
<span class="inline-flex items-center gap-1 px-2.5 py-1 text-xs text-gray-300 border border-gray-100 rounded-lg cursor-default" title="Nomor HP orang tua/wali belum diisi">
<i class="fa-brands fa-whatsapp text-[10px]"></i>
WA
</span>
@endif
</td>
{{-- Aksi --}}
<td class="px-5 py-4 text-right whitespace-nowrap">
<a href="{{ route('violations.show', $v->id) }}"
@@ -203,7 +230,7 @@
</tr>
@empty
<tr>
<td colspan="8" class="px-5 py-20 text-center">
<td colspan="9" class="px-5 py-20 text-center">
<div class="flex flex-col items-center">
<div class="w-14 h-14 rounded-full bg-gray-50 border border-gray-100 flex items-center justify-center mb-4">
<i class="fa-solid fa-triangle-exclamation text-gray-300 text-xl"></i>
File diff suppressed because it is too large Load Diff