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

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