feat: Face ID (scan, register, liveness) + role baru KS/Waka/Ketua Tim + notifikasi WA ortu + scoping wali kelas + error pages premium
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\FaceRecognitionService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Enroll massal embedding wajah siswa (fase pilot).
|
||||
*
|
||||
* Format CSV (header wajib):
|
||||
* student_id,name,photo1,photo2,photo3,twin_group
|
||||
* 2026001,Budi Santoso,budi-1.jpg,budi-2.jpg,budi-3.jpg,
|
||||
* 2026002,Ayu Lestari,ayu-1.jpg,ayu-2.jpg,ayu-3.jpg,TW-07
|
||||
*
|
||||
* Jalankan:
|
||||
* php artisan faceid:enroll /path/data.csv --photos=/path/foto
|
||||
*/
|
||||
class FaceIdEnroll extends Command
|
||||
{
|
||||
protected $signature = 'faceid:enroll
|
||||
{csv : Path file CSV (kolom: student_id,name,photo1,photo2,photo3,twin_group)}
|
||||
{--photos= : Direktori basis foto; path relatif di CSV di-resolve dari sini}';
|
||||
|
||||
protected $description = 'Enroll massal embedding wajah siswa dari CSV + folder foto (fase pilot).';
|
||||
|
||||
public function handle(FaceRecognitionService $faceid): int
|
||||
{
|
||||
$csv = $this->argument('csv');
|
||||
if (! is_file($csv)) {
|
||||
$this->error("File CSV tidak ditemukan: {$csv}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
if (! $faceid->isConfigured()) {
|
||||
$this->error('FaceID belum dikonfigurasi (FACEID_BASE_URL / FACEID_API_KEY di .env).');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$photosDir = $this->option('photos') ?: dirname($csv);
|
||||
$rows = array_map('str_getcsv', file($csv));
|
||||
$header = array_shift($rows);
|
||||
if (! $header) {
|
||||
$this->error('CSV kosong / header tidak ditemukan.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
$idx = array_flip($header);
|
||||
|
||||
$ok = 0;
|
||||
$fail = 0;
|
||||
foreach ($rows as $row) {
|
||||
if (count($row) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$studentId = trim($row[$idx['student_id'] ?? 0] ?? '');
|
||||
$name = trim($row[$idx['name'] ?? 1] ?? '');
|
||||
if ($studentId === '' || $name === '') {
|
||||
$fail++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$paths = [];
|
||||
foreach (['photo1', 'photo2', 'photo3'] as $col) {
|
||||
if (! isset($idx[$col])) {
|
||||
continue;
|
||||
}
|
||||
$p = trim($row[$idx[$col]] ?? '');
|
||||
if ($p === '') {
|
||||
continue;
|
||||
}
|
||||
$full = $p[0] === '/' ? $p : rtrim($photosDir, '/').'/'.$p;
|
||||
if (is_file($full)) {
|
||||
$paths[] = $full;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $paths) {
|
||||
$this->warn(" [skip] {$studentId}: tidak ada foto valid");
|
||||
|
||||
$fail++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$twin = null;
|
||||
if (isset($idx['twin_group'])) {
|
||||
$twin = trim($row[$idx['twin_group']] ?? '') ?: null;
|
||||
}
|
||||
|
||||
$res = $faceid->enroll($studentId, $name, $paths, $twin);
|
||||
if ($res['ok']) {
|
||||
$this->info(" [ok] {$studentId} ({$name}): ".$res['data']['photos_accepted'].' foto diterima');
|
||||
$ok++;
|
||||
} else {
|
||||
$this->error(" [gagal] {$studentId} ({$name}): ".($res['error'] ?? 'unknown'));
|
||||
$fail++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Selesai: {$ok} berhasil, {$fail} gagal/skip.");
|
||||
|
||||
return $fail === 0 ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,12 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Classes;
|
||||
use App\Models\HandlingType;
|
||||
use App\Models\SpThreshold;
|
||||
use App\Models\ViolationCategory;
|
||||
use App\Models\ViolationType;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -143,6 +146,147 @@ class MasterDataController extends Controller
|
||||
return back()->with('success', 'Jenis pelanggaran berhasil dihapus.');
|
||||
}
|
||||
|
||||
// === Handling Types (Jenis Penanganan) ===
|
||||
public function handlingTypes(): View
|
||||
{
|
||||
$types = HandlingType::orderBy('sort_order')->orderBy('name')->get();
|
||||
$roles = HandlingType::roles();
|
||||
|
||||
return view('settings.handling-types', compact('types', 'roles'));
|
||||
}
|
||||
|
||||
public function storeHandlingType(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255', 'unique:handling_types,name'],
|
||||
'icon' => ['required', 'string', 'max:100'],
|
||||
'color' => ['required', 'string', 'max:7'],
|
||||
'sort_order' => ['nullable', 'integer', 'min:0', 'max:999'],
|
||||
]);
|
||||
|
||||
$validated['sort_order'] = $validated['sort_order'] ?? 0;
|
||||
$validated['is_active'] = true;
|
||||
|
||||
HandlingType::create($validated);
|
||||
|
||||
return back()->with('success', 'Jenis penanganan berhasil ditambahkan.');
|
||||
}
|
||||
|
||||
public function updateHandlingType(Request $request, HandlingType $type): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255', 'unique:handling_types,name,' . $type->id],
|
||||
'icon' => ['required', 'string', 'max:100'],
|
||||
'color' => ['required', 'string', 'max:7'],
|
||||
'sort_order' => ['nullable', 'integer', 'min:0', 'max:999'],
|
||||
'is_active' => ['boolean'],
|
||||
]);
|
||||
|
||||
$validated['sort_order'] = $validated['sort_order'] ?? $type->sort_order;
|
||||
$validated['is_active'] = $request->boolean('is_active');
|
||||
|
||||
$type->update($validated);
|
||||
|
||||
return back()->with('success', 'Jenis penanganan berhasil diperbarui.');
|
||||
}
|
||||
|
||||
public function reorderHandlingTypes(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'order' => ['required', 'array'],
|
||||
'order.*' => ['integer', 'exists:handling_types,id'],
|
||||
]);
|
||||
|
||||
foreach ($validated['order'] as $i => $id) {
|
||||
HandlingType::where('id', $id)->update(['sort_order' => $i + 1]);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function destroyHandlingType(HandlingType $type): RedirectResponse
|
||||
{
|
||||
if ($type->is_system) {
|
||||
return back()->with('error', 'Jenis penanganan sistem tidak bisa dihapus. Nonaktifkan saja jika tidak diperlukan.');
|
||||
}
|
||||
|
||||
$type->delete();
|
||||
|
||||
return back()->with('success', 'Jenis penanganan berhasil dihapus.');
|
||||
}
|
||||
|
||||
public function storeHandlingRole(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'role' => ['required', 'string', 'max:100'],
|
||||
]);
|
||||
|
||||
$roles = HandlingType::roles();
|
||||
$role = trim($validated['role']);
|
||||
|
||||
if (in_array($role, $roles)) {
|
||||
return back()->with('error', 'Peran "' . $role . '" sudah ada.');
|
||||
}
|
||||
|
||||
$roles[] = $role;
|
||||
HandlingType::saveRoles($roles);
|
||||
|
||||
return back()->with('success', 'Peran "' . $role . '" berhasil ditambahkan.');
|
||||
}
|
||||
|
||||
public function destroyHandlingRole(string $role): RedirectResponse
|
||||
{
|
||||
$roles = HandlingType::roles();
|
||||
$roles = array_values(array_filter($roles, fn($r) => $r !== $role));
|
||||
HandlingType::saveRoles($roles);
|
||||
|
||||
return back()->with('success', 'Peran "' . $role . '" berhasil dihapus.');
|
||||
}
|
||||
|
||||
public function reorderHandlingRoles(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'order' => ['required', 'array'],
|
||||
'order.*' => ['required', 'string', 'max:100'],
|
||||
]);
|
||||
|
||||
HandlingType::saveRoles($validated['order']);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
// === Homeroom Teachers (Wali Kelas) ===
|
||||
public function homeroomTeachers(): View
|
||||
{
|
||||
$classes = Classes::with('homeroomTeacher')
|
||||
->where('is_active', true)
|
||||
->orderBy('level')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
$teachers = \App\Models\User::where(function ($q) {
|
||||
$q->whereJsonContains('roles', 'wali_kelas')->orWhere('role', 'wali_kelas');
|
||||
})
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('settings.homeroom', compact('classes', 'teachers'));
|
||||
}
|
||||
|
||||
public function updateHomeroomTeacher(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'class_id' => ['required', 'exists:classes,id'],
|
||||
'teacher_id' => ['nullable', 'exists:users,id'],
|
||||
]);
|
||||
|
||||
$class = Classes::findOrFail($validated['class_id']);
|
||||
$class->homeroom_teacher_id = $validated['teacher_id'] ?? null;
|
||||
$class->save();
|
||||
|
||||
return back()->with('success', 'Wali kelas untuk ' . $class->name . ' berhasil diperbarui.');
|
||||
}
|
||||
|
||||
// === SP Thresholds ===
|
||||
public function thresholds(): View
|
||||
{
|
||||
|
||||
@@ -19,7 +19,7 @@ class PermissionController extends Controller
|
||||
->get()
|
||||
->groupBy('group');
|
||||
|
||||
$roles = ['admin', 'bk', 'wali_kelas', 'staff', 'other'];
|
||||
$roles = ['admin', 'bk', 'wali_kelas', 'staff', 'other', 'kepala_sekolah', 'waka_kesiswaan', 'ketua_tim'];
|
||||
|
||||
$rolePermissions = [];
|
||||
foreach ($roles as $role) {
|
||||
@@ -69,7 +69,7 @@ class PermissionController extends Controller
|
||||
}
|
||||
|
||||
// Clear cache
|
||||
foreach (['admin', 'bk', 'wali_kelas', 'staff', 'other'] as $role) {
|
||||
foreach (['admin', 'bk', 'wali_kelas', 'staff', 'other', 'kepala_sekolah', 'waka_kesiswaan', 'ketua_tim'] as $role) {
|
||||
Cache::forget('role_permissions:' . $role);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ class SettingsController extends Controller
|
||||
'kepala_sekolah_name' => ['nullable', 'string', 'max:255'],
|
||||
'kepala_sekolah_nip' => ['nullable', 'string', 'max:50'],
|
||||
'bk_koordinator_name' => ['nullable', 'string', 'max:255'],
|
||||
'wa_notification_template' => ['nullable', 'string', 'max:3000'],
|
||||
]);
|
||||
|
||||
foreach ($validated as $key => $value) {
|
||||
|
||||
@@ -25,7 +25,11 @@ class UserController extends Controller
|
||||
}
|
||||
|
||||
if ($request->filled('role')) {
|
||||
$query->where('role', $request->role);
|
||||
$role = $request->role;
|
||||
$query->where(function ($q) use ($role) {
|
||||
$q->where('role', $role)
|
||||
->orWhereJsonContains('roles', $role);
|
||||
});
|
||||
}
|
||||
|
||||
$users = $query->orderBy('name')->paginate(20);
|
||||
@@ -40,10 +44,14 @@ class UserController extends Controller
|
||||
'username' => ['required', 'string', 'max:255', 'unique:users,username'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
|
||||
'password' => ['required', 'string', 'min:6'],
|
||||
'role' => ['required', 'in:admin,bk,wali_kelas,staff,other'],
|
||||
'roles' => ['required', 'array', 'min:1'],
|
||||
'roles.*' => ['in:admin,bk,wali_kelas,staff,other,kepala_sekolah,waka_kesiswaan,ketua_tim'],
|
||||
'is_active' => ['boolean'],
|
||||
]);
|
||||
|
||||
$roles = array_values(array_unique($validated['roles']));
|
||||
$validated['roles'] = $roles;
|
||||
$validated['role'] = $roles[0];
|
||||
$validated['password'] = Hash::make($validated['password']);
|
||||
$validated['is_active'] = $request->boolean('is_active', true);
|
||||
|
||||
@@ -60,10 +68,15 @@ class UserController extends Controller
|
||||
'username' => ['required', 'string', 'max:255', 'unique:users,username,' . $user->id],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email,' . $user->id],
|
||||
'password' => ['nullable', 'string', 'min:6'],
|
||||
'role' => ['required', 'in:admin,bk,wali_kelas,staff,other'],
|
||||
'roles' => ['required', 'array', 'min:1'],
|
||||
'roles.*' => ['in:admin,bk,wali_kelas,staff,other,kepala_sekolah,waka_kesiswaan,ketua_tim'],
|
||||
'is_active' => ['boolean'],
|
||||
]);
|
||||
|
||||
$roles = array_values(array_unique($validated['roles']));
|
||||
$validated['roles'] = $roles;
|
||||
$validated['role'] = $roles[0];
|
||||
|
||||
if (!empty($validated['password'])) {
|
||||
$validated['password'] = Hash::make($validated['password']);
|
||||
} else {
|
||||
@@ -92,7 +105,7 @@ class UserController extends Controller
|
||||
|
||||
public function resetPassword(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
if ($user->id === auth()->id() && $user->role === 'admin') {
|
||||
if ($user->id === auth()->id() && $user->isAdmin()) {
|
||||
return back()->with('error', 'Ganti password admin lewat profil.');
|
||||
}
|
||||
|
||||
|
||||
@@ -16,22 +16,30 @@ class DashboardController extends Controller
|
||||
{
|
||||
$today = now()->toDateString();
|
||||
|
||||
// Wali kelas murni → data dibatasi ke kelas yang diwalikan
|
||||
$scoped = auth()->user()->isScopedWaliKelas();
|
||||
$scopedStudentIds = $scoped ? Student::whereIn('class_id', auth()->user()->homeroomClassIds())->pluck('id')->all() : [];
|
||||
$scopedClassIds = $scoped ? auth()->user()->homeroomClassIds() : [];
|
||||
$vScope = fn ($q) => $q->when($scoped, fn ($qq) => $qq->whereIn('student_id', $scopedStudentIds));
|
||||
|
||||
$stats = [
|
||||
'today_violations' => Violation::whereDate('violation_date', $today)->count(),
|
||||
'total_violations' => Violation::count(),
|
||||
'total_students' => Student::where('is_active', true)->count(),
|
||||
'active_sp' => SpLetter::where('status', 'draft')->count(),
|
||||
'unhandled_violations' => Violation::where('handling_status', 'unhandled')->count(),
|
||||
'in_progress_violations' => Violation::where('handling_status', 'in_progress')->count(),
|
||||
'resolved_violations' => Violation::where('handling_status', 'resolved')->count(),
|
||||
'today_violations' => Violation::when($scoped, fn ($q) => $q->whereIn('student_id', $scopedStudentIds))->whereDate('violation_date', $today)->count(),
|
||||
'total_violations' => Violation::when($scoped, fn ($q) => $q->whereIn('student_id', $scopedStudentIds))->count(),
|
||||
'total_students' => Student::where('is_active', true)->when($scoped, fn ($q) => $q->whereIn('class_id', $scopedClassIds))->count(),
|
||||
'active_sp' => SpLetter::where('status', 'draft')->when($scoped, fn ($q) => $q->whereHas('student', fn ($qq) => $qq->whereIn('class_id', $scopedClassIds)))->count(),
|
||||
'unhandled_violations' => Violation::when($scoped, fn ($q) => $q->whereIn('student_id', $scopedStudentIds))->where('handling_status', 'unhandled')->count(),
|
||||
'in_progress_violations' => Violation::when($scoped, fn ($q) => $q->whereIn('student_id', $scopedStudentIds))->where('handling_status', 'in_progress')->count(),
|
||||
'resolved_violations' => Violation::when($scoped, fn ($q) => $q->whereIn('student_id', $scopedStudentIds))->where('handling_status', 'resolved')->count(),
|
||||
];
|
||||
|
||||
$recentViolations = Violation::with(['student', 'violationType.category', 'recorder'])
|
||||
->when($scoped, fn ($q) => $q->whereIn('student_id', $scopedStudentIds))
|
||||
->latest()
|
||||
->take(10)
|
||||
->get();
|
||||
|
||||
$topStudents = Student::where('is_active', true)
|
||||
->when($scoped, fn ($q) => $q->whereIn('class_id', $scopedClassIds))
|
||||
->withSum('violations', 'points')
|
||||
->orderByDesc('violations_sum_points')
|
||||
->take(5)
|
||||
@@ -55,14 +63,66 @@ class DashboardController extends Controller
|
||||
|
||||
// Data untuk calendar: pelanggaran per tanggal di bulan ini
|
||||
$calendarData = Violation::selectRaw('DATE(violation_date) as date, COUNT(*) as count')
|
||||
->when($scoped, fn ($q) => $q->whereIn('student_id', $scopedStudentIds))
|
||||
->whereMonth('violation_date', now()->month)
|
||||
->whereYear('violation_date', now()->year)
|
||||
->groupBy('date')
|
||||
->pluck('count', 'date');
|
||||
|
||||
// Tren pelanggaran HARIAN — 7 / 14 / 30 hari terakhir
|
||||
$dailySet = function (int $days) use ($scoped, $scopedStudentIds): array {
|
||||
$labels = [];
|
||||
$data = [];
|
||||
for ($i = $days - 1; $i >= 0; $i--) {
|
||||
$d = now()->subDays($i);
|
||||
$labels[] = $d->format('d/m');
|
||||
$data[] = Violation::when($scoped, fn ($q) => $q->whereIn('student_id', $scopedStudentIds))
|
||||
->whereDate('violation_date', $d->toDateString())->count();
|
||||
}
|
||||
|
||||
return [$labels, $data];
|
||||
};
|
||||
[$dailyLabels7, $dailyData7] = $dailySet(7);
|
||||
[$dailyLabels14, $dailyData14] = $dailySet(14);
|
||||
[$dailyLabels30, $dailyData30] = $dailySet(30);
|
||||
|
||||
$dailyCurrent = $dailyData7[6] ?? 0; // hari ini
|
||||
$dailyPrev = $dailyData7[5] ?? 0; // kemarin
|
||||
$trendPercent = $dailyPrev > 0 ? round((($dailyCurrent - $dailyPrev) / $dailyPrev) * 100) : ($dailyCurrent > 0 ? 100 : 0);
|
||||
$trendTotal7 = array_sum($dailyData7);
|
||||
$trendTotal14 = array_sum($dailyData14);
|
||||
$trendTotal30 = array_sum($dailyData30);
|
||||
|
||||
// Pelanggaran per jenis (grafik batang) — 3 periode
|
||||
$typeCounts = function ($from = null, $month = null) use ($scoped, $scopedStudentIds) {
|
||||
$q = Violation::query();
|
||||
if ($scoped) $q->whereIn('student_id', $scopedStudentIds);
|
||||
if ($from) $q->whereDate('violation_date', '>=', $from);
|
||||
if ($month) $q->whereMonth('violation_date', $month)->whereYear('violation_date', now()->year);
|
||||
return $q->selectRaw('violation_type_id, COUNT(*) as jml')
|
||||
->groupBy('violation_type_id')
|
||||
->pluck('jml', 'violation_type_id');
|
||||
};
|
||||
$typeMapToday = $typeCounts(now()->toDateString());
|
||||
$typeMapWeek = $typeCounts(now()->subDays(7)->toDateString());
|
||||
$typeMapMonth = $typeCounts(null, now()->month);
|
||||
$typeNames = [];
|
||||
$typeToday = [];
|
||||
$typeWeek = [];
|
||||
$typeMonth = [];
|
||||
foreach (\App\Models\ViolationType::where('is_active', true)->orderBy('name')->get() as $t) {
|
||||
$typeNames[] = $t->name;
|
||||
$typeToday[] = $typeMapToday[$t->id] ?? 0;
|
||||
$typeWeek[] = $typeMapWeek[$t->id] ?? 0;
|
||||
$typeMonth[] = $typeMapMonth[$t->id] ?? 0;
|
||||
}
|
||||
|
||||
return view('dashboard.index', compact(
|
||||
'stats', 'recentViolations', 'topStudents',
|
||||
'unreadNotifications', 'notificationCount', 'spThresholds', 'calendarData'
|
||||
'unreadNotifications', 'notificationCount', 'spThresholds', 'calendarData',
|
||||
'dailyLabels7', 'dailyData7', 'dailyLabels14', 'dailyData14', 'dailyLabels30', 'dailyData30',
|
||||
'dailyCurrent', 'trendPercent', 'trendTotal7', 'trendTotal14', 'trendTotal30',
|
||||
'typeNames', 'typeToday', 'typeWeek', 'typeMonth'
|
||||
));
|
||||
}
|
||||
|
||||
@@ -72,6 +132,8 @@ class DashboardController extends Controller
|
||||
$month = $request->input('month', now()->month);
|
||||
|
||||
$data = Violation::selectRaw('DATE(violation_date) as date, COUNT(*) as count')
|
||||
->when(auth()->user()->isScopedWaliKelas(), fn ($q) => $q->whereIn('student_id',
|
||||
Student::whereIn('class_id', auth()->user()->homeroomClassIds())->pluck('id')->all()))
|
||||
->whereYear('violation_date', $year)
|
||||
->whereMonth('violation_date', $month)
|
||||
->groupBy('date')
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Student;
|
||||
use App\Services\FaceRecognitionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Face ID — alur face-first:
|
||||
* - scan: foto wajah -> sistem kenali siswa -> tambah pelanggaran
|
||||
* - register: daftarkan wajah ke siswa yang sudah ada di database
|
||||
*
|
||||
* Microservice hanya ASISTEN. Bila FaceID mati / siswa belum terdaftar,
|
||||
* alur manual tetap berfungsi penuh.
|
||||
*/
|
||||
class FaceIdController extends Controller
|
||||
{
|
||||
public function __construct(private FaceRecognitionService $faceid) {}
|
||||
|
||||
public function scan(): View
|
||||
{
|
||||
return view('face.scan', [
|
||||
'faceidConfigured' => $this->faceid->isConfigured(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function scanVerify(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'photo' => ['required', 'image', 'mimes:jpeg,png,jpg,webp', 'max:5120'],
|
||||
]);
|
||||
|
||||
if (! $this->faceid->isConfigured()) {
|
||||
return response()->json(['ok' => false, 'error' => 'faceid_not_configured']);
|
||||
}
|
||||
|
||||
$result = $this->faceid->verify($request->file('photo')->getRealPath());
|
||||
if (! $result['ok']) {
|
||||
return response()->json(['ok' => false, 'error' => $result['error'] ?? 'faceid_error']);
|
||||
}
|
||||
|
||||
$data = $result['data'];
|
||||
$candidates = [];
|
||||
foreach ($data['top_candidates'] ?? [] as $c) {
|
||||
$student = Student::find($c['student_id']);
|
||||
if (! $student) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidates[] = [
|
||||
'id' => $student->id,
|
||||
'full_name' => $student->full_name,
|
||||
'nisn' => $student->nisn,
|
||||
'class_name' => $student->class_name,
|
||||
'score' => $c['score'],
|
||||
'total_points' => (int) $student->violations()->sum('points'),
|
||||
'recent_violations' => $student->violations()
|
||||
->with('violationType:id,name')
|
||||
->latest('violation_date')
|
||||
->take(3)
|
||||
->get(['id', 'violation_date', 'violation_type_id', 'points'])
|
||||
->map(fn ($v) => [
|
||||
'date' => $v->violation_date?->format('d/m/Y'),
|
||||
'type' => $v->violationType?->name ?? '—',
|
||||
'points' => $v->points,
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'matched' => $data['matched'] ?? false,
|
||||
'ambiguous' => $data['ambiguous'] ?? false,
|
||||
'reason' => $data['reason'] ?? 'tidak_cocok',
|
||||
'candidates' => $candidates,
|
||||
]);
|
||||
}
|
||||
|
||||
public function register(Request $request): View
|
||||
{
|
||||
$query = Student::where('is_active', true);
|
||||
|
||||
$q = trim((string) $request->get('q', ''));
|
||||
if ($q !== '') {
|
||||
$query->where(fn ($qq) => $qq->where('full_name', 'like', "%{$q}%")
|
||||
->orWhere('nisn', 'like', "%{$q}%")
|
||||
->orWhere('student_number', 'like', "%{$q}%"));
|
||||
}
|
||||
|
||||
$students = $query->orderBy('full_name')->paginate(20)->withQueryString();
|
||||
$enrolledMap = $this->faceid->enrolledMap();
|
||||
|
||||
$selected = null;
|
||||
if ($request->has('student_id')) {
|
||||
$selected = Student::find($request->integer('student_id'));
|
||||
}
|
||||
|
||||
return view('face.register', [
|
||||
'students' => $students,
|
||||
'enrolledMap' => $enrolledMap,
|
||||
'selected' => $selected,
|
||||
'q' => $q,
|
||||
'faceidConfigured' => $this->faceid->isConfigured(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function registerStore(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'student_id' => ['required', 'exists:students,id'],
|
||||
'photos' => ['required', 'array', 'min:1', 'max:3'],
|
||||
'photos.*' => ['image', 'mimes:jpeg,png,jpg,webp', 'max:5120'],
|
||||
]);
|
||||
|
||||
$student = Student::findOrFail($request->integer('student_id'));
|
||||
$result = $this->enrollPhotos($student, $request->file('photos') ?? []);
|
||||
|
||||
if (! $result['ok']) {
|
||||
return back()->with('error', 'Registrasi wajah gagal: '.($result['error'] ?? 'unknown'))
|
||||
->withInput();
|
||||
}
|
||||
|
||||
$message = "Wajah {$student->full_name} berhasil terdaftar ({$result['accepted']} foto diterima).";
|
||||
if ($result['rejected']) {
|
||||
$reasons = collect($result['rejected'])->pluck('reason')->unique()->implode(', ');
|
||||
$message .= " Foto ditolak: {$reasons} — ulangi pengambilan foto.";
|
||||
}
|
||||
|
||||
return redirect()->route('face.register', ['student_id' => $student->id, 'q' => $request->input('q')])
|
||||
->with('success', $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrasi cepat dari hasil scan (foto yang sama dipakai untuk enroll).
|
||||
* Dipanggil dari kartu "Siswa Belum Terdaftar" di halaman scan.
|
||||
*/
|
||||
public function registerQuick(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'student_id' => ['required', 'exists:students,id'],
|
||||
'photo' => ['required', 'image', 'mimes:jpeg,png,jpg,webp', 'max:5120'],
|
||||
]);
|
||||
|
||||
$student = Student::findOrFail($request->integer('student_id'));
|
||||
$result = $this->enrollPhotos($student, [$request->file('photo')]);
|
||||
|
||||
if (! $result['ok']) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'error' => $result['error'] ?? 'enroll_failed',
|
||||
'detail' => $result['rejected'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'name' => $student->full_name,
|
||||
'photos_accepted' => $result['accepted'],
|
||||
'rejected' => $result['rejected'],
|
||||
]);
|
||||
}
|
||||
|
||||
/** Kirim foto ke microservice (logika bersama registerStore & registerQuick). */
|
||||
private function enrollPhotos(Student $student, array $photos): array
|
||||
{
|
||||
if (! $this->faceid->isConfigured()) {
|
||||
return ['ok' => false, 'error' => 'faceid_not_configured', 'rejected' => []];
|
||||
}
|
||||
|
||||
$tmpPaths = [];
|
||||
foreach ($photos as $photo) {
|
||||
if (! $photo || ! $photo->isValid()) {
|
||||
continue;
|
||||
}
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'faceid_');
|
||||
file_put_contents($tmp, file_get_contents($photo->getRealPath()));
|
||||
$tmpPaths[] = $tmp;
|
||||
}
|
||||
|
||||
if (! $tmpPaths) {
|
||||
return ['ok' => false, 'error' => 'no_valid_photo', 'rejected' => []];
|
||||
}
|
||||
|
||||
$result = $this->faceid->enroll($student->id, $student->full_name, $tmpPaths, null);
|
||||
|
||||
foreach ($tmpPaths as $tmp) {
|
||||
@unlink($tmp);
|
||||
}
|
||||
|
||||
if (! $result['ok']) {
|
||||
return ['ok' => false, 'error' => $result['error'] ?? 'unknown', 'rejected' => []];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'accepted' => $result['data']['photos_accepted'] ?? 0,
|
||||
'rejected' => $result['data']['photos_rejected'] ?? [],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ class SpLetterController extends Controller
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$letters = SpLetter::with(['student', 'spThreshold', 'generator'])
|
||||
->when(auth()->user()->isScopedWaliKelas(), fn ($q) => $q->whereHas('student', fn ($qq) => $qq->whereIn('class_id', auth()->user()->homeroomClassIds())))
|
||||
->latest()
|
||||
->paginate(20);
|
||||
|
||||
@@ -21,6 +22,8 @@ class SpLetterController extends Controller
|
||||
|
||||
public function show(SpLetter $spLetter): View
|
||||
{
|
||||
abort_unless(auth()->user()->canViewStudent($spLetter->student_id), 403);
|
||||
|
||||
$spLetter->load(['student', 'spThreshold', 'generator']);
|
||||
$school = [
|
||||
'name' => Setting::getValue('school_name', 'SMK'),
|
||||
@@ -35,6 +38,8 @@ class SpLetterController extends Controller
|
||||
|
||||
public function print(SpLetter $spLetter)
|
||||
{
|
||||
abort_unless(auth()->user()->canViewStudent($spLetter->student_id), 403);
|
||||
|
||||
$spLetter->load(['student', 'spThreshold', 'generator']);
|
||||
$school = [
|
||||
'name' => Setting::getValue('school_name', 'SMK'),
|
||||
|
||||
@@ -14,7 +14,8 @@ class StudentReportController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = Student::where('is_active', true);
|
||||
$query = Student::where('is_active', true)
|
||||
->when(auth()->user()->isScopedWaliKelas(), fn ($q) => $q->whereIn('class_id', auth()->user()->homeroomClassIds()));
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
@@ -37,15 +38,20 @@ class StudentReportController extends Controller
|
||||
$query->where('department_code', $request->department);
|
||||
}
|
||||
|
||||
$students = $query->withCount(['violations' => fn($q) => $q->whereNull('deleted_at')])
|
||||
->orderBy('class_name')
|
||||
// Default: siswa dengan poin pelanggaran tertinggi di urutan atas
|
||||
$students = $query
|
||||
->withCount(['violations' => fn($q) => $q->whereNull('deleted_at')])
|
||||
->withSum(['violations' => fn($q) => $q->whereNull('deleted_at')], 'points')
|
||||
->orderByDesc('violations_sum_points')
|
||||
->orderBy('full_name')
|
||||
->paginate(20);
|
||||
|
||||
// Data untuk dropdown filter
|
||||
$classLevels = Student::where('is_active', true)->distinct()->pluck('class_level')->sort()->values();
|
||||
$classNames = Student::where('is_active', true)->distinct()->pluck('class_name')->sort()->values();
|
||||
$departments = Student::where('is_active', true)
|
||||
$baseStudents = Student::where('is_active', true)
|
||||
->when(auth()->user()->isScopedWaliKelas(), fn ($q) => $q->whereIn('class_id', auth()->user()->homeroomClassIds()));
|
||||
$classLevels = (clone $baseStudents)->distinct()->pluck('class_level')->sort()->values();
|
||||
$classNames = (clone $baseStudents)->distinct()->pluck('class_name')->sort()->values();
|
||||
$departments = (clone $baseStudents)
|
||||
->selectRaw('DISTINCT department_code, department_name')
|
||||
->whereNotNull('department_code')
|
||||
->where('department_code', '!=', '')
|
||||
@@ -71,6 +77,8 @@ class StudentReportController extends Controller
|
||||
|
||||
public function show(Student $student): View
|
||||
{
|
||||
abort_unless(auth()->user()->canViewStudent($student), 403);
|
||||
|
||||
$student->load(['violations' => function ($q) {
|
||||
$q->with(['violationType.category', 'recorder', 'evidences', 'handlings.participants.user'])
|
||||
->latest()
|
||||
@@ -106,6 +114,8 @@ class StudentReportController extends Controller
|
||||
|
||||
public function update(Request $request, Student $student): RedirectResponse
|
||||
{
|
||||
abort_unless(auth()->user()->canViewStudent($student), 403);
|
||||
|
||||
$validated = $request->validate([
|
||||
'full_name' => ['required', 'string', 'max:255'],
|
||||
'nisn' => ['nullable', 'string', 'max:50'],
|
||||
@@ -115,6 +125,7 @@ class StudentReportController extends Controller
|
||||
'date_of_birth' => ['nullable', 'date'],
|
||||
'address' => ['nullable', 'string', 'max:500'],
|
||||
'phone_number' => ['nullable', 'string', 'max:30'],
|
||||
'parent_phone' => ['nullable', 'string', 'max:30'],
|
||||
'email' => ['nullable', 'string', 'email', 'max:255'],
|
||||
'class_name' => ['nullable', 'string', 'max:100'],
|
||||
'class_level' => ['nullable', 'string', 'max:20'],
|
||||
|
||||
@@ -24,7 +24,13 @@ class ViolationController extends Controller
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = Violation::with(['student', 'violationType.category', 'recorder']);
|
||||
$query = Violation::with(['student', 'violationType.category', 'recorder'])
|
||||
->withCount('notifications');
|
||||
|
||||
// Wali kelas murni → hanya kelas yang diwalikan
|
||||
if (auth()->user()->isScopedWaliKelas()) {
|
||||
$query->whereHas('student', fn ($q) => $q->whereIn('class_id', auth()->user()->homeroomClassIds()));
|
||||
}
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
@@ -66,8 +72,19 @@ class ViolationController extends Controller
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
$students = Student::where('is_active', true)->orderBy('full_name')->get();
|
||||
$violationTypes = ViolationType::with('category')->where('is_active', true)->get();
|
||||
$studentsQuery = Student::where('is_active', true);
|
||||
if (auth()->user()->isScopedWaliKelas()) {
|
||||
$studentsQuery->whereIn('class_id', auth()->user()->homeroomClassIds());
|
||||
}
|
||||
$students = $studentsQuery->orderBy('full_name')->get();
|
||||
$violationTypes = ViolationType::with('category')
|
||||
->where('is_active', true)
|
||||
->orderBy(
|
||||
\App\Models\ViolationCategory::select('sort_order')
|
||||
->whereColumn('violation_categories.id', 'violation_types.category_id')
|
||||
)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Group by category for searchable dropdown
|
||||
$typeGroups = $violationTypes->groupBy(fn($t) => $t->category?->name ?? 'Lainnya')
|
||||
@@ -100,6 +117,9 @@ class ViolationController extends Controller
|
||||
'evidences.*' => ['image', 'mimes:jpeg,png,jpg,gif,webp', 'max:5120'],
|
||||
]);
|
||||
|
||||
// Wali kelas murni hanya boleh mencatat siswa di kelasnya
|
||||
abort_unless(auth()->user()->canViewStudent($validated['student_id']), 403);
|
||||
|
||||
$evidences = $request->file('evidences') ?? [];
|
||||
$created = 0;
|
||||
|
||||
@@ -123,6 +143,8 @@ class ViolationController extends Controller
|
||||
|
||||
public function show(Violation $violation): View
|
||||
{
|
||||
abort_unless(auth()->user()->canViewStudent($violation->student_id), 403);
|
||||
|
||||
$violation->load([
|
||||
'student', 'violationType.category', 'recorder', 'evidences',
|
||||
'handlings.participants.user', 'handlings.creator',
|
||||
@@ -130,7 +152,12 @@ class ViolationController extends Controller
|
||||
|
||||
$users = \App\Models\User::where('is_active', true)->orderBy('name')->get();
|
||||
|
||||
return view('violations.show', compact('violation', 'users'));
|
||||
$handlingTypes = \App\Models\HandlingType::where('is_active', true)
|
||||
->orderBy('sort_order')->orderBy('name')->get();
|
||||
$handlingTypeMap = $handlingTypes->keyBy('name');
|
||||
$handlingRoles = \App\Models\HandlingType::roles();
|
||||
|
||||
return view('violations.show', compact('violation', 'users', 'handlingTypes', 'handlingTypeMap', 'handlingRoles'));
|
||||
}
|
||||
|
||||
public function storeHandling(Request $request, Violation $violation): RedirectResponse
|
||||
@@ -206,6 +233,32 @@ class ViolationController extends Controller
|
||||
->with('success', 'Catatan penanganan berhasil dihapus.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Kirim notifikasi WhatsApp ke orang tua/wali (manual, wa.me).
|
||||
* Mencatat log pengiriman lalu redirect ke WhatsApp dengan pesan terisi.
|
||||
*/
|
||||
public function notifyParentWa(Violation $violation): RedirectResponse
|
||||
{
|
||||
$wa = $violation->buildWaNotification();
|
||||
if (! $wa) {
|
||||
return back()->with('error', 'Nomor HP orang tua/wali belum terisi untuk siswa ini — isi dulu di halaman siswa.');
|
||||
}
|
||||
|
||||
[$phone, $message] = $wa;
|
||||
|
||||
$violation->notifications()->create([
|
||||
'student_id' => $violation->student_id,
|
||||
'channel' => 'whatsapp',
|
||||
'recipient' => $phone,
|
||||
'message' => $message,
|
||||
'status' => 'sent',
|
||||
'user_id' => auth()->id(),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
return redirect()->away('https://wa.me/'.$phone.'?text='.rawurlencode($message));
|
||||
}
|
||||
|
||||
public function verify(Request $request, Violation $violation): RedirectResponse
|
||||
{
|
||||
$violation->update([
|
||||
@@ -233,6 +286,8 @@ class ViolationController extends Controller
|
||||
$q->where('full_name', 'like', "%{$search}%")
|
||||
->orWhere('nisn', 'like', "%{$search}%");
|
||||
})
|
||||
->when(auth()->user()->isScopedWaliKelas(), fn ($q) => $q->whereIn('class_id', auth()->user()->homeroomClassIds()))
|
||||
->withSum('violations', 'points')
|
||||
->orderBy('full_name')
|
||||
->take(20)
|
||||
->get(['id', 'nisn', 'full_name', 'class_name', 'class_level']);
|
||||
|
||||
@@ -22,7 +22,7 @@ class CheckRole
|
||||
}
|
||||
|
||||
foreach ($roles as $role) {
|
||||
if ($user->role === $role) {
|
||||
if ($user->hasRole($role)) {
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,21 +23,15 @@ class PermissionMiddleware
|
||||
}
|
||||
|
||||
// Admin always has all permissions
|
||||
if ($user->role === 'admin') {
|
||||
if ($user->isAdmin()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Cache role permissions (cleared when role_permissions changes)
|
||||
$rolePermissions = Cache::remember('role_permissions:' . $user->role, 3600, function () use ($user) {
|
||||
return \DB::table('role_permissions')
|
||||
->join('permissions', 'role_permissions.permission_id', '=', 'permissions.id')
|
||||
->where('role_permissions.role', $user->role)
|
||||
->pluck('permissions.key')
|
||||
->toArray();
|
||||
});
|
||||
// Gabungkan permission dari SEMUA role user (multi-role)
|
||||
$userPermissions = $user->allPermissions();
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
if (in_array($permission, $rolePermissions)) {
|
||||
if (in_array($permission, $userPermissions)) {
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ class Classes extends Model
|
||||
protected $fillable = [
|
||||
'source_id', 'name', 'level', 'department_code',
|
||||
'department_name', 'academic_year_name', 'is_active',
|
||||
'metadata',
|
||||
'metadata', 'homeroom_teacher_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -26,4 +26,9 @@ class Classes extends Model
|
||||
{
|
||||
return $this->hasMany(Student::class, 'class_id');
|
||||
}
|
||||
|
||||
public function homeroomTeacher()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'homeroom_teacher_id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class FaceVerificationLog extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'student_id',
|
||||
'violation_id',
|
||||
'faceid_matched',
|
||||
'faceid_ambiguous',
|
||||
'faceid_reason',
|
||||
'faceid_score',
|
||||
'photo_hash',
|
||||
'top_candidates',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'faceid_matched' => 'boolean',
|
||||
'faceid_ambiguous' => 'boolean',
|
||||
'faceid_score' => 'float',
|
||||
'top_candidates' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function student(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Student::class);
|
||||
}
|
||||
|
||||
public function violation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Violation::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class HandlingType extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name', 'icon', 'color', 'sort_order', 'is_active', 'is_system',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
'is_system' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Daftar peran penanganan (dari settings, dengan default).
|
||||
*/
|
||||
public static function roles(): array
|
||||
{
|
||||
$defaults = ['Wakil Ketua Tim', 'Anggota Tim', 'Wali Kelas', 'Guru BK', 'Saksi'];
|
||||
$raw = \App\Models\Setting::getValue('handling_roles');
|
||||
if (!$raw) {
|
||||
return $defaults;
|
||||
}
|
||||
$roles = json_decode($raw, true);
|
||||
return is_array($roles) && count($roles) ? $roles : $defaults;
|
||||
}
|
||||
|
||||
public static function saveRoles(array $roles): void
|
||||
{
|
||||
$roles = array_values(array_filter(array_map('trim', $roles)));
|
||||
\App\Models\Setting::setValue('handling_roles', json_encode($roles), 'general', 'Daftar peran penanganan');
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ class Student extends Model
|
||||
protected $fillable = [
|
||||
'source_id', 'nisn', 'student_number', 'full_name',
|
||||
'gender', 'place_of_birth', 'date_of_birth', 'address',
|
||||
'phone_number', 'email', 'class_name', 'class_level',
|
||||
'phone_number', 'parent_phone', 'email', 'class_name', 'class_level',
|
||||
'department_code', 'department_name', 'academic_year_name',
|
||||
'status', 'photo_path', 'class_id', 'is_active',
|
||||
'metadata', 'synced_at',
|
||||
|
||||
@@ -10,8 +10,10 @@ class User extends Authenticatable
|
||||
{
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
public const ROLES = ['admin', 'bk', 'wali_kelas', 'staff', 'other', 'kepala_sekolah', 'waka_kesiswaan', 'ketua_tim'];
|
||||
|
||||
protected $fillable = [
|
||||
'name', 'username', 'email', 'password', 'role', 'is_active',
|
||||
'name', 'username', 'email', 'password', 'role', 'roles', 'is_active',
|
||||
];
|
||||
|
||||
protected $hidden = ['password', 'remember_token'];
|
||||
@@ -22,29 +24,111 @@ class User extends Authenticatable
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'is_active' => 'boolean',
|
||||
'roles' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function isAdmin(): bool { return $this->role === 'admin'; }
|
||||
public function isBK(): bool { return $this->role === 'bk'; }
|
||||
public function isWaliKelas(): bool { return $this->role === 'wali_kelas'; }
|
||||
public function isStaff(): bool { return $this->role === 'staff'; }
|
||||
public function isOther(): bool { return $this->role === 'other'; }
|
||||
/**
|
||||
* Daftar role user (multi-role). Kolom `role` lama tetap sebagai fallback.
|
||||
*/
|
||||
public function roleList(): array
|
||||
{
|
||||
$roles = $this->roles ?? [];
|
||||
if (!is_array($roles) || empty($roles)) {
|
||||
return [$this->role];
|
||||
}
|
||||
return array_values(array_unique(array_filter($roles)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Role utama (pertama) — untuk kompatibilitas kolom `role`.
|
||||
*/
|
||||
public function primaryRole(): string
|
||||
{
|
||||
return $this->roleList()[0] ?? $this->role;
|
||||
}
|
||||
|
||||
public function hasRole(string $role): bool
|
||||
{
|
||||
return in_array($role, $this->roleList(), true);
|
||||
}
|
||||
|
||||
public function hasAnyRole(array $roles): bool
|
||||
{
|
||||
return count(array_intersect($roles, $this->roleList())) > 0;
|
||||
}
|
||||
|
||||
public function isAdmin(): bool { return $this->hasRole('admin'); }
|
||||
public function isBK(): bool { return $this->hasRole('bk'); }
|
||||
public function isWaliKelas(): bool { return $this->hasRole('wali_kelas'); }
|
||||
public function isStaff(): bool { return $this->hasRole('staff'); }
|
||||
public function isOther(): bool { return $this->hasRole('other'); }
|
||||
public function isKepalaSekolah(): bool { return $this->hasRole('kepala_sekolah'); }
|
||||
public function isWakaKesiswaan(): bool { return $this->hasRole('waka_kesiswaan'); }
|
||||
public function isKetuaTim(): bool { return $this->hasRole('ketua_tim'); }
|
||||
|
||||
/**
|
||||
* Role dengan visibilitas data GLOBAL (semua kelas/siswa).
|
||||
* Guru ber-role ganda (mis. staff + wali_kelas) masuk kategori ini.
|
||||
*/
|
||||
public function isGlobalRole(): bool
|
||||
{
|
||||
return $this->hasAnyRole(['admin', 'bk', 'staff', 'waka_kesiswaan', 'kepala_sekolah', 'ketua_tim']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wali kelas MURNI (tanpa role global) → datanya dibatasi ke kelas yang diwalikan.
|
||||
*/
|
||||
public function isScopedWaliKelas(): bool
|
||||
{
|
||||
return $this->hasRole('wali_kelas') && ! $this->isGlobalRole();
|
||||
}
|
||||
|
||||
/** ID kelas yang diwalikan ke user ini. */
|
||||
public function homeroomClassIds(): array
|
||||
{
|
||||
return \App\Models\Classes::where('homeroom_teacher_id', $this->id)->pluck('id')->all();
|
||||
}
|
||||
|
||||
/** Boleh tidak user ini mengakses data siswa tertentu? */
|
||||
public function canViewStudent(\App\Models\Student|int $student): bool
|
||||
{
|
||||
if (! $this->isScopedWaliKelas()) {
|
||||
return true;
|
||||
}
|
||||
$classId = is_int($student)
|
||||
? \App\Models\Student::find($student)?->class_id
|
||||
: $student->class_id;
|
||||
|
||||
return in_array($classId, $this->homeroomClassIds(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission dari SEMUA role user (digabung).
|
||||
*/
|
||||
public function allPermissions(): array
|
||||
{
|
||||
$perms = [];
|
||||
foreach ($this->roleList() as $role) {
|
||||
$perms = array_merge($perms, \Illuminate\Support\Facades\Cache::remember(
|
||||
'role_permissions:' . $role,
|
||||
3600,
|
||||
fn () => \DB::table('role_permissions')
|
||||
->join('permissions', 'role_permissions.permission_id', '=', 'permissions.id')
|
||||
->where('role_permissions.role', $role)
|
||||
->pluck('permissions.key')
|
||||
->toArray()
|
||||
));
|
||||
}
|
||||
return array_values(array_unique($perms));
|
||||
}
|
||||
|
||||
public function canPermission(string $permission): bool
|
||||
{
|
||||
if ($this->isAdmin()) {
|
||||
return true;
|
||||
}
|
||||
return in_array($permission, \Illuminate\Support\Facades\Cache::remember(
|
||||
'role_permissions:' . $this->role,
|
||||
3600,
|
||||
fn () => \DB::table('role_permissions')
|
||||
->join('permissions', 'role_permissions.permission_id', '=', 'permissions.id')
|
||||
->where('role_permissions.role', $this->role)
|
||||
->pluck('permissions.key')
|
||||
->toArray()
|
||||
));
|
||||
return in_array($permission, $this->allPermissions(), true);
|
||||
}
|
||||
|
||||
public function recordedViolations()
|
||||
|
||||
@@ -56,6 +56,63 @@ class Violation extends Model
|
||||
return $this->hasMany(ViolationEvidence::class);
|
||||
}
|
||||
|
||||
public function notifications(): HasMany
|
||||
{
|
||||
return $this->hasMany(ViolationNotification::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bangun notifikasi WA untuk orang tua/wali.
|
||||
*
|
||||
* @return array{0: string, 1: string}|null [nomor_wa, pesan] — null bila HP ortu belum diisi
|
||||
*/
|
||||
public function buildWaNotification(): ?array
|
||||
{
|
||||
$student = $this->student;
|
||||
if (! $student || ! $student->parent_phone) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalisasi nomor Indonesia: 08xx / 8xx → 628xx
|
||||
$phone = preg_replace('/\D/', '', $student->parent_phone);
|
||||
if (str_starts_with($phone, '0')) {
|
||||
$phone = '62'.substr($phone, 1);
|
||||
} elseif (str_starts_with($phone, '8')) {
|
||||
$phone = '62'.$phone;
|
||||
}
|
||||
if (strlen($phone) < 10) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$template = trim((string) \App\Models\Setting::getValue('wa_notification_template', ''));
|
||||
if ($template === '') {
|
||||
$template = "Yth. Bapak/Ibu Wali dari {nama_siswa} ({kelas}),\n\n"
|
||||
."Dengan hormat, kami informasikan bahwa ananda tercatat melakukan pelanggaran:\n"
|
||||
."• Jenis: {jenis}\n"
|
||||
."• Tanggal: {tanggal}\n"
|
||||
."• Poin: {poin}\n\n"
|
||||
."Total poin ananda saat ini: {total_poin}.\n\n"
|
||||
."Demikian disampaikan, atas perhatian dan kerja samanya kami ucapkan terima kasih.\n\n"
|
||||
."Tim Ketertiban\n{sekolah}";
|
||||
}
|
||||
|
||||
$placeholders = [
|
||||
'{nama_siswa}' => $student->full_name,
|
||||
'{nisn}' => $student->nisn,
|
||||
'{kelas}' => $student->class_name,
|
||||
'{jenis}' => $this->violationType?->name ?? '-',
|
||||
'{tanggal}' => $this->violation_date ? $this->violation_date->format('d/m/Y') : '-',
|
||||
'{waktu}' => $this->violation_time ? $this->violation_time->format('H:i') : '-',
|
||||
'{poin}' => (string) $this->points,
|
||||
'{total_poin}' => (string) ($student->total_points ?? 0),
|
||||
'{lokasi}' => $this->location ?: '-',
|
||||
'{deskripsi}' => $this->description ?: '-',
|
||||
'{sekolah}' => (string) \App\Models\Setting::getValue('school_name', 'SMKN 1 Wonorejo'),
|
||||
];
|
||||
|
||||
return [$phone, strtr($template, $placeholders)];
|
||||
}
|
||||
|
||||
public function handlings(): HasMany
|
||||
{
|
||||
return $this->hasMany(ViolationHandling::class);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ViolationNotification extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'violation_id',
|
||||
'student_id',
|
||||
'channel',
|
||||
'recipient',
|
||||
'message',
|
||||
'status',
|
||||
'user_id',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function violation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Violation::class);
|
||||
}
|
||||
|
||||
public function student(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Student::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Klien microservice deteksi wajah (FastAPI + InsightFace).
|
||||
*
|
||||
* Prinsip: microservice adalah ASISTEN petugas, bukan hakim.
|
||||
* Bila microservice mati / timeout -> method mengembalikan ['ok' => false, 'error' => 'faceid_down'],
|
||||
* dan pemanggil WAJIB punya fallback manual (form tetap berfungsi tanpa verifikasi wajah).
|
||||
*/
|
||||
class FaceRecognitionService
|
||||
{
|
||||
private string $baseUrl;
|
||||
private string $apiKey;
|
||||
private int $timeout;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->baseUrl = rtrim((string) config('services.faceid.base_url', ''), '/');
|
||||
$this->apiKey = (string) config('services.faceid.api_key', '');
|
||||
$this->timeout = (int) config('services.faceid.timeout', 3);
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->baseUrl !== '' && $this->apiKey !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Daftar siswa yang sudah terdaftar di microservice (embedding count).
|
||||
*
|
||||
* @return array<string, int> student_id => embedding_count
|
||||
*/
|
||||
public function enrolledMap(): array
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$resp = Http::timeout($this->timeout)
|
||||
->withHeaders(['X-API-Key' => $this->apiKey])
|
||||
->get($this->baseUrl.'/students');
|
||||
|
||||
if (! $resp->successful()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$map = [];
|
||||
foreach ($resp->json('students') ?? [] as $s) {
|
||||
$map[(string) $s['student_id']] = (int) $s['embedding_count'];
|
||||
}
|
||||
|
||||
return $map;
|
||||
} catch (ConnectionException $e) {
|
||||
Log::warning('FaceID students gagal: '.$e->getMessage());
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifikasi satu foto -> 3 kandidat teratas + skor + flag ambiguous.
|
||||
*
|
||||
* @return array{ok: bool, data?: array, error?: string}
|
||||
*/
|
||||
public function verify(string $photoPath): array
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
return ['ok' => false, 'error' => 'faceid_not_configured'];
|
||||
}
|
||||
|
||||
try {
|
||||
$resp = Http::timeout($this->timeout)
|
||||
->withHeaders(['X-API-Key' => $this->apiKey])
|
||||
->attach('photo', fopen($photoPath, 'r'), basename($photoPath))
|
||||
->post($this->baseUrl.'/verify');
|
||||
|
||||
if ($resp->successful()) {
|
||||
return ['ok' => true, 'data' => $resp->json()];
|
||||
}
|
||||
|
||||
Log::warning('FaceID verify HTTP '.$resp->status().': '.substr($resp->body(), 0, 300));
|
||||
|
||||
return ['ok' => false, 'error' => 'faceid_http_'.$resp->status()];
|
||||
} catch (ConnectionException $e) {
|
||||
Log::warning('FaceID tidak terjangkau, fallback manual: '.$e->getMessage());
|
||||
|
||||
return ['ok' => false, 'error' => 'faceid_down'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enroll embedding siswa dari 1-5 foto.
|
||||
*
|
||||
* @param array<string> $photoPaths
|
||||
* @return array{ok: bool, data?: array, error?: string}
|
||||
*/
|
||||
public function enroll(string $studentId, string $name, array $photoPaths, ?string $twinGroup = null): array
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
return ['ok' => false, 'error' => 'faceid_not_configured'];
|
||||
}
|
||||
|
||||
try {
|
||||
$request = Http::timeout($this->timeout * 3)
|
||||
->withHeaders(['X-API-Key' => $this->apiKey])
|
||||
->asMultipart();
|
||||
|
||||
foreach ($photoPaths as $path) {
|
||||
$request->attach('photos', fopen($path, 'r'), basename($path));
|
||||
}
|
||||
|
||||
$resp = $request->post($this->baseUrl.'/enroll', [
|
||||
'student_id' => $studentId,
|
||||
'name' => $name,
|
||||
'twin_group' => $twinGroup ?? '',
|
||||
]);
|
||||
|
||||
if ($resp->successful()) {
|
||||
return ['ok' => true, 'data' => $resp->json()];
|
||||
}
|
||||
|
||||
return ['ok' => false, 'error' => 'faceid_http_'.$resp->status(), 'detail' => $resp->json()];
|
||||
} catch (ConnectionException $e) {
|
||||
Log::warning('FaceID enroll gagal (timeout/down): '.$e->getMessage());
|
||||
|
||||
return ['ok' => false, 'error' => 'faceid_down'];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -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" }
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 101 KiB |
@@ -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;
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
|
||||
@@ -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)',
|
||||
])
|
||||
|
||||
@@ -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)',
|
||||
])
|
||||
@@ -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,
|
||||
])
|
||||
@@ -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,
|
||||
])
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'];
|
||||
|
||||
@@ -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 --}}
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||