This commit is contained in:
HelgaFaisa 2026-03-02 10:17:09 +07:00
parent 7586a2beb5
commit 7ce8586b8c
345 changed files with 39070 additions and 21896 deletions

249
DOKUMENTASI_RBAC.md Normal file
View File

@ -0,0 +1,249 @@
# Dokumentasi RBAC (Role-Based Access Control) — SIM-PKPPS
## 1. Ringkasan
Sistem RBAC telah diimplementasikan untuk memisahkan hak akses 3 jenis admin:
| Role | Deskripsi |
|------|-----------|
| **super_admin** | Akses penuh ke semua fitur, termasuk keuangan & SPP |
| **akademik** | Fokus data akademik: santri, kelas, kegiatan, materi, pelanggaran, berita |
| **pamong** | Fokus pengasuhan: uang saku, absensi, kesehatan, kepulangan |
Role lain (`santri`, `wali`) tidak terpengaruh — tetap berjalan seperti sebelumnya.
---
## 2. Akun Test
| Role | Username / Email | Password |
|------|-----------------|----------|
| super_admin | `helga.faisa06@gmail.com` | `12345678` |
| akademik | `akademik@test.com` | `password123` |
| pamong | `pamong@test.com` | `password123` |
Login di: **http://127.0.0.1:8000/admin/login**
> **Penting:** Jika mengalami redirect loop di browser, bersihkan cookies terlebih dahulu (Ctrl+Shift+Delete → Cookies).
---
## 3. Matriks Hak Akses
### Legenda
- ✅ = Akses penuh (CRUD)
- 👁 = Hanya lihat (Read Only)
- ❌ = Tidak bisa akses
| Fitur | super_admin | akademik | pamong |
|-------|:-----------:|:--------:|:------:|
| **Dashboard** | ✅ (semua data) | ✅ (tanpa SPP & uang saku) | ✅ (tanpa SPP) |
| **Data Santri** | ✅ | ✅ | 👁 |
| **Kelas & Kelompok** | ✅ | ✅ | ❌ |
| **Kenaikan Kelas** | ✅ | ✅ | ❌ |
| **Kegiatan** | ✅ | ✅ | 👁 |
| **Jadwal Kegiatan** | ✅ | ✅ | 👁 |
| **Absensi Kegiatan** | ✅ | ✅ | ✅ |
| **Kartu RFID** | ✅ | ✅ | ❌ |
| **Capaian Santri** | ✅ | ✅ | ✅ |
| **Materi & Semester** | ✅ | ✅ | ❌ |
| **Pelanggaran** | ✅ | ✅ | ❌ |
| **Pembinaan & Sanksi** | ✅ | ✅ | ❌ |
| **Berita** | ✅ | ✅ | ❌ |
| **Kategori Kegiatan** | ✅ | ✅ | ❌ |
| **Rekap Absensi** | ✅ | ✅ | ❌ |
| **Riwayat Kegiatan** | ✅ | ✅ | ❌ |
| **Laporan Kegiatan** | ✅ | ✅ | ❌ |
| **Kesehatan Santri** | ✅ | ✅ | ✅ |
| **Kepulangan** | ✅ | ✅ | ✅ |
| **Uang Saku** | ✅ | ❌ | ✅ |
| **Keuangan Pondok** | ✅ | ❌ | ❌ |
| **Pembayaran SPP** | ✅ | ❌ | ❌ |
| **Manajemen User (santri/wali)** | ✅ | ❌ | ❌ |
| **Manajemen Akun Admin** | ✅ | ❌ | ❌ |
---
## 4. File yang Dimodifikasi / Dibuat
### Migration
| File | Status |
|------|--------|
| `database/migrations/2026_02_24_000001_update_users_role_enum.php` | **BARU** — Migrasi enum role |
### Model
| File | Perubahan |
|------|-----------|
| `app/Models/User.php` | Tambah method: `isSuperAdmin()`, `isAkademik()`, `isPamong()`, `hasRole(...$roles)`. Update `isAdmin()` |
### Middleware
| File | Perubahan |
|------|-----------|
| `app/Http/Middleware/Role.php` | **FIX KRITIS**: Signature `string $roles``string ...$roles` (variadic). Hapus `explode()`. Redirect by role. |
| `app/Http/Middleware/RedirectIfAuthenticated.php` | Redirect sesuai role (admin→admin.dashboard, santri→santri.dashboard) |
| `app/Http/Middleware/Authenticate.php` | Dibersihkan (debug log dihapus) |
| `app/Http/Kernel.php` | Disable `AuthenticateSession` dan `ClearStuckSession` dari web middleware group |
### Controller
| File | Perubahan |
|------|-----------|
| `app/Http/Controllers/Auth/AdminAuthController.php` | Register default = `super_admin`. Hapus session invalidation sebelum login. |
| `app/Http/Controllers/DashboardController.php` | Data dashboard kondisional per role. Fix nama kolom uang_saku. |
| `app/Http/Controllers/Admin/UserController.php` | Tambah 6 method CRUD untuk akun admin (akademik/pamong) |
### Routes
| File | Perubahan |
|------|-----------|
| `routes/web.php` | Restrukturisasi menjadi 5 middleware group berdasarkan role |
### Views
| File | Status |
|------|--------|
| `resources/views/layouts/admin-sidebar.blade.php` | **REWRITE** — Menu kondisional per role |
| `resources/views/admin/dashboardAdmin.blade.php` | Update — Seksi SPP/uang saku kondisional |
| `resources/views/admin/dashboard/_kpi-cards.blade.php` | Update — KPI "Belum Ada Wali" hanya super_admin |
| `resources/views/admin/dashboard/_alert-panel.blade.php` | Update — Alert SPP hanya super_admin |
| `resources/views/layouts/app.blade.php` | Update — `isAdmin()` menggantikan `role === 'admin'` |
| `resources/views/admin/users/admin_accounts.blade.php` | **BARU** — Daftar akun admin |
| `resources/views/admin/users/admin_form.blade.php` | **BARU** — Form create/edit akun admin |
---
## 5. Langkah-Langkah yang Dilakukan
### Langkah 1: Migrasi Database
```bash
php artisan migrate
```
Migrasi mengubah enum `role` di tabel `users`:
- **Sebelum:** `admin`, `santri`, `wali`
- **Sesudah:** `super_admin`, `akademik`, `pamong`, `santri`, `wali`
- Semua user yang sebelumnya `admin` otomatis menjadi `super_admin`.
### Langkah 2: Update Model User
Ditambahkan helper method di `User.php`:
```php
public function isSuperAdmin() { return $this->role === 'super_admin'; }
public function isAkademik() { return $this->role === 'akademik'; }
public function isPamong() { return $this->role === 'pamong'; }
public function isAdmin() { return in_array($this->role, ['super_admin', 'akademik', 'pamong']); }
public function hasRole() { return in_array($this->role, func_get_args()); }
```
### Langkah 3: Fix Middleware Role (Variadic Parameter)
**Root cause** dari redirect loop: Laravel memanggil middleware `role:super_admin,akademik,pamong` dengan 3 argumen terpisah, bukan 1 string. Signature harus menggunakan **variadic** (`...`):
```php
// SALAH (hanya tangkap argumen pertama):
public function handle(Request $request, Closure $next, string $roles)
// BENAR (tangkap semua argumen):
public function handle(Request $request, Closure $next, string ...$roles)
```
### Langkah 4: Restrukturisasi Routes
`routes/web.php` dibagi menjadi 5 middleware group:
| Group | Middleware | Isi |
|-------|-----------|-----|
| 1 | `role:super_admin,akademik,pamong` | Dashboard, Logout |
| 2 | `role:super_admin` | Keuangan, SPP, Manajemen User |
| 3 | `role:super_admin,akademik` | Santri CUD, Kelas, Kegiatan CUD, Pelanggaran, Berita, dll |
| 4 | `role:super_admin,akademik,pamong` | Santri Read, Kegiatan Read, Absensi, Capaian, Kesehatan, Kepulangan |
| 5 | `role:super_admin,pamong` | Uang Saku |
### Langkah 5: Update Sidebar & Dashboard
- Sidebar menampilkan menu sesuai role user yang login
- Dashboard menampilkan data sesuai hak akses role
### Langkah 6: CRUD Akun Admin
Super admin dapat membuat akun akademik/pamong via UI:
- **URL:** `/admin/users/admin`
- Hanya super_admin yang bisa mengakses
- Tidak bisa membuat akun super_admin baru via UI (untuk keamanan)
---
## 6. Cara Membuat Akun Admin Baru
### Via UI (Recommended)
1. Login sebagai **super_admin**
2. Buka menu **Data Master → Akun Admin**
3. Klik **Tambah Akun Admin**
4. Isi form (email, nama, password, pilih role akademik/pamong)
5. Klik **Simpan**
### Via Tinker (Manual)
```bash
php artisan tinker
```
```php
use App\Models\User;
use Illuminate\Support\Facades\Hash;
User::create([
'name' => 'Nama User',
'email' => 'user@example.com',
'username' => 'user@example.com',
'password' => Hash::make('password123'),
'role' => 'akademik', // atau 'pamong'
]);
```
---
## 7. Troubleshooting
### Redirect Loop (ERR_TOO_MANY_REDIRECTS)
1. **Bersihkan cookies browser** (Ctrl+Shift+Delete → Cookies)
2. Jalankan:
```bash
php artisan cache:clear
php artisan config:clear
php artisan route:clear
```
3. Hapus session files:
```bash
# Di PowerShell:
Remove-Item storage/framework/sessions/* -Force
```
### Error 500 Setelah Login
- Periksa `storage/logs/laravel.log` untuk detail error
- Pastikan semua migrasi sudah dijalankan: `php artisan migrate:status`
### User Tidak Bisa Login
- Pastikan kolom `username` terisi (login menggunakan `username`, bukan `email`)
- Untuk update username yang kosong:
```bash
php artisan tinker
```
```php
User::whereNull('username')->orWhere('username', '')->get()->each(fn($u) => $u->update(['username' => $u->email]));
```
---
## 8. Arsitektur Middleware
```
Request
├─ web middleware group (Kernel.php)
│ ├─ EncryptCookies
│ ├─ AddQueuedCookiesToResponse
│ ├─ StartSession
│ ├─ ShareErrorsFromSession
│ ├─ VerifyCsrfToken
│ └─ SubstituteBindings
├─ auth middleware (Authenticate.php)
│ └─ Redirect ke /admin/login jika belum login
└─ role middleware (Role.php)
└─ Cek apakah user->role termasuk dalam daftar yang diizinkan
├─ Ya → lanjut ke controller
└─ Tidak → redirect ke dashboard dengan pesan error
```
> **Catatan:** `AuthenticateSession` dan `ClearStuckSession` telah di-disable dari web middleware group karena menyebabkan konflik session.

View File

@ -0,0 +1,215 @@
# Dokumentasi Redesign Halaman Jadwal & Absensi Kegiatan
## 🎯 Perubahan yang Dilakukan
### 1. **Halaman Jadwal Kegiatan** (`admin.kegiatan.data.index`)
**Lokasi**: `sim-pkpps/resources/views/admin/kegiatan/data/index.blade.php`
#### Perubahan Tampilan:
**Dari**: Tabel flat dengan filter dropdown di atas
**Ke**: 7 tab horizontal (Senin-Ahad) dengan card grid per hari
#### Fitur Utama:
- **Tab Navigation**: 7 tab horizontal untuk setiap hari dalam seminggu
- **Auto-Select Tab**: Tab hari ini otomatis terpilih saat pertama kali membuka halaman
- **Card Layout**: Kegiatan ditampilkan sebagai card, bukan baris tabel
- **Filter per Tab**: Dropdown filter kelas & kategori di dalam setiap tab
- **Tab Switching JavaScript**: Berpindah tab tanpa reload (URL state preserved dengan `pushState`)
#### Struktur Card Kegiatan:
```
┌─────────────────────────────────┐
│ Nama Kegiatan [Badge] │
│ 🕐 08:00 - 10:00 │
│ [Kelas1] [Kelas2] [+2 lainnya]│
│ 📖 Materi: ... │
│ │
│ [Input Absensi] [Detail] │
└─────────────────────────────────┘
```
#### CSS Responsif:
- Grid auto-fill dengan minimum width 320px
- Horizontal scroll pada tab navigation untuk mobile
- Hover effects & animations (fadeIn, translateY)
---
### 2. **Halaman Input Absensi** (`admin.absensi-kegiatan.index`)
**Lokasi**: `sim-pkpps/resources/views/admin/kegiatan/absensi/index.blade.php`
#### Perubahan Tampilan:
**Dari**: Filter dropdown biasa + tabel list kegiatan
**Ke**: Date picker dengan header tanggal + card grid dengan status badge
#### Fitur Utama:
- **Date Picker Section**: Background gradient hijau dengan header tanggal lengkap
- **Nama Hari Otomatis**: Menampilkan "Jumat, 8 Desember 2024" berdasarkan tanggal dipilih
- **Filter dalam Date Picker**: Kategori & Kelas digabung dalam satu section
- **Status Badge**: Menampilkan "Sudah Input" (hijau) atau "Belum Input" (merah)
- **Progress Bar**: Jika sudah ada data absensi, tampilkan persentase kehadiran
- **Query Otomatis Hari**: Sistem otomatis filter kegiatan berdasarkan hari dari tanggal dipilih
#### Struktur Card Kegiatan:
```
┌─────────────────────────────────┬──────────────┐
│ Nama Kegiatan [Badge] │ [Status] │
│ 🕐 08:00 - 10:00 │ │
│ [Kelas1] [Kelas2] [Kelas3] │ │
│ │ │
│ ┌─────────────────────────────┐│ │
│ │ Kehadiran 15/20 (75%) ││ │
│ │ ████████████░░░░░░░░ ││ │
│ └─────────────────────────────┘│ │
│ │ │
│ [Input Absensi] [Rekap] │ │
└─────────────────────────────────┴──────────────┘
```
#### Logika Backend (View Only):
```php
// Map hari Indonesia ke hari sistem
$hariDipilih = Carbon::parse($tanggal)->locale('id')->isoFormat('dddd');
$hariMap = ['Senin' => 'Senin', 'Minggu' => 'Ahad', ...];
$hariFilter = $hariMap[$hariDipilih] ?? 'Senin';
// Filter kegiatan berdasarkan hari dari tanggal dipilih
$query = $kegiatans->where('hari', $hariFilter);
// Cek apakah sudah ada data absensi
$absensiExists = AbsensiKegiatan::where('kegiatan_id', $kegiatan->kegiatan_id)
->whereDate('tanggal', $tanggal)
->exists();
// Hitung persentase kehadiran
$absensiData = AbsensiKegiatan::where('kegiatan_id', $kegiatan->kegiatan_id)
->whereDate('tanggal', $tanggal)
->get();
$hadirCount = $absensiData->where('status', 'Hadir')->count();
$persenKehadiran = round(($hadirCount / $totalSantri) * 100);
```
---
## 🎨 Palette Warna
| Elemen | Warna | Hex Code |
|--------|-------|----------|
| Primary Green | Eucalyptus Green | `#6FBA9D` |
| Dark Green | Darker Shade | `#5EA98C` |
| Light Green | Background | `#E8F7F2` |
| Page Background | Very Light | `#F8FBF9` |
| Status Sudah (Green) | Success | `#D1FAE5` / `#065F46` |
| Status Belum (Red) | Error | `#FEE2E2` / `#991B1B` |
| Blue Button | Info | `#3B82F6` |
---
## 📦 Tidak Ada Perubahan Controller
**Semua perubahan hanya pada VIEW layer**
✅ Controller logic tetap sama:
- `App\Http\Controllers\Admin\KegiatanController@jadwal` → index.blade.php
- `App\Http\Controllers\Admin\AbsensiKegiatanController@index` → absensi/index.blade.php
✅ Model relationships tetap digunakan:
- `$kegiatan->kelasKegiatan` (many-to-many via `kelas_kegiatan`)
- `$kegiatan->kategori` (belongsTo)
- `AbsensiKegiatan::where()` queries
---
## 🚀 Testing Checklist
### Halaman Jadwal (`/admin/kegiatan/jadwal`)
- [ ] Tab navigation berfungsi (klik untuk switch)
- [ ] Tab hari ini otomatis terpilih
- [ ] Filter kelas & kategori submit dengan GET parameter
- [ ] Card menampilkan semua informasi kegiatan
- [ ] Tombol "Input Absensi" redirect ke input page
- [ ] Tombol "Detail" redirect ke detail page
- [ ] Responsive di mobile (tab horizontal scroll)
### Halaman Absensi (`/admin/absensi-kegiatan`)
- [ ] Date picker default ke hari ini
- [ ] Nama hari + tanggal tampil di header
- [ ] Filter kategori & kelas berfungsi
- [ ] Status badge menampilkan "Sudah Input" / "Belum Input" dengan benar
- [ ] Progress bar muncul jika sudah ada data absensi
- [ ] Persentase kehadiran dihitung dengan benar (hadir/total)
- [ ] Tombol "Input Absensi" membawa parameter `tanggal` dalam URL
- [ ] Tombol "Rekap" redirect ke rekap page
- [ ] Empty state muncul jika tidak ada kegiatan di hari tersebut
---
## 🔧 Teknologi & Dependencies
**View Engine**: Laravel Blade
**Styling**: Inline CSS (no external library)
**JavaScript**: Vanilla JS (tab switching, no jQuery)
**PHP Helpers**: Carbon (date formatting dengan locale Indonesia)
**Icons**: Font Awesome 5
**Browser Compatibility**:
- Chrome/Edge: ✅ Full support
- Firefox: ✅ Full support
- Safari: ✅ CSS Grid supported
- Mobile: ✅ Responsive grid & horizontal scroll
---
## 📝 Catatan Teknis
### URL Parameters yang Digunakan:
**Jadwal**:
```
GET /admin/kegiatan/jadwal?hari=Senin&kelas_id=1&kategori_id=2
```
**Absensi**:
```
GET /admin/absensi-kegiatan?tanggal=2024-12-06&kategori_id=1&id_kelas=2
```
### Mapping Hari Minggu → Ahad:
```php
$hariMap = [
'Senin' => 'Senin',
'Selasa' => 'Selasa',
'Rabu' => 'Rabu',
'Kamis' => 'Kamis',
'Jumat' => 'Jumat',
'Sabtu' => 'Sabtu',
'Minggu' => 'Ahad' // Penting untuk database
];
```
### Animation Classes:
```css
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
```
---
## ✅ Hasil Akhir
- **Jadwal**: Tab-based layout dengan 7 hari, auto-select hari ini, card grid per tab
- **Absensi**: Date picker dengan header tanggal, card dengan status badge & progress bar
- **UI/UX**: Clean, modern, responsif, dengan animasi smooth
- **Performance**: Lightweight CSS tanpa library eksternal
- **Code Quality**: Clean Blade syntax, reusable CSS classes
**Total Files Modified**: 2 files
**Lines Changed**: ~600 lines (redesign complete)
**No Breaking Changes**: Semua route & controller logic tetap sama
---
Dibuat: {{ now()->format('d F Y H:i') }}
Developer: GitHub Copilot
Project: SIM-PKPPS (Sistem Informasi Manajemen Pesantren)

View File

@ -11,7 +11,7 @@ LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel
DB_DATABASE=sim_santri
DB_USERNAME=root
DB_PASSWORD=

View File

@ -0,0 +1,107 @@
<?php
// app/Console/Commands/CleanSantriAccounts.php
namespace App\Console\Commands;
use App\Models\SantriAccount;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
class CleanSantriAccounts extends Command
{
/**
* Nama dan signature command.
*/
protected $signature = 'santri:clean-accounts
{--dry-run : Tampilkan perubahan tanpa menyimpan}';
/**
* Deskripsi command.
*/
protected $description = 'Reset username & password semua akun santri/wali agar konsisten (santri=nama_lengkap, wali=nama_orang_tua, password=NIS)';
/**
* Jalankan command.
*/
public function handle()
{
$dryRun = $this->option('dry-run');
if ($dryRun) {
$this->info('=== DRY RUN MODE (tidak ada perubahan yang disimpan) ===');
}
$accounts = SantriAccount::with('santri')->get();
$updated = 0;
$skipped = 0;
$errors = [];
foreach ($accounts as $account) {
$santri = $account->santri;
// -- Skip jika santri tidak ditemukan --
if (!$santri) {
$errors[] = "Account ID {$account->id} (id_santri={$account->id_santri}): Data santri tidak ditemukan.";
$skipped++;
continue;
}
// -- Skip jika NIS kosong --
if (empty($santri->nis)) {
$errors[] = "Account ID {$account->id} ({$santri->nama_lengkap}): NIS kosong, dilewati.";
$skipped++;
continue;
}
// -- Tentukan username yang benar --
if ($account->role === 'wali') {
$usernameBenar = $santri->nama_orang_tua ?: $santri->nama_lengkap;
} else {
$usernameBenar = $santri->nama_lengkap;
}
// -- Cek apakah username sudah benar --
$usernameChanged = ($account->username !== $usernameBenar);
if ($usernameChanged) {
// -- Pastikan username unik --
$existing = SantriAccount::where('username', $usernameBenar)
->where('id', '!=', $account->id)
->exists();
if ($existing) {
$usernameBenar = $usernameBenar . '_' . $santri->nis;
}
}
if ($usernameChanged) {
$this->line(" [{$account->role}] {$santri->nama_lengkap}: username '{$account->username}' -> '{$usernameBenar}'");
}
if (!$dryRun) {
$account->username = $usernameBenar;
$account->password = Hash::make($santri->nis);
$account->save();
}
$updated++;
}
$this->newLine();
$this->info("Selesai! Updated: {$updated}, Skipped: {$skipped}");
if (count($errors) > 0) {
$this->newLine();
$this->warn('Masalah ditemukan:');
foreach ($errors as $err) {
$this->warn(" - {$err}");
}
}
if ($dryRun) {
$this->newLine();
$this->comment('Jalankan tanpa --dry-run untuk menyimpan perubahan.');
}
return Command::SUCCESS;
}
}

View File

@ -5,6 +5,8 @@
use App\Http\Controllers\Controller;
use App\Models\AbsensiKegiatan;
use App\Models\Kegiatan;
use App\Models\Kelas;
use App\Models\Kepulangan;
use App\Models\Santri;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@ -12,49 +14,11 @@
class AbsensiKegiatanController extends Controller
{
/**
* Daftar kegiatan untuk absensi
* Daftar kegiatan untuk absensi diarahkan ke Dashboard Kegiatan (tidak redundan)
*/
public function index(Request $request)
{
// Query dengan eager loading untuk optimasi
$query = Kegiatan::with(['kategori', 'kelasKegiatan'])
->select('id', 'kegiatan_id', 'kategori_id', 'nama_kegiatan', 'hari', 'waktu_mulai', 'waktu_selesai');
// Filter Hari
if ($request->filled('hari')) {
$query->where('hari', $request->hari);
}
// Filter Kategori
if ($request->filled('kategori_id')) {
$query->where('kategori_id', $request->kategori_id);
}
// Filter Kelas
if ($request->filled('id_kelas')) {
$query->whereHas('kelasKegiatan', function($q) use ($request) {
$q->where('kelas.id', $request->id_kelas);
});
}
// Search
if ($request->filled('search')) {
$search = $request->search;
$query->where(function($q) use ($search) {
$q->where('nama_kegiatan', 'like', "%{$search}%")
->orWhere('kegiatan_id', 'like', "%{$search}%");
});
}
// Pagination dengan 15 item per page
$kegiatans = $query->orderBy('hari')->orderBy('waktu_mulai')->paginate(15)->appends(request()->query());
// Data untuk filter
$hariList = ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu', 'Ahad'];
$kategoris = \App\Models\KategoriKegiatan::select('kategori_id', 'nama_kategori')->get();
$kelasList = \App\Models\Kelas::with('kelompok')->orderBy('urutan')->get();
return view('admin.kegiatan.absensi.index', compact('kegiatans', 'hariList', 'kategoris', 'kelasList'));
return redirect()->route('admin.kegiatan.jadwal');
}
/**
@ -63,42 +27,45 @@ public function index(Request $request)
public function inputAbsensi($kegiatan_id)
{
// Get kegiatan dengan relasi kategori dan kelas
$kegiatan = Kegiatan::with(['kategori', 'kelasKegiatan'])
$kegiatan = Kegiatan::with(['kategori', 'kelasKegiatan.kelompok'])
->where('kegiatan_id', $kegiatan_id)
->firstOrFail();
$tanggal = request('tanggal', now()->format('Y-m-d'));
// Get santri sesuai kelas kegiatan
// Build santri grouped by kegiatan kelas
$santriGrouped = collect();
if ($kegiatan->isForAllClasses()) {
// Kegiatan umum: ambil SEMUA santri aktif
$santris = Santri::where('status', 'Aktif')
->with('kelasSantri.kelas')
// Kegiatan umum: ambil SEMUA santri aktif, group by primary kelas
$allSantris = Santri::where('status', 'Aktif')
->with(['kelasSantri.kelas', 'kelasPrimary.kelas'])
->orderBy('nama_lengkap')
->get();
$santriGrouped = $allSantris->groupBy(function($s) {
$primary = $s->kelasPrimary;
return $primary && $primary->kelas ? $primary->kelas->nama_kelas : 'Tanpa Kelas';
})->sortKeys();
} else {
// Kegiatan khusus: ambil santri yang kelasnya match
$kelasIds = $kegiatan->kelasKegiatan->pluck('id')->toArray();
// Coba ambil santri dari sistem kelas baru
$santris = Santri::where('status', 'Aktif')
->whereHas('kelasSantri', function($query) use ($kelasIds) {
$query->whereIn('id_kelas', $kelasIds);
// Kegiatan khusus: group by kegiatan kelas
foreach ($kegiatan->kelasKegiatan as $kelas) {
$santriInKelas = Santri::where('status', 'Aktif')
->whereHas('kelasSantri', function($q) use ($kelas) {
$q->where('id_kelas', $kelas->id);
})
->with('kelasSantri.kelas')
->with(['kelasSantri.kelas', 'kelasPrimary.kelas'])
->orderBy('nama_lengkap')
->get();
// Fallback: Jika tidak ada santri (belum migrasi), gunakan old column kelas
if ($santris->isEmpty()) {
$kelasNames = $kegiatan->kelasKegiatan->pluck('nama_kelas')->toArray();
$santris = Santri::where('status', 'Aktif')
->whereIn('kelas', $kelasNames)
->with('kelasSantri.kelas')
->orderBy('nama_lengkap')
->get();
if ($santriInKelas->count() > 0) {
$santriGrouped[$kelas->nama_kelas] = $santriInKelas;
}
}
}
// Flatten for total count
$santris = $santriGrouped->flatten()->unique('id_santri');
// Ambil data absensi yang sudah ada
$absensiData = AbsensiKegiatan::where('kegiatan_id', $kegiatan_id)
@ -106,6 +73,13 @@ public function inputAbsensi($kegiatan_id)
->pluck('status', 'id_santri')
->toArray();
// Cek santri yang sedang pulang
$santriSedangPulang = Kepulangan::where('status', 'Disetujui')
->where('tanggal_pulang', '<=', $tanggal)
->where('tanggal_kembali', '>=', $tanggal)
->pluck('id_santri')
->toArray();
// Info kelas kegiatan untuk view
$kegiatanInfo = [
'is_umum' => $kegiatan->isForAllClasses(),
@ -113,24 +87,42 @@ public function inputAbsensi($kegiatan_id)
'jumlah_kelas' => $kegiatan->kelasKegiatan->count(),
];
return view('admin.kegiatan.absensi.input', compact('kegiatan', 'santris', 'absensiData', 'tanggal', 'kegiatanInfo'));
return view('admin.kegiatan.absensi.input', compact('kegiatan', 'santris', 'santriGrouped', 'absensiData', 'tanggal', 'kegiatanInfo', 'santriSedangPulang'));
}
/**
* Simpan absensi manual
* Simpan absensi manual (hanya santri yang dikirim form)
*/
public function simpanAbsensi(Request $request)
{
$validated = $request->validate([
'kegiatan_id' => 'required|exists:kegiatans,kegiatan_id',
'tanggal' => 'required|date',
'absensi' => 'required|array',
'absensi.*' => 'required|in:Hadir,Izin,Sakit,Alpa',
'absensi' => 'nullable|array',
'absensi.*' => 'nullable|in:Hadir,Izin,Sakit,Alpa,Terlambat,Pulang',
]);
// Cek santri yang sedang pulang
$santriSedangPulang = Kepulangan::where('status', 'Disetujui')
->where('tanggal_pulang', '<=', $request->tanggal)
->where('tanggal_kembali', '>=', $request->tanggal)
->pluck('id_santri')
->toArray();
$absensiInput = $request->absensi ?? [];
DB::beginTransaction();
try {
foreach ($request->absensi as $id_santri => $status) {
$saved = 0;
foreach ($absensiInput as $id_santri => $status) {
// Skip jika kosong (santri dilewati)
if (empty($status)) {
continue;
}
// Paksa status Pulang untuk santri yang sedang pulang
$finalStatus = in_array($id_santri, $santriSedangPulang) ? 'Pulang' : $status;
AbsensiKegiatan::updateOrCreate(
[
'kegiatan_id' => $request->kegiatan_id,
@ -138,22 +130,66 @@ public function simpanAbsensi(Request $request)
'tanggal' => $request->tanggal,
],
[
'status' => $status,
'status' => $finalStatus,
'metode_absen' => 'Manual',
'waktu_absen' => now()->format('H:i:s'),
]
);
$saved++;
}
DB::commit();
return redirect()->route('admin.absensi-kegiatan.index')
->with('success', 'Absensi berhasil disimpan.');
return redirect()->route('admin.kegiatan.index')
->with('success', "Absensi berhasil disimpan ({$saved} santri).");
} catch (\Exception $e) {
DB::rollBack();
return back()->with('error', 'Gagal menyimpan absensi: ' . $e->getMessage());
}
}
/**
* Edit single absensi record
*/
public function editAbsensi($id)
{
$absensi = AbsensiKegiatan::with(['santri', 'kegiatan.kategori'])->findOrFail($id);
return view('admin.kegiatan.absensi.edit', compact('absensi'));
}
/**
* Update single absensi record
*/
public function updateAbsensi(Request $request, $id)
{
$absensi = AbsensiKegiatan::findOrFail($id);
$validated = $request->validate([
'status' => 'required|in:Hadir,Izin,Sakit,Alpa,Terlambat,Pulang',
]);
$absensi->update([
'status' => $validated['status'],
'waktu_absen' => now()->format('H:i:s'),
]);
return redirect()->route('admin.absensi-kegiatan.rekap', $absensi->kegiatan_id)
->with('success', 'Status absensi ' . $absensi->santri->nama_lengkap . ' berhasil diperbarui.');
}
/**
* Hapus single absensi record
*/
public function hapusAbsensi($id)
{
$absensi = AbsensiKegiatan::findOrFail($id);
$kegiatanId = $absensi->kegiatan_id;
$nama = $absensi->santri->nama_lengkap;
$absensi->delete();
return redirect()->route('admin.absensi-kegiatan.rekap', $kegiatanId)
->with('success', 'Data absensi ' . $nama . ' berhasil dihapus.');
}
/**
* Rekap absensi kegiatan
*/
@ -161,7 +197,7 @@ public function rekapAbsensi(Request $request, $kegiatan_id)
{
$kegiatan = Kegiatan::with(['kategori', 'kelasKegiatan'])->where('kegiatan_id', $kegiatan_id)->firstOrFail();
$query = AbsensiKegiatan::with('santri')
$query = AbsensiKegiatan::with(['santri.kelasSantri.kelas'])
->where('kegiatan_id', $kegiatan_id);
// Filter tanggal
@ -175,18 +211,61 @@ public function rekapAbsensi(Request $request, $kegiatan_id)
->whereYear('tanggal', date('Y', strtotime($request->bulan)));
}
// Filter kelas
if ($request->filled('kelas_id')) {
$query->whereHas('santri.kelasSantri', function($q) use ($request) {
$q->where('id_kelas', $request->kelas_id);
});
}
$absensis = $query->orderBy('tanggal', 'desc')
->orderBy('waktu_absen', 'desc')
->paginate(20);
->get();
// Build kelas list for filter dropdown
if ($kegiatan->isForAllClasses()) {
$kelasFilterList = Kelas::active()->ordered()->get();
} else {
$kelasFilterList = $kegiatan->kelasKegiatan;
}
// Grup per kelas berdasarkan kegiatan kelas
if ($kegiatan->isForAllClasses()) {
$absensiPerKelas = $absensis->groupBy(function ($item) {
return $item->santri->kelas_name ?? 'Belum Ada Kelas';
})->sortKeys();
} else {
$absensiPerKelas = collect();
foreach ($kegiatan->kelasKegiatan as $kelas) {
$kelasAbsensis = $absensis->filter(function ($item) use ($kelas) {
return $item->santri->kelasSantri->contains('id_kelas', $kelas->id);
});
if ($kelasAbsensis->count() > 0) {
$absensiPerKelas[$kelas->nama_kelas] = $kelasAbsensis;
}
}
}
// Statistik
$stats = AbsensiKegiatan::where('kegiatan_id', $kegiatan_id)
->select('status', DB::raw('count(*) as total'))
$statsQuery = AbsensiKegiatan::where('kegiatan_id', $kegiatan_id);
if ($request->filled('tanggal')) {
$statsQuery->whereDate('tanggal', $request->tanggal);
}
if ($request->filled('bulan')) {
$statsQuery->whereMonth('tanggal', date('m', strtotime($request->bulan)))
->whereYear('tanggal', date('Y', strtotime($request->bulan)));
}
if ($request->filled('kelas_id')) {
$statsQuery->whereHas('santri.kelasSantri', function($q) use ($request) {
$q->where('id_kelas', $request->kelas_id);
});
}
$stats = $statsQuery->select('status', DB::raw('count(*) as total'))
->groupBy('status')
->pluck('total', 'status')
->toArray();
return view('admin.kegiatan.absensi.rekap', compact('kegiatan', 'absensis', 'stats'));
return view('admin.kegiatan.absensi.rekap', compact('kegiatan', 'absensis', 'absensiPerKelas', 'stats', 'kelasFilterList'));
}
/**

File diff suppressed because it is too large Load Diff

View File

@ -5,18 +5,16 @@
use App\Http\Controllers\Controller;
use App\Models\Santri;
use Illuminate\Http\Request;
use Barryvdh\DomPDF\Facade\Pdf;
use Mpdf\Mpdf;
use Mpdf\Config\ConfigVariables;
use Mpdf\Config\FontVariables;
class KartuRfidController extends Controller
{
/**
* Halaman kelola kartu RFID
*/
public function index(Request $request)
{
$query = Santri::where('status', 'Aktif');
// Filter: Santri yang sudah/belum punya RFID
if ($request->filled('filter')) {
if ($request->filter == 'ada_rfid') {
$query->whereNotNull('rfid_uid');
@ -25,28 +23,24 @@ public function index(Request $request)
}
}
$santris = $query->select('id', 'id_santri', 'nama_lengkap', 'kelas', 'rfid_uid')
$santris = $query
->select('id', 'id_santri', 'nis', 'nama_lengkap', 'rfid_uid', 'foto', 'status')
->with(['kelasSantri.kelas'])
->orderBy('nama_lengkap')
->paginate(15);
return view('admin.kegiatan.kartu.index', compact('santris'));
}
/**
* Form daftarkan RFID ke santri
*/
public function daftarRfid($id_santri)
{
$santri = Santri::where('id_santri', $id_santri)->firstOrFail();
return view('admin.kegiatan.kartu.daftar', compact('santri'));
}
/**
* Simpan RFID UID ke santri
*/
public function simpanRfid(Request $request, $id_santri)
{
$validated = $request->validate([
$request->validate([
'rfid_uid' => 'required|string|max:50|unique:santris,rfid_uid',
], [
'rfid_uid.required' => 'UID RFID wajib diisi.',
@ -60,9 +54,6 @@ public function simpanRfid(Request $request, $id_santri)
->with('success', 'RFID berhasil didaftarkan untuk ' . $santri->nama_lengkap);
}
/**
* Hapus RFID dari santri
*/
public function hapusRfid($id_santri)
{
$santri = Santri::where('id_santri', $id_santri)->firstOrFail();
@ -72,20 +63,104 @@ public function hapusRfid($id_santri)
->with('success', 'RFID berhasil dihapus dari ' . $santri->nama_lengkap);
}
/**
* Cetak kartu RFID santri (PDF)
*/
public function cetakKartu($id_santri)
{
$santri = Santri::where('id_santri', $id_santri)->firstOrFail();
$santri = Santri::where('id_santri', $id_santri)
->with([
'kelasPrimary.kelas',
'kelasSantri' => fn($q) => $q->orderByDesc('is_primary')->orderBy('id'),
'kelasSantri.kelas',
])
->firstOrFail();
if (!$santri->rfid_uid) {
return back()->with('error', 'Santri belum memiliki RFID yang terdaftar.');
}
$pdf = Pdf::loadView('admin.kegiatan.kartu.cetak', compact('santri'));
$pdf->setPaper([0, 0, 243, 153], 'landscape'); // Ukuran kartu ID (85.6mm x 54mm)
// ── Siapkan data untuk view ──────────────────────────────────────
$namaSantri = strtoupper($santri->nama_lengkap ?? 'NAMA SANTRI');
$initial = strtoupper(substr($santri->nama_lengkap ?? 'S', 0, 1));
$nis = !empty($santri->nis) ? $santri->nis : '-';
$uid = !empty($santri->rfid_uid) ? $santri->rfid_uid : '-';
return $pdf->stream('Kartu_RFID_' . $santri->id_santri . '.pdf');
// Kelas: pakai kelasPrimary, fallback ke first kelasSantri
$kelasNama = '-';
if ($santri->kelasPrimary && $santri->kelasPrimary->kelas) {
$kelasNama = strtoupper($santri->kelasPrimary->kelas->nama_kelas);
} elseif ($santri->kelasSantri->first() && $santri->kelasSantri->first()->kelas) {
$kelasNama = strtoupper($santri->kelasSantri->first()->kelas->nama_kelas);
}
// Logo — embed base64 (tidak butuh GD)
$logoBase64 = '';
$logoMime = 'image/png';
foreach ([
public_path('images/logo.png'),
public_path('images/logo.jpg'),
public_path('img/logo.png'),
public_path('logo.png'),
] as $lp) {
if (file_exists($lp)) {
$ext = strtolower(pathinfo($lp, PATHINFO_EXTENSION));
$logoMime = $ext === 'png' ? 'image/png' : 'image/jpeg';
$logoBase64 = base64_encode(file_get_contents($lp));
break;
}
}
// Foto santri — embed base64 (tidak butuh GD)
$fotoBase64 = '';
$fotoMime = 'image/jpeg';
if (!empty($santri->foto)) {
foreach ([
storage_path('app/public/' . $santri->foto),
public_path('storage/' . $santri->foto),
public_path($santri->foto),
] as $fp) {
if (file_exists($fp)) {
$ext = strtolower(pathinfo($fp, PATHINFO_EXTENSION));
$fotoMime = in_array($ext, ['png', 'gif', 'webp']) ? 'image/' . $ext : 'image/jpeg';
$fotoBase64 = base64_encode(file_get_contents($fp));
break;
}
}
}
// ── Render HTML dari blade ────────────────────────────────────────
$html = view('admin.kegiatan.kartu.cetak', compact(
'santri',
'namaSantri', 'initial', 'nis', 'uid', 'kelasNama',
'logoBase64', 'logoMime',
'fotoBase64', 'fotoMime'
))->render();
// ── Inisialisasi mPDF ─────────────────────────────────────────────
// Format: 54mm × 85.6mm (ukuran kartu ID standar)
$mpdf = new Mpdf([
'mode' => 'utf-8',
'format' => [54, 85.6],
'orientation' => 'P',
'margin_top' => 0,
'margin_bottom' => 0,
'margin_left' => 0,
'margin_right' => 0,
'margin_header' => 0,
'margin_footer' => 0,
'default_font' => 'dejavusans',
'tempDir' => storage_path('app/mpdf_tmp'),
'autoScriptToLang' => false,
'autoLangToFont' => false,
// Aktifkan dukungan SVG (untuk foto bulat)
'enableImports' => true,
]);
// Matikan page break otomatis
$mpdf->SetAutoPageBreak(false);
$mpdf->SetDisplayMode('fullpage');
$mpdf->WriteHTML($html);
return response($mpdf->Output('Kartu_RFID_' . $santri->id_santri . '.pdf', 'S'))
->header('Content-Type', 'application/pdf')
->header('Content-Disposition', 'inline; filename="Kartu_RFID_' . $santri->id_santri . '.pdf"');
}
}

View File

@ -19,7 +19,6 @@ class KegiatanController extends Controller
*/
public function index(Request $request)
{
// Tentukan tanggal yang dipilih (default: hari ini, tapi bisa pilih tanggal lain)
$selectedDate = $request->filled('tanggal')
? Carbon::parse($request->tanggal)
: Carbon::now();
@ -31,145 +30,102 @@ public function index(Request $request)
'Thursday' => 'Kamis',
'Friday' => 'Jumat',
'Saturday' => 'Sabtu',
'Sunday' => 'Ahad'
'Sunday' => 'Ahad',
];
$selectedHari = $hariIndonesia[$selectedDate->format('l')];
// Filter kelas (optional)
$selectedKelasId = $request->filled('kelas') ? $request->kelas : null;
// Query kegiatan hari yang dipilih
$query = Kegiatan::with(['kategori', 'kelasKegiatan.kelompok', 'absensis' => function($q) use ($selectedDate) {
$query = Kegiatan::with(['kategori', 'kelasKegiatan.kelompok', 'absensis' => function ($q) use ($selectedDate) {
$q->whereDate('tanggal', $selectedDate->format('Y-m-d'));
}])->where('hari', $selectedHari);
// Filter by kelas if selected
if ($selectedKelasId) {
if ($selectedKelasId === 'umum') {
// Kegiatan umum (tidak punya relasi kelas)
$query->doesntHave('kelasKegiatan');
} else {
// Kegiatan untuk kelas tertentu
$query->whereHas('kelasKegiatan', function($q) use ($selectedKelasId) {
$query->whereHas('kelasKegiatan', function ($q) use ($selectedKelasId) {
$q->where('kelas.id', $selectedKelasId);
});
}
}
$kegiatanHariIni = $query->orderBy('waktu_mulai')->get();
if ($request->filled('kategori_id')) {
$query->where('kategori_id', $request->kategori_id);
}
// Total santri aktif (untuk perhitungan %)
$kegiatanHariIni = $query->orderBy('waktu_mulai')->get();
$totalSantriAktif = Santri::where('status', 'Aktif')->count();
// Hitung statistik untuk setiap kegiatan
$kegiatanHariIni->each(function ($kegiatan) use ($totalSantriAktif, $selectedDate) {
$totalAbsensi = $kegiatan->absensis->count();
$hadir = $kegiatan->absensis->where('status', 'Hadir')->count();
// Persentase kehadiran
$persenKehadiran = $totalAbsensi > 0 ? round(($hadir / $totalAbsensi) * 100) : 0;
// Status kegiatan berdasarkan waktu
$now = Carbon::now();
$waktuMulaiStr = is_string($kegiatan->waktu_mulai)
? $kegiatan->waktu_mulai
: $kegiatan->waktu_mulai->format('H:i');
$waktuSelesaiStr = is_string($kegiatan->waktu_selesai)
? $kegiatan->waktu_selesai
: $kegiatan->waktu_selesai->format('H:i');
$waktuMulaiStr = is_string($kegiatan->waktu_mulai) ? $kegiatan->waktu_mulai : $kegiatan->waktu_mulai->format('H:i');
$waktuSelesaiStr = is_string($kegiatan->waktu_selesai) ? $kegiatan->waktu_selesai : $kegiatan->waktu_selesai->format('H:i');
$waktuMulai = Carbon::parse($selectedDate->format('Y-m-d') . ' ' . $waktuMulaiStr);
$waktuSelesai = Carbon::parse($selectedDate->format('Y-m-d') . ' ' . $waktuSelesaiStr);
if ($selectedDate->isToday()) {
if ($now->lt($waktuMulai)) {
$status = 'belum';
} elseif ($now->between($waktuMulai, $waktuSelesai)) {
$status = 'berlangsung';
} else {
$status = 'selesai';
}
if ($now->lt($waktuMulai)) $status = 'belum';
elseif ($now->between($waktuMulai, $waktuSelesai)) $status = 'berlangsung';
else $status = 'selesai';
} elseif ($selectedDate->isFuture()) {
$status = 'belum';
} else {
$status = 'selesai';
}
// Tambahkan data ke object
$kegiatan->total_hadir = $hadir;
$kegiatan->total_absensi = $totalAbsensi;
$kegiatan->persen_kehadiran = $persenKehadiran;
$kegiatan->status_kegiatan = $status;
});
// KPI Cards
$totalKegiatanHariIni = $kegiatanHariIni->count();
$kegiatanSelesai = $kegiatanHariIni->where('status_kegiatan', 'selesai')->count();
$kegiatanBerlangsung = $kegiatanHariIni->where('status_kegiatan', 'berlangsung')->count();
$avgKehadiran = $kegiatanHariIni->count() > 0
? round($kegiatanHariIni->avg('persen_kehadiran'))
: 0;
$avgKehadiran = $kegiatanHariIni->count() > 0 ? round($kegiatanHariIni->avg('persen_kehadiran')) : 0;
// KPI Comparison vs minggu lalu (same day)
$lastWeekDate = $selectedDate->copy()->subWeek();
$lastWeekHari = $hariIndonesia[$lastWeekDate->format('l')];
$kegiatanLastWeekCount = Kegiatan::where('hari', $lastWeekHari)->count();
$comparisonTotal = $totalKegiatanHariIni - $kegiatanLastWeekCount;
$kegiatanLastWeek = Kegiatan::where('hari', $lastWeekHari)->count();
$comparisonTotal = $totalKegiatanHariIni - $kegiatanLastWeek;
// Avg kehadiran minggu lalu
$avgKehadiranLastWeek = Cache::remember('avg_kehadiran_' . $lastWeekDate->format('Y-m-d'), 600, function() use ($lastWeekDate, $lastWeekHari) {
$kegiatanLastWeek = Kegiatan::where('hari', $lastWeekHari)->get();
$avgKehadiranLastWeek = Cache::remember('avg_kehadiran_' . $lastWeekDate->format('Y-m-d'), 600, function () use ($lastWeekDate, $lastWeekHari) {
$list = Kegiatan::where('hari', $lastWeekHari)->get();
$totalPersen = 0;
$count = 0;
foreach ($kegiatanLastWeek as $kg) {
$absensi = AbsensiKegiatan::where('kegiatan_id', $kg->kegiatan_id)
->whereDate('tanggal', $lastWeekDate->format('Y-m-d'))
->get();
if ($absensi->count() > 0) {
$hadir = $absensi->where('status', 'Hadir')->count();
$totalPersen += ($hadir / $absensi->count()) * 100;
foreach ($list as $kg) {
$abs = AbsensiKegiatan::where('kegiatan_id', $kg->kegiatan_id)
->whereDate('tanggal', $lastWeekDate->format('Y-m-d'))->get();
if ($abs->count() > 0) {
$totalPersen += ($abs->where('status', 'Hadir')->count() / $abs->count()) * 100;
$count++;
}
}
return $count > 0 ? round($totalPersen / $count) : 0;
});
$comparisonAvg = $avgKehadiran - $avgKehadiranLastWeek;
// Get kelas list for filter tabs
$kelasList = Kelas::with('kelompok')->active()->ordered()->get();
// Generate Quick Insights
$kategoris = KategoriKegiatan::select('kategori_id', 'nama_kategori')->get();
$insights = $this->generateInsights($kegiatanHariIni, $totalSantriAktif, $selectedDate);
// Heatmap data (30 hari terakhir) - cached
$heatmapData = Cache::remember('heatmap_30days_' . now()->format('Y-m-d'), 600, function() {
$heatmapData = Cache::remember('heatmap_30days_' . now()->format('Y-m-d'), 600, function () {
return $this->generateHeatmapData();
});
// Data untuk view
$hariList = ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu', 'Ahad'];
return view('admin.kegiatan.data.dashboard', compact(
'kegiatanHariIni',
'totalKegiatanHariIni',
'kegiatanSelesai',
'kegiatanBerlangsung',
'avgKehadiran',
'totalSantriAktif',
'selectedDate',
'selectedHari',
'hariList',
'kelasList',
'selectedKelasId',
'comparisonTotal',
'comparisonAvg',
'insights',
'heatmapData'
'kegiatanHariIni', 'totalKegiatanHariIni', 'kegiatanSelesai',
'kegiatanBerlangsung', 'avgKehadiran', 'totalSantriAktif',
'selectedDate', 'selectedHari', 'hariList', 'kelasList',
'selectedKelasId', 'comparisonTotal', 'comparisonAvg',
'insights', 'heatmapData', 'kategoris'
));
}
@ -180,66 +136,53 @@ private function generateInsights($kegiatanHariIni, $totalSantriAktif, $selected
{
$insights = [];
// Rule 1: Kehadiran rendah (<70%)
foreach ($kegiatanHariIni as $kegiatan) {
if ($kegiatan->total_absensi > 0 && $kegiatan->persen_kehadiran < 70) {
$insights[] = [
'type' => 'warning',
'icon' => 'exclamation-triangle',
'type' => 'warning', 'icon' => 'exclamation-triangle',
'message' => "Kegiatan {$kegiatan->nama_kegiatan} kehadiran rendah ({$kegiatan->persen_kehadiran}%)",
'detail' => "{$kegiatan->total_hadir} dari {$kegiatan->total_absensi} santri hadir",
'action_url' => route('admin.absensi-kegiatan.input', $kegiatan->kegiatan_id) . '?tanggal=' . $selectedDate->format('Y-m-d'),
'action_text' => 'Input Absensi'
'action_text' => 'Input Absensi',
];
}
}
// Rule 2: Kehadiran perfect (100%)
foreach ($kegiatanHariIni as $kegiatan) {
if ($kegiatan->persen_kehadiran == 100 && $kegiatan->total_absensi > 0) {
$insights[] = [
'type' => 'success',
'icon' => 'check-circle',
'type' => 'success', 'icon' => 'check-circle',
'message' => "Perfect! {$kegiatan->nama_kegiatan} kehadiran 100%",
'detail' => 'Semua santri hadir',
'action_url' => null,
'action_text' => null
'detail' => 'Semua santri hadir', 'action_url' => null, 'action_text' => null,
];
}
}
// Rule 3: Kegiatan sedang berlangsung
$kegiatanLive = $kegiatanHariIni->where('status_kegiatan', 'berlangsung')->first();
if ($kegiatanLive) {
$insights[] = [
'type' => 'info',
'icon' => 'clock',
'type' => 'info', 'icon' => 'clock',
'message' => "Kegiatan {$kegiatanLive->nama_kegiatan} sedang berlangsung",
'detail' => "Progress absensi: {$kegiatanLive->persen_kehadiran}%",
'action_url' => route('admin.absensi-kegiatan.input', $kegiatanLive->kegiatan_id) . '?tanggal=' . $selectedDate->format('Y-m-d'),
'action_text' => 'Input Absensi Sekarang'
'action_text' => 'Input Absensi Sekarang',
];
}
// Rule 4: Kegiatan selesai tapi belum input absensi
foreach ($kegiatanHariIni as $kegiatan) {
if ($kegiatan->status_kegiatan == 'selesai' && $kegiatan->total_absensi == 0) {
$waktuSelesai = is_string($kegiatan->waktu_selesai)
? $kegiatan->waktu_selesai
: $kegiatan->waktu_selesai->format('H:i');
$waktuSelesai = is_string($kegiatan->waktu_selesai) ? $kegiatan->waktu_selesai : $kegiatan->waktu_selesai->format('H:i');
$insights[] = [
'type' => 'danger',
'icon' => 'exclamation-circle',
'type' => 'danger', 'icon' => 'exclamation-circle',
'message' => "Kegiatan {$kegiatan->nama_kegiatan} belum input absensi",
'detail' => "Sudah selesai pukul {$waktuSelesai}",
'action_url' => route('admin.absensi-kegiatan.input', $kegiatan->kegiatan_id) . '?tanggal=' . $selectedDate->format('Y-m-d'),
'action_text' => 'Input Sekarang'
'action_text' => 'Input Sekarang',
];
}
}
return collect($insights)->take(5)->toArray(); // Max 5 insights
return collect($insights)->take(5)->toArray();
}
/**
@ -253,39 +196,31 @@ private function generateHeatmapData()
for ($i = 0; $i < 30; $i++) {
$date = $startDate->copy()->addDays($i);
$dateStr = $date->format('Y-m-d');
// Hitung rata-rata kehadiran hari tersebut
$absensi = AbsensiKegiatan::whereDate('tanggal', $dateStr)->get();
if ($absensi->count() > 0) {
$hadir = $absensi->where('status', 'Hadir')->count();
$percentage = round(($hadir / $absensi->count()) * 100, 1);
} else {
$percentage = 0;
}
$percentage = $absensi->count() > 0
? round(($absensi->where('status', 'Hadir')->count() / $absensi->count()) * 100, 1)
: 0;
$heatmapData[] = [
'date' => $dateStr,
'day_name' => $date->locale('id')->isoFormat('ddd'),
'percentage' => $percentage,
'level' => $this->getHeatmapLevel($percentage),
'is_today' => $date->isToday()
'is_today' => $date->isToday(),
];
}
return $heatmapData;
}
/**
* Get Heatmap Level (0-4)
*/
private function getHeatmapLevel($percentage)
{
if ($percentage >= 90) return 4; // Dark green
if ($percentage >= 80) return 3; // Green
if ($percentage >= 70) return 2; // Yellow
if ($percentage > 0) return 1; // Red
return 0; // No data
if ($percentage >= 90) return 4;
if ($percentage >= 80) return 3;
if ($percentage >= 70) return 2;
if ($percentage > 0) return 1;
return 0;
}
/**
@ -294,80 +229,63 @@ private function getHeatmapLevel($percentage)
public function getDetailModal($kegiatan_id, Request $request)
{
$tanggal = $request->get('tanggal', now()->format('Y-m-d'));
$kegiatan = Kegiatan::with(['kategori', 'kelasKegiatan.kelompok'])
->where('kegiatan_id', $kegiatan_id)
->firstOrFail();
->where('kegiatan_id', $kegiatan_id)->firstOrFail();
// Get absensi untuk tanggal tersebut
$absensis = AbsensiKegiatan::with('santri')
$absensis = AbsensiKegiatan::with(['santri.kelasSantri.kelas'])
->where('kegiatan_id', $kegiatan_id)
->whereDate('tanggal', $tanggal)
->orderBy('waktu_absen', 'desc')
->get();
->orderBy('waktu_absen', 'desc')->get();
if ($kegiatan->isForAllClasses()) {
$absensiPerKelas = $absensis->groupBy(fn($item) => $item->santri->kelas_name ?? 'Belum Ada Kelas')->sortKeys();
} else {
$absensiPerKelas = collect();
foreach ($kegiatan->kelasKegiatan as $kelas) {
$kelasAbsensis = $absensis->filter(fn($item) => $item->santri->kelasSantri->contains('id_kelas', $kelas->id));
if ($kelasAbsensis->count() > 0) $absensiPerKelas[$kelas->nama_kelas] = $kelasAbsensis;
}
}
// Statistik
$stats = [
'hadir' => $absensis->where('status', 'Hadir')->count(),
'izin' => $absensis->where('status', 'Izin')->count(),
'sakit' => $absensis->where('status', 'Sakit')->count(),
'alpa' => $absensis->where('status', 'Alpa')->count(),
];
// Total santri yang seharusnya
if ($kegiatan->isForAllClasses()) {
$totalSantri = Santri::where('status', 'Aktif')->count();
} else {
$totalSantri = $kegiatan->getEligibleSantris()->count();
}
$totalSantri = $kegiatan->isForAllClasses()
? Santri::where('status', 'Aktif')->count()
: $kegiatan->getEligibleSantris()->count();
$stats['belum_absen'] = $totalSantri - $absensis->count();
$stats['total'] = $totalSantri;
$stats['persen_hadir'] = $totalSantri > 0 ? round(($stats['hadir'] / $totalSantri) * 100, 1) : 0;
return view('admin.kegiatan.data.partials.detail-modal', compact('kegiatan', 'absensis', 'stats', 'tanggal'));
return view('admin.kegiatan.data.partials.detail-modal', compact('kegiatan', 'absensis', 'absensiPerKelas', 'stats', 'tanggal'));
}
/**
* Jadwal Kegiatan Lengkap (untuk "Lihat Semua Jadwal")
* Jadwal Kegiatan Lengkap
*/
public function jadwal(Request $request)
{
$query = Kegiatan::with(['kategori', 'kelasKegiatan.kelompok']);
// Filter hari
if ($request->filled('hari')) {
$query->where('hari', $request->hari);
}
// Filter kategori
if ($request->filled('kategori_id')) {
$query->where('kategori_id', $request->kategori_id);
}
// Filter kelas
if ($request->filled('hari')) $query->where('hari', $request->hari);
if ($request->filled('kategori_id')) $query->where('kategori_id', $request->kategori_id);
if ($request->filled('kelas_id')) {
if ($request->kelas_id === 'umum') {
$query->doesntHave('kelasKegiatan');
} else {
$query->whereHas('kelasKegiatan', function($q) use ($request) {
$q->where('kelas.id', $request->kelas_id);
});
$query->whereHas('kelasKegiatan', fn($q) => $q->where('kelas.id', $request->kelas_id));
}
}
// Search
if ($request->filled('search')) {
$query->search($request->search);
}
if ($request->filled('search')) $query->search($request->search);
$kegiatans = $query->select('id', 'kegiatan_id', 'kategori_id', 'nama_kegiatan', 'hari', 'waktu_mulai', 'waktu_selesai', 'materi')
->orderBy('hari')
->orderBy('waktu_mulai')
->paginate(15)
->appends(request()->query());
->orderBy('hari')->orderBy('waktu_mulai')
->paginate(15)->appends(request()->query());
// Data untuk filter
$kategoris = KategoriKegiatan::select('kategori_id', 'nama_kategori')->get();
$hariList = ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu', 'Ahad'];
$kelasList = Kelas::with('kelompok')->active()->ordered()->get();
@ -388,9 +306,8 @@ public function create()
$kategoris = KategoriKegiatan::select('kategori_id', 'nama_kategori')->get();
$hariList = ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu', 'Ahad'];
$kelompokKelas = KelompokKelas::with(['kelas' => function($q) {
$q->where('is_active', true)->orderBy('urutan');
}])->active()->ordered()->get();
$kelompokKelas = KelompokKelas::with(['kelas' => fn($q) => $q->where('is_active', true)->orderBy('urutan')])
->active()->ordered()->get();
return view('admin.kegiatan.data.create', compact('nextId', 'kategoris', 'hariList', 'kelompokKelas'));
}
@ -403,7 +320,8 @@ public function store(Request $request)
$validated = $request->validate([
'kategori_id' => 'required|exists:kategori_kegiatans,kategori_id',
'nama_kegiatan' => 'required|string|max:150',
'hari' => 'required|in:Senin,Selasa,Rabu,Kamis,Jumat,Sabtu,Ahad',
'hari' => 'required|array|min:1',
'hari.*' => 'in:Senin,Selasa,Rabu,Kamis,Jumat,Sabtu,Ahad',
'waktu_mulai' => 'required|date_format:H:i',
'waktu_selesai' => 'required|date_format:H:i|after:waktu_mulai',
'materi' => 'nullable|string|max:200',
@ -413,23 +331,32 @@ public function store(Request $request)
], [
'kategori_id.required' => 'Kategori wajib dipilih.',
'nama_kegiatan.required' => 'Nama kegiatan wajib diisi.',
'hari.required' => 'Hari wajib dipilih.',
'hari.required' => 'Minimal pilih satu hari.',
'hari.min' => 'Minimal pilih satu hari.',
'waktu_mulai.required' => 'Waktu mulai wajib diisi.',
'waktu_selesai.required' => 'Waktu selesai wajib diisi.',
'waktu_selesai.after' => 'Waktu selesai harus lebih dari waktu mulai.',
]);
$kegiatan = Kegiatan::create($validated);
$hariList = $validated['hari'];
unset($validated['hari']);
$createdCount = 0;
// Assign kelas to kegiatan if selected
foreach ($hariList as $hari) {
$kg = Kegiatan::create(array_merge($validated, ['hari' => $hari]));
if ($request->has('kelas_ids') && !empty($request->kelas_ids)) {
$kegiatan->assignKelas($request->kelas_ids);
$kg->assignKelas($request->kelas_ids);
}
$createdCount++;
}
Cache::forget('next_kegiatan_id');
return redirect()->route('admin.kegiatan.index')
->with('success', 'Kegiatan berhasil ditambahkan.');
$message = $createdCount > 1
? "Berhasil menambahkan kegiatan untuk {$createdCount} hari."
: 'Kegiatan berhasil ditambahkan.';
return redirect()->route('admin.kegiatan.jadwal')->with('success', $message);
}
/**
@ -448,25 +375,30 @@ public function edit(Kegiatan $kegiatan)
{
$kategoris = KategoriKegiatan::select('kategori_id', 'nama_kategori')->get();
$hariList = ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu', 'Ahad'];
$kelompokKelas = KelompokKelas::with(['kelas' => function($q) {
$q->where('is_active', true)->orderBy('urutan');
}])->active()->ordered()->get();
$kelompokKelas = KelompokKelas::with(['kelas' => fn($q) => $q->where('is_active', true)->orderBy('urutan')])
->active()->ordered()->get();
// Load existing kelas relations
$kegiatan->load('kelasKegiatan');
return view('admin.kegiatan.data.edit', compact('kegiatan', 'kategoris', 'hariList', 'kelompokKelas'));
}
/**
* Update kegiatan
* Update kegiatan smart multi-hari
*
* Logika:
* - Cari semua kegiatan "saudara" = nama_kegiatan + kategori_id LAMA yang sama
* - Hari yang DIPILIH & sudah ada di saudara UPDATE kegiatan saudara tsb
* - Hari yang DIPILIH tapi belum ada di saudara BUAT kegiatan baru
* - Hari yang TIDAK DIPILIH tidak disentuh sama sekali
*/
public function update(Request $request, Kegiatan $kegiatan)
{
$validated = $request->validate([
'kategori_id' => 'required|exists:kategori_kegiatans,kategori_id',
'nama_kegiatan' => 'required|string|max:150',
'hari' => 'required|in:Senin,Selasa,Rabu,Kamis,Jumat,Sabtu,Ahad',
'hari' => 'required|array|min:1',
'hari.*' => 'in:Senin,Selasa,Rabu,Kamis,Jumat,Sabtu,Ahad',
'waktu_mulai' => 'required|date_format:H:i',
'waktu_selesai' => 'required|date_format:H:i|after:waktu_mulai',
'materi' => 'nullable|string|max:200',
@ -476,18 +408,49 @@ public function update(Request $request, Kegiatan $kegiatan)
], [
'kategori_id.required' => 'Kategori wajib dipilih.',
'nama_kegiatan.required' => 'Nama kegiatan wajib diisi.',
'hari.required' => 'Minimal pilih satu hari.',
'hari.min' => 'Minimal pilih satu hari.',
'waktu_selesai.after' => 'Waktu selesai harus lebih dari waktu mulai.',
]);
$kegiatan->update($validated);
$hariDipilih = $validated['hari'];
$kelasIds = $request->input('kelas_ids', []);
// Update kelas assignments
if ($request->has('kelas_ids')) {
$kegiatan->assignKelas($request->kelas_ids ?? []);
// Data dasar tanpa hari & kelas_ids
$baseData = collect($validated)->except(['hari', 'kelas_ids'])->toArray();
// Cari semua saudara berdasarkan nama + kategori LAMA (sebelum diubah)
$saudara = Kegiatan::where('nama_kegiatan', $kegiatan->nama_kegiatan)
->where('kategori_id', $kegiatan->kategori_id)
->get()
->keyBy('hari'); // ['Senin' => obj, 'Rabu' => obj, ...]
$updatedCount = 0;
$createdCount = 0;
foreach ($hariDipilih as $hari) {
if ($saudara->has($hari)) {
// Kegiatan di hari ini sudah ada → update
$target = $saudara->get($hari);
$target->update(array_merge($baseData, ['hari' => $hari]));
$target->assignKelas($kelasIds);
$updatedCount++;
} else {
// Belum ada kegiatan di hari ini → buat baru
$newKg = Kegiatan::create(array_merge($baseData, ['hari' => $hari]));
$newKg->assignKelas($kelasIds);
$createdCount++;
}
}
return redirect()->route('admin.kegiatan.index')
->with('success', 'Kegiatan berhasil diperbarui.');
Cache::forget('next_kegiatan_id');
$parts = [];
if ($updatedCount > 0) $parts[] = "{$updatedCount} kegiatan diperbarui";
if ($createdCount > 0) $parts[] = "{$createdCount} kegiatan baru dibuat";
return redirect()->route('admin.kegiatan.jadwal')
->with('success', 'Berhasil: ' . implode(', ', $parts) . '.');
}
/**
@ -499,7 +462,7 @@ public function destroy(Kegiatan $kegiatan)
$kegiatan->delete();
Cache::forget('next_kegiatan_id');
return redirect()->route('admin.kegiatan.index')
->with('success', "Kegiatan \"$nama\" berhasil dihapus.");
return redirect()->route('admin.kegiatan.jadwal')
->with('success', "Kegiatan \"{$nama}\" berhasil dihapus.");
}
}

View File

@ -1,18 +1,4 @@
<?php
/**
* ============================================================================
* LOKASI FILE: app/Http/Controllers/Admin/KelasController.php
* ============================================================================
*
* INSTRUKSI:
* 1. Backup file KelasController.php yang lama
* 2. Replace dengan file ini
* 3. File ini sudah include semua fitur:
* - CRUD Kelas
* - CRUD Kelompok Kelas
* - Kenaikan Kelas Massal
* ============================================================================
*/
namespace App\Http\Controllers\Admin;
@ -31,66 +17,49 @@ class KelasController extends Controller
// SECTION 1: CRUD KELAS
// ==========================================
/**
* Display a listing of kelas.
*/
public function index(Request $request)
{
$query = Kelas::with('kelompok');
// Search by nama kelas atau kode kelas
if ($request->filled('search')) {
$search = $request->search;
$query->where(function($q) use ($search) {
$query->where(function ($q) use ($search) {
$q->where('nama_kelas', 'like', "%{$search}%")
->orWhere('kode_kelas', 'like', "%{$search}%");
});
}
// Filter by kelompok kelas
if ($request->filled('kelompok')) {
$query->where('id_kelompok', $request->kelompok);
}
// Filter by status
if ($request->filled('status')) {
$isActive = $request->status === 'active';
$query->where('is_active', $isActive);
$query->where('is_active', $request->status === 'active');
}
// Order by kelompok then urutan
$kelas = $query->orderBy('id_kelompok', 'asc')
->orderBy('urutan', 'asc')
->paginate(15)
->appends(request()->query());
// Get kelompok kelas for filter dropdown
$kelompokKelas = KelompokKelas::active()->ordered()->get();
return view('admin.kelas.index', compact('kelas', 'kelompokKelas'));
}
/**
* Show the form for creating a new kelas.
*/
public function create()
{
// Get next kode_kelas
$nextKodeKelas = Cache::remember('next_kelas_kode', 60, function () {
$lastKelas = Kelas::orderBy('id', 'desc')->first();
$nextNum = $lastKelas ? intval(substr($lastKelas->kode_kelas, 3)) + 1 : 1;
return 'KLS' . str_pad($nextNum, 3, '0', STR_PAD_LEFT);
});
// Get kelompok kelas for dropdown
$kelompokKelas = KelompokKelas::active()->ordered()->get();
return view('admin.kelas.create', compact('nextKodeKelas', 'kelompokKelas'));
}
/**
* Store a newly created kelas in storage.
*/
public function store(Request $request)
{
$validated = $request->validate([
@ -98,63 +67,32 @@ public function store(Request $request)
'id_kelompok' => 'required|string|exists:kelompok_kelas,id_kelompok',
'urutan' => 'required|integer|min:0',
'is_active' => 'boolean',
], [
'nama_kelas.required' => 'Nama kelas wajib diisi.',
'nama_kelas.unique' => 'Nama kelas sudah digunakan.',
'id_kelompok.required' => 'Kelompok kelas wajib dipilih.',
'id_kelompok.exists' => 'Kelompok kelas tidak valid.',
'urutan.required' => 'Urutan wajib diisi.',
'urutan.integer' => 'Urutan harus berupa angka.',
'urutan.min' => 'Urutan minimal 0.',
]);
// Set is_active default to true if not provided
$validated['is_active'] = $request->has('is_active') ? true : false;
// Create kelas (kode_kelas will be auto-generated in model)
$validated['is_active'] = $request->has('is_active');
Kelas::create($validated);
// Clear cache
Cache::forget('next_kelas_kode');
return redirect()->route('admin.kelas.index')
->with('success', 'Kelas berhasil ditambahkan.');
}
/**
* Display the specified kelas.
*/
public function show(Kelas $kela)
{
// Load relationships
$kela->load(['kelompok', 'santriKelas.santri']);
// Get santri count in this kelas for current academic year
$tahunAjaranAktif = SantriKelas::getCurrentAcademicYear();
$santriCount = $kela->santriKelas()
->where('tahun_ajaran', $tahunAjaranAktif)
->whereHas('santri', function($q) {
$q->where('status', 'Aktif');
})
->whereHas('santri', fn($q) => $q->where('status', 'Aktif'))
->count();
return view('admin.kelas.show', compact('kela', 'santriCount', 'tahunAjaranAktif'));
return view('admin.kelas.show', compact('kela', 'santriCount'));
}
/**
* Show the form for editing the specified kelas.
*/
public function edit(Kelas $kela)
{
// Get kelompok kelas for dropdown
$kelompokKelas = KelompokKelas::active()->ordered()->get();
return view('admin.kelas.edit', compact('kela', 'kelompokKelas'));
}
/**
* Update the specified kelas in storage.
*/
public function update(Request $request, Kelas $kela)
{
$validated = $request->validate([
@ -162,32 +100,17 @@ public function update(Request $request, Kelas $kela)
'id_kelompok' => 'required|string|exists:kelompok_kelas,id_kelompok',
'urutan' => 'required|integer|min:0',
'is_active' => 'boolean',
], [
'nama_kelas.required' => 'Nama kelas wajib diisi.',
'nama_kelas.unique' => 'Nama kelas sudah digunakan.',
'id_kelompok.required' => 'Kelompok kelas wajib dipilih.',
'id_kelompok.exists' => 'Kelompok kelas tidak valid.',
'urutan.required' => 'Urutan wajib diisi.',
'urutan.integer' => 'Urutan harus berupa angka.',
'urutan.min' => 'Urutan minimal 0.',
]);
// Set is_active
$validated['is_active'] = $request->has('is_active') ? true : false;
// Update kelas
$validated['is_active'] = $request->has('is_active');
$kela->update($validated);
return redirect()->route('admin.kelas.index')
->with('success', 'Kelas berhasil diperbarui.');
}
/**
* Remove the specified kelas from storage.
*/
public function destroy(Kelas $kela)
{
// Check if kelas is still being used
$santriCount = $kela->santriKelas()->count();
$kegiatanCount = $kela->kegiatans()->count();
@ -201,9 +124,7 @@ public function destroy(Kelas $kela)
->with('error', "Kelas tidak dapat dihapus karena masih memiliki {$kegiatanCount} kegiatan.");
}
// Delete kelas
$kela->delete();
return redirect()->route('admin.kelas.index')
->with('success', 'Kelas berhasil dihapus.');
}
@ -212,26 +133,17 @@ public function destroy(Kelas $kela)
// SECTION 2: CRUD KELOMPOK KELAS
// ==========================================
/**
* Display a listing of kelompok kelas.
*/
public function kelompokIndex(Request $request)
{
$query = KelompokKelas::withCount('kelas');
// Search by nama kelompok
if ($request->filled('search')) {
$search = $request->search;
$query->where('nama_kelompok', 'like', "%{$search}%");
$query->where('nama_kelompok', 'like', '%' . $request->search . '%');
}
// Filter by status
if ($request->filled('status')) {
$isActive = $request->status === 'active';
$query->where('is_active', $isActive);
$query->where('is_active', $request->status === 'active');
}
// Order by urutan
$kelompokKelas = $query->orderBy('urutan', 'asc')
->paginate(15)
->appends(request()->query());
@ -239,24 +151,17 @@ public function kelompokIndex(Request $request)
return view('admin.kelas.kelompok.index', compact('kelompokKelas'));
}
/**
* Show the form for creating a new kelompok kelas.
*/
public function kelompokCreate()
{
// Get next id_kelompok
$nextIdKelompok = Cache::remember('next_kelompok_id', 60, function () {
$lastKelompok = KelompokKelas::orderBy('id', 'desc')->first();
$nextNum = $lastKelompok ? intval(substr($lastKelompok->id_kelompok, 3)) + 1 : 1;
$last = KelompokKelas::orderBy('id', 'desc')->first();
$nextNum = $last ? intval(substr($last->id_kelompok, 3)) + 1 : 1;
return 'KEL' . str_pad($nextNum, 3, '0', STR_PAD_LEFT);
});
return view('admin.kelas.kelompok.create', compact('nextIdKelompok'));
}
/**
* Store a newly created kelompok kelas in storage.
*/
public function kelompokStore(Request $request)
{
$validated = $request->validate([
@ -264,78 +169,43 @@ public function kelompokStore(Request $request)
'deskripsi' => 'nullable|string|max:500',
'urutan' => 'required|integer|min:0',
'is_active' => 'boolean',
], [
'nama_kelompok.required' => 'Nama kelompok wajib diisi.',
'nama_kelompok.unique' => 'Nama kelompok sudah digunakan.',
'urutan.required' => 'Urutan wajib diisi.',
'urutan.integer' => 'Urutan harus berupa angka.',
'urutan.min' => 'Urutan minimal 0.',
'deskripsi.max' => 'Deskripsi maksimal 500 karakter.',
]);
// Set is_active default to true if not provided
$validated['is_active'] = $request->has('is_active') ? true : false;
// Create kelompok (id_kelompok will be auto-generated in model)
$validated['is_active'] = $request->has('is_active');
KelompokKelas::create($validated);
// Clear cache
Cache::forget('next_kelompok_id');
return redirect()->route('admin.kelas.kelompok.index')
->with('success', 'Kelompok kelas berhasil ditambahkan.');
}
/**
* Show the form for editing the specified kelompok kelas.
*/
public function kelompokEdit($id)
{
$kelompok = KelompokKelas::findOrFail($id);
$kelompok->loadCount('kelas');
return view('admin.kelas.kelompok.edit', compact('kelompok'));
}
/**
* Update the specified kelompok kelas in storage.
*/
public function kelompokUpdate(Request $request, $id)
{
$kelompok = KelompokKelas::findOrFail($id);
$validated = $request->validate([
'nama_kelompok' => 'required|string|max:100|unique:kelompok_kelas,nama_kelompok,' . $kelompok->id,
'deskripsi' => 'nullable|string|max:500',
'urutan' => 'required|integer|min:0',
'is_active' => 'boolean',
], [
'nama_kelompok.required' => 'Nama kelompok wajib diisi.',
'nama_kelompok.unique' => 'Nama kelompok sudah digunakan.',
'urutan.required' => 'Urutan wajib diisi.',
'urutan.integer' => 'Urutan harus berupa angka.',
'urutan.min' => 'Urutan minimal 0.',
'deskripsi.max' => 'Deskripsi maksimal 500 karakter.',
]);
// Set is_active
$validated['is_active'] = $request->has('is_active') ? true : false;
// Update kelompok
$validated['is_active'] = $request->has('is_active');
$kelompok->update($validated);
return redirect()->route('admin.kelas.kelompok.index')
->with('success', 'Kelompok kelas berhasil diperbarui.');
}
/**
* Remove the specified kelompok kelas from storage.
*/
public function kelompokDestroy($id)
{
$kelompok = KelompokKelas::findOrFail($id);
// Check if kelompok still has kelas
$kelasCount = $kelompok->kelas()->count();
if ($kelasCount > 0) {
@ -343,9 +213,7 @@ public function kelompokDestroy($id)
->with('error', "Kelompok tidak dapat dihapus karena masih memiliki {$kelasCount} kelas.");
}
// Delete kelompok
$kelompok->delete();
return redirect()->route('admin.kelas.kelompok.index')
->with('success', 'Kelompok kelas berhasil dihapus.');
}
@ -354,47 +222,30 @@ public function kelompokDestroy($id)
// SECTION 3: KENAIKAN KELAS MASSAL
// ==========================================
/**
* Display kenaikan kelas index page
*/
public function kenaikanIndex(Request $request)
{
$tahunAjaranAktif = SantriKelas::getCurrentAcademicYear();
$tahunAjaranAktif = $this->getActiveTahunAjaran();
$tahunAjaranBaru = $this->getNextAcademicYear($tahunAjaranAktif);
// Get total santri aktif
$totalSantriAktif = Santri::where('status', 'Aktif')->count();
// Get all kelompok kelas for dropdown
$kelompokKelas = KelompokKelas::with(['kelas' => function($q) {
$q->where('is_active', true)->orderBy('urutan');
}])
->active()
->ordered()
->get();
$kelompokKelas = KelompokKelas::with([
'kelas' => fn($q) => $q->where('is_active', true)->orderBy('urutan'),
])->active()->ordered()->get();
// Determine selected kelompok (default: first kelompok)
$selectedKelompok = $request->get('kelompok');
if (!$selectedKelompok && $kelompokKelas->isNotEmpty()) {
$selectedKelompok = $kelompokKelas->first()->id_kelompok;
}
// Get kelas list for selected kelompok only
$kelasList = Kelas::with('kelompok')
->where('is_active', true)
->when($selectedKelompok, function($q) use ($selectedKelompok) {
$q->where('id_kelompok', $selectedKelompok);
})
->withCount(['santriKelas as santri_aktif_count' => function($q) use ($tahunAjaranAktif) {
$q->where('tahun_ajaran', $tahunAjaranAktif)
->whereHas('santri', function($q2) {
$q2->where('status', 'Aktif');
});
}])
->when($selectedKelompok, fn($q) => $q->where('id_kelompok', $selectedKelompok))
->withCount([
'santriKelas as santri_aktif_count' => fn($q) => $q->whereHas('santri', fn($s) => $s->where('status', 'Aktif')),
])
->orderBy('urutan', 'asc')
->get();
// Get all kelas for dropdown selection (bisa naik ke kelas manapun)
$allKelasList = Kelas::with('kelompok')
->where('is_active', true)
->orderBy('id_kelompok', 'asc')
@ -402,76 +253,50 @@ public function kenaikanIndex(Request $request)
->get();
return view('admin.kelas.kenaikan.index', compact(
'tahunAjaranAktif',
'tahunAjaranBaru',
'totalSantriAktif',
'kelompokKelas',
'kelasList',
'allKelasList',
'selectedKelompok'
'tahunAjaranAktif', 'tahunAjaranBaru', 'totalSantriAktif',
'kelompokKelas', 'kelasList', 'allKelasList', 'selectedKelompok'
));
}
/**
* Preview santri in a class before kenaikan
*/
public function kenaikanPreview($id)
{
$kelas = Kelas::with('kelompok')->findOrFail($id);
$tahunAjaranAktif = SantriKelas::getCurrentAcademicYear();
$tahunAjaranAktif = $this->getActiveTahunAjaran();
$tahunAjaranBaru = $this->getNextAcademicYear($tahunAjaranAktif);
// Get santri in this class (tahun ajaran aktif, status aktif)
$santriList = Santri::whereHas('kelasSantri', function($q) use ($id, $tahunAjaranAktif) {
$q->where('id_kelas', $id)
->where('tahun_ajaran', $tahunAjaranAktif);
})
$santriList = Santri::whereHas('kelasSantri', fn($q) => $q->where('id_kelas', $id))
->where('status', 'Aktif')
->orderBy('nama_lengkap')
->get();
// Get all kelompok with kelas for dropdown
$kelasOptions = KelompokKelas::with(['kelas' => function($q) {
$q->where('is_active', true)->orderBy('urutan');
}])
->active()
->ordered()
->get();
$kelasOptions = KelompokKelas::with([
'kelas' => fn($q) => $q->where('is_active', true)->orderBy('urutan'),
])->active()->ordered()->get();
return view('admin.kelas.kenaikan.preview', compact(
'kelas',
'santriList',
'tahunAjaranAktif',
'tahunAjaranBaru',
'kelasOptions'
'kelas', 'santriList', 'tahunAjaranAktif', 'tahunAjaranBaru', 'kelasOptions'
));
}
/**
* Process kenaikan kelas for all santri in a class
*/
public function kenaikanProcess(Request $request)
{
$request->validate([
'id_kelas_asal' => 'required|exists:kelas,id',
'id_kelas_tujuan' => 'required|exists:kelas,id',
'id_kelas_tujuan' => 'required|exists:kelas,id|different:id_kelas_asal',
], [
'id_kelas_tujuan.different' => 'Kelas tujuan tidak boleh sama dengan kelas asal.',
]);
$kelasAsal = Kelas::findOrFail($request->id_kelas_asal);
$kelasTujuan = Kelas::findOrFail($request->id_kelas_tujuan);
$tahunAjaranAktif = SantriKelas::getCurrentAcademicYear();
// Get all santri aktif in kelas asal
$santriIds = Santri::whereHas('kelasSantri', function($q) use ($request, $tahunAjaranAktif) {
$q->where('id_kelas', $request->id_kelas_asal)
->where('tahun_ajaran', $tahunAjaranAktif);
})
$santriIds = Santri::whereHas('kelasSantri', fn($q) => $q->where('id_kelas', $request->id_kelas_asal))
->where('status', 'Aktif')
->pluck('id_santri');
if ($santriIds->isEmpty()) {
return redirect()->route('admin.kelas.kenaikan.index')
->with('error', 'Tidak ada santri aktif di kelas ' . $kelasAsal->nama_kelas);
->with('error', 'Tidak ada santri aktif di kelas ' . $kelasAsal->nama_kelas . '.');
}
$processed = 0;
@ -479,95 +304,109 @@ public function kenaikanProcess(Request $request)
DB::beginTransaction();
try {
foreach ($santriIds as $idSantri) {
// Cari record santri_kelas yg ada di kelas asal
$record = SantriKelas::where('id_santri', $idSantri)
->where('id_kelas', $kelasAsal->id)
->where('tahun_ajaran', $tahunAjaranAktif)
->orderBy('tahun_ajaran', 'desc')
->first();
if ($record) {
// Update record: ganti kelas saja, tahun_ajaran & is_primary TETAP
$record->update([
'id_kelas' => $kelasTujuan->id,
]);
$processed++;
if (!$record) continue;
// Cek duplikasi: jika sudah ada di kelas tujuan + tahun_ajaran sama, hapus record lama
$sudahAda = SantriKelas::where('id_santri', $idSantri)
->where('id_kelas', $kelasTujuan->id)
->where('tahun_ajaran', $record->tahun_ajaran)
->exists();
if ($sudahAda) {
$record->delete();
} else {
$record->update(['id_kelas' => $kelasTujuan->id]);
}
$processed++;
}
DB::commit();
return redirect()->route('admin.kelas.kenaikan.index')
->with('success', "Berhasil menaikkan {$processed} santri dari kelas {$kelasAsal->nama_kelas} ke {$kelasTujuan->nama_kelas}.");
->with('success', "Berhasil menaikkan {$processed} santri dari {$kelasAsal->nama_kelas} ke {$kelasTujuan->nama_kelas}.");
} catch (\Exception $e) {
DB::rollBack();
return redirect()->route('admin.kelas.kenaikan.index')
->with('error', 'Terjadi kesalahan saat memproses kenaikan kelas: ' . $e->getMessage());
->with('error', 'Terjadi kesalahan: ' . $e->getMessage());
}
}
/**
* Process kenaikan kelas for selected santri only
*/
public function kenaikanProcessSelected(Request $request)
{
$request->validate([
'id_kelas_asal' => 'required|exists:kelas,id',
'id_kelas_tujuan' => 'required|exists:kelas,id',
'id_kelas_tujuan' => 'required|exists:kelas,id|different:id_kelas_asal',
'santri_ids' => 'required|array|min:1',
'santri_ids.*' => 'exists:santris,id_santri',
], [
'santri_ids.required' => 'Pilih minimal 1 santri untuk dinaikkan kelasnya.',
'santri_ids.min' => 'Pilih minimal 1 santri untuk dinaikkan kelasnya.',
'id_kelas_tujuan.different' => 'Kelas tujuan tidak boleh sama dengan kelas asal.',
]);
$kelasAsal = Kelas::findOrFail($request->id_kelas_asal);
$kelasTujuan = Kelas::findOrFail($request->id_kelas_tujuan);
$tahunAjaranAktif = SantriKelas::getCurrentAcademicYear();
$processed = 0;
DB::beginTransaction();
try {
foreach ($request->santri_ids as $idSantri) {
// Cari record santri_kelas yg ada di kelas asal
$record = SantriKelas::where('id_santri', $idSantri)
->where('id_kelas', $kelasAsal->id)
->where('tahun_ajaran', $tahunAjaranAktif)
->orderBy('tahun_ajaran', 'desc')
->first();
if ($record) {
// Update record: ganti kelas saja, tahun_ajaran & is_primary TETAP
$record->update([
'id_kelas' => $kelasTujuan->id,
]);
$processed++;
if (!$record) continue;
// Cek duplikasi: jika sudah ada di kelas tujuan + tahun_ajaran sama, hapus record lama
$sudahAda = SantriKelas::where('id_santri', $idSantri)
->where('id_kelas', $kelasTujuan->id)
->where('tahun_ajaran', $record->tahun_ajaran)
->exists();
if ($sudahAda) {
$record->delete();
} else {
$record->update(['id_kelas' => $kelasTujuan->id]);
}
$processed++;
}
DB::commit();
return redirect()->route('admin.kelas.kenaikan.index')
->with('success', "Berhasil menaikkan {$processed} santri dari kelas {$kelasAsal->nama_kelas} ke {$kelasTujuan->nama_kelas}.");
->with('success', "Berhasil menaikkan {$processed} santri dari {$kelasAsal->nama_kelas} ke {$kelasTujuan->nama_kelas}.");
} catch (\Exception $e) {
DB::rollBack();
return redirect()->route('admin.kelas.kenaikan.preview', $request->id_kelas_asal)
->with('error', 'Terjadi kesalahan saat memproses kenaikan kelas: ' . $e->getMessage());
->with('error', 'Terjadi kesalahan: ' . $e->getMessage());
}
}
/**
* Helper: Get next academic year
* Input: 2024/2025
* Output: 2025/2026
* Helper: tahun ajaran aktif berdasarkan data yang ada di santri_kelas.
* Menggunakan tahun ajaran terbaru yang punya record, fallback ke kalkulasi.
*/
private function getNextAcademicYear($currentYear)
private function getActiveTahunAjaran(): string
{
return SantriKelas::max('tahun_ajaran') ?? SantriKelas::getCurrentAcademicYear();
}
/**
* Helper: hitung tahun ajaran berikutnya
* Contoh: "2024/2025" -> "2025/2026"
*/
private function getNextAcademicYear(string $currentYear): string
{
$parts = explode('/', $currentYear);
$startYear = (int) $parts[0] + 1;
$endYear = (int) $parts[1] + 1;
return $startYear . '/' . $endYear;
return ((int) $parts[0] + 1) . '/' . ((int) $parts[1] + 1);
}
}

View File

@ -196,9 +196,9 @@ public function detailSantri($id_santri, Request $request)
DB::raw('SUM(CASE WHEN status = "Sakit" THEN 1 ELSE 0 END) as sakit'),
DB::raw('SUM(CASE WHEN status = "Alpa" THEN 1 ELSE 0 END) as alpa')
)
->first();
->first() ?? (object) ['total' => 0, 'hadir' => 0, 'izin' => 0, 'sakit' => 0, 'alpa' => 0];
$persenKehadiran = $stats->total > 0 ? round(($stats->hadir / $stats->total) * 100, 1) : 0;
$persenKehadiran = ($stats->total ?? 0) > 0 ? round(($stats->hadir / $stats->total) * 100, 1) : 0;
// Kehadiran per kegiatan
$perKegiatan = AbsensiKegiatan::where('id_santri', $id_santri)
@ -360,8 +360,8 @@ public function analisKegiatan($kegiatan_id, Request $request)
DB::raw('SUM(CASE WHEN status="Sakit" THEN 1 ELSE 0 END) as sakit'),
DB::raw('SUM(CASE WHEN status="Alpa" THEN 1 ELSE 0 END) as alpa')
)
->first();
$stats->persen = $stats->total > 0 ? round(($stats->hadir / $stats->total) * 100, 1) : 0;
->first() ?? (object) ['total' => 0, 'hadir' => 0, 'izin' => 0, 'sakit' => 0, 'alpa' => 0];
$stats->persen = ($stats->total ?? 0) > 0 ? round(($stats->hadir / $stats->total) * 100, 1) : 0;
// Trend 4 minggu
$trend = [];

View File

@ -11,9 +11,10 @@
class PembayaranSppController extends Controller
{
/**
* Display a listing of the resource.
*/
// ══════════════════════════════════════════════════════
// INDEX
// ══════════════════════════════════════════════════════
public function index(Request $request)
{
// Default tab
@ -23,12 +24,7 @@ public function index(Request $request)
$bulan = $request->filled('bulan') ? $request->bulan : date('n');
$tahun = $request->filled('tahun') ? $request->tahun : date('Y');
// Query untuk mendapatkan data pembayaran berdasarkan filter
$query = PembayaranSpp::with('santri')
->where('bulan', $bulan)
->where('tahun', $tahun);
// Data untuk filter
// Data untuk filter tahun
$tahunList = PembayaranSpp::selectRaw('DISTINCT tahun')
->orderBy('tahun', 'desc')
->pluck('tahun');
@ -40,126 +36,108 @@ public function index(Request $request)
// Get santri dengan status pembayaran untuk periode yang dipilih
$santriList = Santri::where('status', 'Aktif')
->with(['pembayaranSpp' => function($q) use ($bulan, $tahun) {
->with(['pembayaranSpp' => function ($q) use ($bulan, $tahun) {
$q->where('bulan', $bulan)->where('tahun', $tahun);
}])
->get()
->map(function($santri) use ($bulan, $tahun) {
$pembayaran = $santri->pembayaranSpp->first();
->map(function ($santri) {
$p = $santri->pembayaranSpp->first();
return [
'id_santri' => $santri->id_santri,
'nama_lengkap' => $santri->nama_lengkap,
'nis' => $santri->nis,
'kelas' => $santri->kelas,
'pembayaran' => $pembayaran,
'status' => $pembayaran ? $pembayaran->status : 'Belum Ada Tagihan',
'is_telat' => $pembayaran ? $pembayaran->isTelat() : false,
'nominal' => $pembayaran ? $pembayaran->nominal : 0,
'tanggal_bayar' => $pembayaran ? $pembayaran->tanggal_bayar : null,
'batas_bayar' => $pembayaran ? $pembayaran->batas_bayar : null,
'pembayaran' => $p,
// status virtual: Lunas / Cicilan / Belum Lunas / Belum Ada Tagihan
'status' => $p ? ($p->status === 'Lunas' ? 'Lunas' : ($p->isCicilan() ? 'Cicilan' : 'Belum Lunas')) : 'Belum Ada Tagihan',
'is_telat' => $p ? $p->isTelat() : false,
'nominal' => $p ? (float) $p->nominal : 0,
'tanggal_bayar'=> $p ? $p->tanggal_bayar : null,
'batas_bayar' => $p ? $p->batas_bayar : null,
];
});
// Filter berdasarkan tab
if ($tab === 'sudah-bayar') {
$santriList = $santriList->filter(function($item) {
return $item['pembayaran'] && $item['status'] === 'Lunas';
});
} else {
// Belum bayar (termasuk yang belum ada tagihan dan yang telat)
$santriList = $santriList->filter(function($item) {
return !$item['pembayaran'] || $item['status'] !== 'Lunas';
});
}
// Filter search
if ($request->filled('search')) {
$search = strtolower($request->search);
$santriList = $santriList->filter(function($item) use ($search) {
return str_contains(strtolower($item['nama_lengkap']), $search) ||
str_contains(strtolower($item['id_santri']), $search) ||
str_contains(strtolower($item['nis']), $search);
});
}
// Filter status spesifik
if ($request->filled('filter_status')) {
if ($request->filter_status === 'Telat') {
$santriList = $santriList->filter(function($item) {
return $item['is_telat'];
});
} elseif ($request->filter_status === 'Belum Ada Tagihan') {
$santriList = $santriList->filter(function($item) {
return !$item['pembayaran'];
});
} else {
$santriList = $santriList->filter(function($item) use ($request) {
return $item['status'] === $request->filter_status;
});
}
}
// Hitung statistik
$totalSantri = $santriList->count();
// ─── KPI (hitung dari data PENUH sebelum filter tab) ─────────
$totalSantriAll = $santriList->count();
$totalLunas = $santriList->where('status', 'Lunas')->count();
$totalBelumBayar = $santriList->where('status', 'Belum Lunas')->count();
$totalCicilan = $santriList->where('status', 'Cicilan')->count();
$totalBelumBayar = $santriList->whereIn('status', ['Belum Lunas', 'Belum Ada Tagihan'])->count();
$totalTelat = $santriList->where('is_telat', true)->count();
$totalBelumAdaTagihan = $santriList->where('status', 'Belum Ada Tagihan')->count();
$nominalLunas = $santriList->where('status', 'Lunas')->sum('nominal');
$nominalBelumLunas = $santriList->where('status', 'Belum Lunas')->sum('nominal');
$nominalBelumLunas = $santriList->whereIn('status', ['Belum Lunas', 'Cicilan'])->sum('nominal');
// Sort
$santriList = $santriList->sortBy('nama_lengkap')->values();
// ─── Filter tab ───────────────────────────────────────────────
if ($tab === 'sudah-bayar') {
$santriList = $santriList
->filter(fn($i) => $i['pembayaran'] && $i['status'] === 'Lunas')
->sortByDesc(fn($i) => $i['tanggal_bayar'] ? $i['tanggal_bayar']->timestamp : 0);
// Manual pagination
} elseif ($tab === 'cicilan') {
$santriList = $santriList
->filter(fn($i) => $i['pembayaran'] && $i['status'] === 'Cicilan')
->sortBy('nama_lengkap');
} else {
// belum-bayar: status Belum Lunas atau Belum Ada Tagihan
$santriList = $santriList
->filter(fn($i) => in_array($i['status'], ['Belum Lunas', 'Belum Ada Tagihan']))
->sortBy('nama_lengkap');
}
// ─── Search ───────────────────────────────────────────────────
if ($request->filled('search')) {
$search = strtolower($request->search);
$santriList = $santriList->filter(fn($i) =>
str_contains(strtolower($i['nama_lengkap']), $search) ||
str_contains(strtolower($i['id_santri']), $search) ||
str_contains(strtolower($i['nis']), $search)
);
}
// ─── Filter status spesifik (tab belum-bayar) ─────────────────
if ($request->filled('filter_status')) {
if ($request->filter_status === 'Telat') {
$santriList = $santriList->filter(fn($i) => $i['is_telat']);
} elseif ($request->filter_status === 'Belum Ada Tagihan') {
$santriList = $santriList->filter(fn($i) => !$i['pembayaran']);
} else {
$santriList = $santriList->filter(fn($i) => $i['status'] === $request->filter_status);
}
}
// ─── Pagination manual ────────────────────────────────────────
$santriList = $santriList->values();
$perPage = 20;
$currentPage = $request->get('page', 1);
$offset = ($currentPage - 1) * $perPage;
$santriPaginated = $santriList->slice($offset, $perPage)->values();
$totalPages = ceil($santriList->count() / $perPage);
$totalSantri = $santriList->count();
return view('admin.pembayaran-spp.index', compact(
'santriPaginated',
'tab',
'bulan',
'tahun',
'tahunList',
'totalSantri',
'totalLunas',
'totalBelumBayar',
'totalTelat',
'totalBelumAdaTagihan',
'nominalLunas',
'nominalBelumLunas',
'currentPage',
'totalPages'
'santriPaginated', 'tab', 'bulan', 'tahun', 'tahunList',
'totalSantri', 'totalSantriAll',
'totalLunas', 'totalCicilan', 'totalBelumBayar',
'totalTelat', 'totalBelumAdaTagihan',
'nominalLunas', 'nominalBelumLunas',
'currentPage', 'totalPages'
));
}
/**
* Show the form for creating a new resource.
*/
// ══════════════════════════════════════════════════════
// CREATE / STORE
// ══════════════════════════════════════════════════════
public function create()
{
// Ambil santri yang aktif
$santris = Santri::where('status', 'Aktif')
->orderBy('nama_lengkap', 'asc')
->get();
// Generate preview ID
$santris = Santri::where('status', 'Aktif')->orderBy('nama_lengkap', 'asc')->get();
$last = PembayaranSpp::orderBy('id', 'desc')->first();
$nextNum = $last ? intval(substr($last->id_pembayaran, 3)) + 1 : 1;
$nextId = 'SPP' . str_pad($nextNum, 3, '0', STR_PAD_LEFT);
return view('admin.pembayaran-spp.create', compact('santris', 'nextId'));
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
$validated = $request->validate([
@ -168,7 +146,7 @@ public function store(Request $request)
'tahun' => 'required|integer|min:2020|max:2100',
'nominal' => 'required|numeric|min:0',
'status' => 'required|in:Lunas,Belum Lunas',
'tanggal_bayar' => 'nullable|date',
'tanggal_bayar'=> 'nullable|date',
'batas_bayar' => 'required|date',
'keterangan' => 'nullable|string',
], [
@ -184,14 +162,15 @@ public function store(Request $request)
'batas_bayar.required' => 'Batas bayar wajib diisi.',
]);
// Cek duplikasi
$exists = PembayaranSpp::where('id_santri', $validated['id_santri'])
// Cek duplikasi — jika sudah ada, arahkan ke edit
$existing = PembayaranSpp::where('id_santri', $validated['id_santri'])
->where('bulan', $validated['bulan'])
->where('tahun', $validated['tahun'])
->exists();
->first();
if ($exists) {
return back()->withInput()->with('error', 'Data pembayaran untuk periode ini sudah ada.');
if ($existing) {
return redirect()->route('admin.pembayaran-spp.edit', $existing->id)
->with('info', 'Data SPP untuk periode ini sudah ada. Silakan edit data berikut untuk mengubah status pembayaran.');
}
// Jika status lunas dan tanggal_bayar kosong, set ke hari ini
@ -205,27 +184,22 @@ public function store(Request $request)
->with('success', 'Data pembayaran SPP berhasil ditambahkan.');
}
/**
* Display the specified resource.
*/
// ══════════════════════════════════════════════════════
// SHOW / EDIT / UPDATE / DESTROY
// ══════════════════════════════════════════════════════
public function show(PembayaranSpp $pembayaranSpp)
{
$pembayaranSpp->load('santri');
return view('admin.pembayaran-spp.show', compact('pembayaranSpp'));
}
/**
* Show the form for editing the specified resource.
*/
public function edit(PembayaranSpp $pembayaranSpp)
{
$santris = Santri::orderBy('nama_lengkap', 'asc')->get();
return view('admin.pembayaran-spp.edit', compact('pembayaranSpp', 'santris'));
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, PembayaranSpp $pembayaranSpp)
{
$validated = $request->validate([
@ -234,7 +208,7 @@ public function update(Request $request, PembayaranSpp $pembayaranSpp)
'tahun' => 'required|integer|min:2020|max:2100',
'nominal' => 'required|numeric|min:0',
'status' => 'required|in:Lunas,Belum Lunas',
'tanggal_bayar' => 'nullable|date',
'tanggal_bayar'=> 'nullable|date',
'batas_bayar' => 'required|date',
'keterangan' => 'nullable|string',
], [
@ -262,29 +236,103 @@ public function update(Request $request, PembayaranSpp $pembayaranSpp)
$validated['tanggal_bayar'] = Carbon::now()->format('Y-m-d');
}
// Jika diubah ke Lunas, hapus data cicilan dari keterangan
if ($validated['status'] === 'Lunas' && $pembayaranSpp->isCicilan()) {
$validated['keterangan'] = $pembayaranSpp->catatan_teks; // simpan teks catatan saja
}
$pembayaranSpp->update($validated);
return redirect()->route('admin.pembayaran-spp.index')
->with('success', 'Data pembayaran SPP berhasil diperbarui.');
}
/**
* Remove the specified resource from storage.
*/
public function destroy(PembayaranSpp $pembayaranSpp)
{
$periode = $pembayaranSpp->periode_lengkap;
$santri = $pembayaranSpp->santri->nama_lengkap;
$pembayaranSpp->delete();
return redirect()->route('admin.pembayaran-spp.index')
->with('success', "Data pembayaran SPP {$periode} untuk {$santri} berhasil dihapus.");
}
// ══════════════════════════════════════════════════════
// QUICK ACTIONS
// ══════════════════════════════════════════════════════
/**
* Tampilkan riwayat pembayaran per santri
* Tandai Lunas langsung (quick pay)
*/
public function bayar(Request $request, PembayaranSpp $pembayaranSpp)
{
if ($pembayaranSpp->status === 'Lunas') {
return redirect()->back()->with('info', 'Pembayaran ini sudah berstatus Lunas.');
}
$pembayaranSpp->update([
'status' => 'Lunas',
'tanggal_bayar' => $request->filled('tanggal_bayar')
? $request->tanggal_bayar
: Carbon::now()->format('Y-m-d'),
// Bersihkan data cicilan dari keterangan, simpan catatan teks jika ada
'keterangan' => $pembayaranSpp->catatan_teks,
]);
$nama = $pembayaranSpp->santri->nama_lengkap;
$periode = $pembayaranSpp->periode_lengkap;
return redirect()->route('admin.pembayaran-spp.index', [
'tab' => 'sudah-bayar',
'bulan' => $pembayaranSpp->bulan,
'tahun' => $pembayaranSpp->tahun,
])->with('success', "Pembayaran SPP {$periode} untuk {$nama} berhasil ditandai Lunas.");
}
/**
* Catat cicilan (tambah nominal terbayar)
* Status DB tetap "Belum Lunas" cicilan disimpan di keterangan (JSON).
*/
public function catatCicilan(Request $request, PembayaranSpp $pembayaranSpp)
{
$request->validate([
'nominal_cicilan' => 'required|numeric|min:1',
'catatan' => 'nullable|string|max:200',
]);
$sudahTerbayar = $pembayaranSpp->nominal_terbayar;
$totalTagihan = (float) $pembayaranSpp->nominal;
$baru = $sudahTerbayar + (float) $request->nominal_cicilan;
// Jika total cicilan >= tagihan → otomatis Lunas
if ($baru >= $totalTagihan) {
$pembayaranSpp->update([
'status' => 'Lunas',
'tanggal_bayar' => Carbon::now()->format('Y-m-d'),
'keterangan' => $request->catatan ?? $pembayaranSpp->catatan_teks,
]);
return redirect()->route('admin.pembayaran-spp.index', [
'tab' => 'sudah-bayar',
'bulan' => $pembayaranSpp->bulan,
'tahun' => $pembayaranSpp->tahun,
])->with('success', "Cicilan terakhir diterima. SPP {$pembayaranSpp->periode_lengkap} untuk {$pembayaranSpp->santri->nama_lengkap} sekarang Lunas.");
}
// Masih cicilan — update keterangan saja, status tetap "Belum Lunas"
$pembayaranSpp->setCicilan($baru, $request->catatan ?? $pembayaranSpp->catatan_teks);
$pembayaranSpp->save();
$sisaFormat = 'Rp ' . number_format($totalTagihan - $baru, 0, ',', '.');
$cicilanFormat = 'Rp ' . number_format((float) $request->nominal_cicilan, 0, ',', '.');
return redirect()->back()
->with('success', "Cicilan {$cicilanFormat} berhasil dicatat. Sisa: {$sisaFormat}");
}
// ══════════════════════════════════════════════════════
// RIWAYAT PER SANTRI
// ══════════════════════════════════════════════════════
public function riwayat($id_santri)
{
$santri = Santri::where('id_santri', $id_santri)->firstOrFail();
@ -309,17 +357,14 @@ public function riwayat($id_santri)
->count();
return view('admin.pembayaran-spp.riwayat', compact(
'santri',
'pembayaranSpp',
'totalBayar',
'totalTunggakan',
'jumlahTelat'
'santri', 'pembayaranSpp', 'totalBayar', 'totalTunggakan', 'jumlahTelat'
));
}
/**
* Generate SPP untuk semua santri aktif dalam periode tertentu
*/
// ══════════════════════════════════════════════════════
// GENERATE SPP MASSAL
// ══════════════════════════════════════════════════════
public function generate(Request $request)
{
if ($request->isMethod('post')) {
@ -335,7 +380,6 @@ public function generate(Request $request)
$skipped = 0;
foreach ($santris as $santri) {
// Cek apakah sudah ada
$exists = PembayaranSpp::where('id_santri', $santri->id_santri)
->where('bulan', $validated['bulan'])
->where('tahun', $validated['tahun'])
@ -363,28 +407,21 @@ public function generate(Request $request)
return view('admin.pembayaran-spp.generate');
}
/**
* Halaman pilihan laporan
*/
// ══════════════════════════════════════════════════════
// LAPORAN & CETAK
// ══════════════════════════════════════════════════════
public function laporan()
{
return view('admin.pembayaran-spp.laporan');
}
/**
* Cetak laporan SPP (semua data atau filter)
*/
public function cetakLaporan(Request $request)
{
$query = PembayaranSpp::with('santri');
// Filter
if ($request->filled('bulan')) {
$query->where('bulan', $request->bulan);
}
if ($request->filled('tahun')) {
$query->where('tahun', $request->tahun);
}
if ($request->filled('bulan')) $query->where('bulan', $request->bulan);
if ($request->filled('tahun')) $query->where('tahun', $request->tahun);
if ($request->filled('status')) {
if ($request->status === 'Telat') {
$query->telat();
@ -393,56 +430,33 @@ public function cetakLaporan(Request $request)
}
}
$pembayaranSpp = $query->orderBy('tahun', 'desc')
->orderBy('bulan', 'desc')
->get();
// Statistik
$pembayaranSpp = $query->orderBy('tahun', 'desc')->orderBy('bulan', 'desc')->get();
$totalLunas = $pembayaranSpp->where('status', 'Lunas')->sum('nominal');
$totalTunggakan = $pembayaranSpp->where('status', 'Belum Lunas')->sum('nominal');
$jumlahTelat = $pembayaranSpp->filter(function($spp) {
return $spp->isTelat();
})->count();
$jumlahTelat = $pembayaranSpp->filter(fn($s) => $s->isTelat())->count();
return view('admin.pembayaran-spp.cetak-laporan', compact(
'pembayaranSpp',
'totalLunas',
'totalTunggakan',
'jumlahTelat'
'pembayaranSpp', 'totalLunas', 'totalTunggakan', 'jumlahTelat'
));
}
/**
* Cetak laporan SPP per santri
*/
public function cetakLaporanSantri($id_santri)
{
$santri = Santri::where('id_santri', $id_santri)->firstOrFail();
$pembayaranSpp = PembayaranSpp::where('id_santri', $id_santri)
->orderBy('tahun', 'desc')
->orderBy('bulan', 'desc')
->get();
// Statistik
$totalLunas = $pembayaranSpp->where('status', 'Lunas')->sum('nominal');
$totalTunggakan = $pembayaranSpp->where('status', 'Belum Lunas')->sum('nominal');
$jumlahTelat = $pembayaranSpp->filter(function($spp) {
return $spp->isTelat();
})->count();
$jumlahTelat = $pembayaranSpp->filter(fn($s) => $s->isTelat())->count();
return view('admin.pembayaran-spp.cetak-laporan-santri', compact(
'santri',
'pembayaranSpp',
'totalLunas',
'totalTunggakan',
'jumlahTelat'
'santri', 'pembayaranSpp', 'totalLunas', 'totalTunggakan', 'jumlahTelat'
));
}
/**
* Cetak bukti pembayaran
*/
public function cetakBukti(PembayaranSpp $pembayaranSpp)
{
$pembayaranSpp->load('santri');

View File

@ -14,18 +14,46 @@ class UangSakuController extends Controller
{
/**
* Tampilkan daftar uang saku Grouped per Santri
* Default: bulan ini
*/
public function index(Request $request)
{
$search = $request->get('search');
// Query santri aktif yang punya transaksi (atau semua jika tidak ada filter)
// ── Default: bulan ini ──────────────────────────────────────
$dari = $request->get('dari', now()->startOfMonth()->format('Y-m-d'));
$sampai = $request->get('sampai', now()->endOfMonth()->format('Y-m-d'));
$sort = $request->get('sort', 'nama'); // nama | saldo_asc | saldo_desc | transaksi_desc | terakhir
// ── KPI ringkasan periode (dipengaruhi filter tanggal) ──────
$kpiQuery = UangSaku::whereBetween('tanggal_transaksi', [$dari, $sampai]);
$kpi = [
'total_transaksi' => (clone $kpiQuery)->count(),
'total_pemasukan' => (float)(clone $kpiQuery)->where('jenis_transaksi', 'pemasukan')->sum('nominal'),
'total_pengeluaran' => (float)(clone $kpiQuery)->where('jenis_transaksi', 'pengeluaran')->sum('nominal'),
'total_santri' => (clone $kpiQuery)->distinct('id_santri')->count('id_santri'),
];
// Selisih periode: apakah dalam rentang ini uang yang masuk lebih besar dari yang keluar
$kpi['selisih'] = $kpi['total_pemasukan'] - $kpi['total_pengeluaran'];
// ── KPI Real-time: Total saldo aktual seluruh santri (tidak dipengaruhi filter) ──
// Ambil saldo_sesudah dari transaksi TERAKHIR masing-masing santri
$totalSaldoSemua = DB::table('uang_saku as u1')
->join(DB::raw('(
SELECT id_santri, MAX(id) as max_id
FROM uang_saku
GROUP BY id_santri
) as latest'), function ($join) {
$join->on('u1.id_santri', '=', 'latest.id_santri')
->on('u1.id', '=', 'latest.max_id');
})
->sum('u1.saldo_sesudah');
$kpi['total_saldo_realtime'] = (float) $totalSaldoSemua;
// ── Query santri ────────────────────────────────────────────
$santriQuery = Santri::aktif()
->select('id_santri', 'nama_lengkap')
->withCount(['uangSaku as transaksi_bulan_ini' => function ($q) {
$q->whereMonth('tanggal_transaksi', now()->month)
->whereYear('tanggal_transaksi', now()->year);
}])
->has('uangSaku');
if ($search) {
@ -35,37 +63,72 @@ public function index(Request $request)
});
}
$santriList = $santriQuery->orderBy('nama_lengkap')
->paginate(20)
->appends(request()->query());
$santriQuery->orderBy('nama_lengkap');
$santriList = $santriQuery->paginate(20)->appends(request()->query());
// Ambil saldo terakhir & transaksi terbaru per santri (batch)
$ids = $santriList->pluck('id_santri');
// Saldo terakhir per santri (dari transaksi terbaru)
$saldoMap = UangSaku::whereIn('id_santri', $ids)
->select('id_santri', 'saldo_sesudah')
->orderByDesc('tanggal_transaksi')
->orderByDesc('created_at')
// ── Saldo terakhir per santri (efisien: subquery per-id) ────
// Ambil id transaksi terakhir per santri lalu join, hindari get()->unique() yang boros
$latestIds = DB::table('uang_saku')
->whereIn('id_santri', $ids)
->select('id_santri', DB::raw('MAX(id) as max_id'))
->groupBy('id_santri')
->pluck('max_id', 'id_santri');
$saldoMap = UangSaku::whereIn('id', $latestIds->values())
->get()
->unique('id_santri')
->keyBy('id_santri');
// Transaksi terbaru per santri (max 5)
// ── Pemasukan & pengeluaran bulan ini per santri ────────────
$bulanIniStats = UangSaku::whereIn('id_santri', $ids)
->whereMonth('tanggal_transaksi', now()->month)
->whereYear('tanggal_transaksi', now()->year)
->select(
'id_santri',
DB::raw('SUM(CASE WHEN jenis_transaksi="pemasukan" THEN nominal ELSE 0 END) as pemasukan_bulan'),
DB::raw('SUM(CASE WHEN jenis_transaksi="pengeluaran" THEN nominal ELSE 0 END) as pengeluaran_bulan'),
DB::raw('COUNT(*) as total_bulan')
)
->groupBy('id_santri')
->get()
->keyBy('id_santri');
// ── Transaksi terbaru per santri (max 5, untuk collapsed detail) ──
$transaksiMap = UangSaku::whereIn('id_santri', $ids)
->orderByDesc('tanggal_transaksi')
->orderByDesc('created_at')
->get()
->groupBy('id_santri')
->map(fn ($group) => $group->take(5));
->map(fn($g) => $g->take(5));
// Attach ke santri objects
$santriList->getCollection()->each(function ($santri) use ($saldoMap, $transaksiMap) {
$santri->saldo_terakhir = $saldoMap[$santri->id_santri]->saldo_sesudah ?? 0;
// ── Attach semua data ke santri objects ─────────────────────
$collection = $santriList->getCollection()->map(function ($santri) use ($saldoMap, $bulanIniStats, $transaksiMap) {
$saldoRow = $saldoMap[$santri->id_santri] ?? null;
$bulan = $bulanIniStats[$santri->id_santri] ?? null;
$santri->saldo_terakhir = $saldoRow ? (float)$saldoRow->saldo_sesudah : 0;
$santri->transaksi_terakhir_tgl = $saldoRow ? $saldoRow->tanggal_transaksi : null;
$santri->pemasukan_bulan = $bulan ? (float)$bulan->pemasukan_bulan : 0;
$santri->pengeluaran_bulan = $bulan ? (float)$bulan->pengeluaran_bulan : 0;
$santri->transaksi_bulan_ini = $bulan ? (int)$bulan->total_bulan : 0;
$santri->transaksi_terbaru = $transaksiMap[$santri->id_santri] ?? collect();
return $santri;
});
return view('admin.uang-saku.index', compact('santriList'));
// ── Re-sort collection setelah attach ───────────────────────
$sorted = match($sort) {
'saldo_asc' => $collection->sortBy('saldo_terakhir'),
'saldo_desc' => $collection->sortByDesc('saldo_terakhir'),
'transaksi_desc' => $collection->sortByDesc('transaksi_bulan_ini'),
'terakhir' => $collection->sortByDesc('transaksi_terakhir_tgl'),
default => $collection->sortBy('nama_lengkap'),
};
$santriList->setCollection($sorted->values());
return view('admin.uang-saku.index', compact('santriList', 'kpi', 'dari', 'sampai', 'sort'));
}
/**
@ -77,15 +140,13 @@ public function santriInfo($id_santri)
$bulanIni = now();
// Saldo terakhir
$lastTx = UangSaku::where('id_santri', $id_santri)
->orderByDesc('tanggal_transaksi')
->orderByDesc('created_at')
->first();
$saldo = $lastTx ? $lastTx->saldo_sesudah : 0;
$saldo = $lastTx ? (float)$lastTx->saldo_sesudah : 0;
// Total pemasukan & pengeluaran bulan ini
$pemasukanBulanIni = UangSaku::where('id_santri', $id_santri)
->where('jenis_transaksi', 'pemasukan')
->whereMonth('tanggal_transaksi', $bulanIni->month)
@ -98,13 +159,12 @@ public function santriInfo($id_santri)
->whereYear('tanggal_transaksi', $bulanIni->year)
->sum('nominal');
// 3 transaksi terakhir
$transaksiTerakhir = UangSaku::where('id_santri', $id_santri)
->orderByDesc('tanggal_transaksi')
->orderByDesc('created_at')
->limit(3)
->get()
->map(fn ($t) => [
->map(fn($t) => [
'tanggal' => $t->tanggal_transaksi->format('d/m/Y'),
'jenis' => $t->jenis_transaksi,
'nominal' => number_format($t->nominal, 0, ',', '.'),
@ -121,9 +181,6 @@ public function santriInfo($id_santri)
]);
}
/**
* Form tambah transaksi
*/
public function create()
{
$santriList = Santri::where('status', 'Aktif')
@ -134,9 +191,6 @@ public function create()
return view('admin.uang-saku.create', compact('santriList'));
}
/**
* Simpan transaksi baru
*/
public function store(Request $request)
{
$validated = $request->validate([
@ -145,117 +199,84 @@ public function store(Request $request)
'nominal' => 'required|numeric|min:1|max:99999999',
'keterangan' => 'nullable|string|max:500',
'tanggal_transaksi' => 'required|date',
], [
'id_santri.required' => 'Santri wajib dipilih.',
'id_santri.exists' => 'Santri tidak ditemukan.',
'jenis_transaksi.required' => 'Jenis transaksi wajib dipilih.',
'nominal.required' => 'Nominal wajib diisi.',
'nominal.numeric' => 'Nominal harus berupa angka.',
'nominal.min' => 'Nominal minimal Rp 1.',
'tanggal_transaksi.required' => 'Tanggal transaksi wajib diisi.',
]);
DB::beginTransaction();
try {
UangSaku::create($validated);
// Update saldo transaksi berikutnya jika ada
$this->recalculateSaldoAfter($validated['id_santri'], $validated['tanggal_transaksi']);
DB::commit();
Cache::forget('santri_aktif_uang_saku');
return redirect()->route('admin.uang-saku.index')
->with('success', 'Transaksi uang saku berhasil ditambahkan.');
} catch (\Exception $e) {
DB::rollBack();
return back()->withInput()
->with('error', 'Gagal menambahkan transaksi: ' . $e->getMessage());
return back()->withInput()->with('error', 'Gagal menambahkan transaksi: ' . $e->getMessage());
}
}
/**
* Tampilkan detail transaksi
*/
public function show($id)
{
$transaksi = UangSaku::with('santri')->findOrFail($id);
return view('admin.uang-saku.show', compact('transaksi'));
}
/**
* Form edit transaksi
*/
public function edit($id)
{
$transaksi = UangSaku::with('santri')->findOrFail($id);
$santriList = Santri::where('status', 'Aktif')
->select('id_santri', 'nama_lengkap')
->orderBy('nama_lengkap')
->get();
return view('admin.uang-saku.edit', compact('transaksi', 'santriList'));
}
/**
* Update transaksi
*/
public function update(Request $request, $id)
{
$transaksi = UangSaku::findOrFail($id);
$validated = $request->validate([
'jenis_transaksi' => 'required|in:pemasukan,pengeluaran',
'nominal' => 'required|numeric|min:1|max:99999999',
'keterangan' => 'nullable|string|max:500',
'tanggal_transaksi' => 'required|date',
], [
'jenis_transaksi.required' => 'Jenis transaksi wajib dipilih.',
'nominal.required' => 'Nominal wajib diisi.',
'nominal.numeric' => 'Nominal harus berupa angka.',
'nominal.min' => 'Nominal minimal Rp 1.',
'tanggal_transaksi.required' => 'Tanggal transaksi wajib diisi.',
]);
// Simpan tanggal lama sebelum update, agar recalculate dimulai dari yang paling awal
$tanggalLama = $transaksi->tanggal_transaksi->format('Y-m-d');
DB::beginTransaction();
try {
$transaksi->update($validated);
// Gunakan saveQuietly agar model boot (updating) tidak ikut menghitung ulang saldo
// — recalculate akan dikerjakan secara menyeluruh oleh recalculateSaldoAfter()
$transaksi->fill($validated)->saveQuietly();
// Recalculate semua saldo setelah transaksi ini
$this->recalculateSaldoAfter($transaksi->id_santri, $transaksi->tanggal_transaksi);
// Recalculate dari tanggal yang paling awal antara tanggal lama dan baru
$tanggalBaru = $validated['tanggal_transaksi'];
$tanggalMulai = min($tanggalLama, $tanggalBaru);
$this->recalculateSaldoAfter($transaksi->id_santri, $tanggalMulai);
DB::commit();
Cache::forget('santri_aktif_uang_saku');
return redirect()->route('admin.uang-saku.index')
->with('success', 'Transaksi berhasil diperbarui.');
} catch (\Exception $e) {
DB::rollBack();
return back()->withInput()
->with('error', 'Gagal memperbarui transaksi: ' . $e->getMessage());
return back()->withInput()->with('error', 'Gagal memperbarui transaksi: ' . $e->getMessage());
}
}
/**
* Hapus transaksi
*/
public function destroy($id)
{
$transaksi = UangSaku::findOrFail($id);
$idSantri = $transaksi->id_santri;
$tanggal = $transaksi->tanggal_transaksi;
$tanggal = $transaksi->tanggal_transaksi->format('Y-m-d');
DB::beginTransaction();
try {
$transaksi->delete();
// Recalculate saldo setelah transaksi dihapus
$this->recalculateSaldoAfter($idSantri, $tanggal);
DB::commit();
Cache::forget('santri_aktif_uang_saku');
return redirect()->route('admin.uang-saku.index')
->with('success', 'Transaksi berhasil dihapus.');
} catch (\Exception $e) {
@ -264,35 +285,21 @@ public function destroy($id)
}
}
/**
* Tampilkan riwayat uang saku per santri dengan filter tanggal
*/
public function riwayat(Request $request, $id_santri)
{
$santri = Santri::where('id_santri', $id_santri)->firstOrFail();
// Default: bulan ini
$tanggalDari = $request->filled('tanggal_dari')
? $request->tanggal_dari
: now()->startOfMonth()->format('Y-m-d');
$tanggalDari = $request->filled('tanggal_dari') ? $request->tanggal_dari : now()->startOfMonth()->format('Y-m-d');
$tanggalSampai = $request->filled('tanggal_sampai') ? $request->tanggal_sampai : now()->endOfMonth()->format('Y-m-d');
$tanggalSampai = $request->filled('tanggal_sampai')
? $request->tanggal_sampai
: now()->endOfMonth()->format('Y-m-d');
// Query transaksi dengan filter tanggal
$query = UangSaku::where('id_santri', $id_santri);
if ($tanggalDari && $tanggalSampai) {
$query->whereBetween('tanggal_transaksi', [$tanggalDari, $tanggalSampai]);
}
$query = UangSaku::where('id_santri', $id_santri)
->whereBetween('tanggal_transaksi', [$tanggalDari, $tanggalSampai]);
$transaksi = $query->orderBy('tanggal_transaksi', 'desc')
->orderBy('created_at', 'desc')
->paginate(20)
->appends($request->query());
// Statistik dengan filter tanggal
$totalPemasukan = UangSaku::where('id_santri', $id_santri)
->where('jenis_transaksi', 'pemasukan')
->whereBetween('tanggal_transaksi', [$tanggalDari, $tanggalSampai])
@ -303,82 +310,79 @@ public function riwayat(Request $request, $id_santri)
->whereBetween('tanggal_transaksi', [$tanggalDari, $tanggalSampai])
->sum('nominal');
// Saldo terakhir tetap dari keseluruhan transaksi
$saldoTerakhir = $santri->saldo_uang_saku;
// Ambil saldo aktual dari transaksi TERAKHIR santri ini (real-time, bukan dari filter)
$lastTx = UangSaku::where('id_santri', $id_santri)
->orderByDesc('tanggal_transaksi')
->orderByDesc('created_at')
->first();
$saldoTerakhir = $lastTx ? (float)$lastTx->saldo_sesudah : 0;
// Data untuk grafik dengan filter tanggal
$dataGrafik = UangSaku::where('id_santri', $id_santri)
->whereBetween('tanggal_transaksi', [$tanggalDari, $tanggalSampai])
->select(
DB::raw('DATE(tanggal_transaksi) as tanggal'),
DB::raw('SUM(CASE WHEN jenis_transaksi = "pemasukan" THEN nominal ELSE 0 END) as pemasukan'),
DB::raw('SUM(CASE WHEN jenis_transaksi = "pengeluaran" THEN nominal ELSE 0 END) as pengeluaran')
DB::raw('SUM(CASE WHEN jenis_transaksi="pemasukan" THEN nominal ELSE 0 END) as pemasukan'),
DB::raw('SUM(CASE WHEN jenis_transaksi="pengeluaran" THEN nominal ELSE 0 END) as pengeluaran')
)
->groupBy('tanggal')
->orderBy('tanggal')
->get();
// Jika tidak ada transaksi di rentang tanggal, buat data kosong
if ($dataGrafik->isEmpty()) {
$dataGrafik = collect([
(object)[
'tanggal' => $tanggalDari,
'pemasukan' => 0,
'pengeluaran' => 0
]
]);
$dataGrafik = collect([(object)['tanggal' => $tanggalDari, 'pemasukan' => 0, 'pengeluaran' => 0]]);
}
// Info periode
$periodeDari = \Carbon\Carbon::parse($tanggalDari);
$periodeSampai = \Carbon\Carbon::parse($tanggalSampai);
$periodeDari = Carbon::parse($tanggalDari);
$periodeSampai = Carbon::parse($tanggalSampai);
return view('admin.uang-saku.riwayat', compact(
'santri',
'transaksi',
'totalPemasukan',
'totalPengeluaran',
'saldoTerakhir',
'dataGrafik',
'tanggalDari',
'tanggalSampai',
'periodeDari',
'periodeSampai'
'santri', 'transaksi',
'totalPemasukan', 'totalPengeluaran', 'saldoTerakhir',
'dataGrafik', 'tanggalDari', 'tanggalSampai',
'periodeDari', 'periodeSampai'
));
}
/**
* Helper: Recalculate saldo untuk transaksi setelah tanggal tertentu
* Hitung ulang saldo_sebelum & saldo_sesudah untuk semua transaksi
* milik $idSantri yang tanggalnya >= $tanggal.
*
* Dipanggil setelah store / update / destroy agar urutan saldo
* tetap konsisten meski transaksi disisipkan di tengah.
*/
private function recalculateSaldoAfter($idSantri, $tanggal)
{
// Pastikan format tanggal string (bukan Carbon object)
$tanggal = $tanggal instanceof \Carbon\Carbon
? $tanggal->format('Y-m-d')
: $tanggal;
$transaksiSetelah = UangSaku::where('id_santri', $idSantri)
->where('tanggal_transaksi', '>=', $tanggal)
->orderBy('tanggal_transaksi')
->orderBy('created_at')
->orderBy('id')
->get();
foreach ($transaksiSetelah as $index => $trans) {
if ($index === 0) {
// Transaksi pertama: ambil saldo dari transaksi sebelumnya
$saldoSebelumnya = UangSaku::where('id_santri', $idSantri)
->where('id', '<', $trans->id)
->orderBy('tanggal_transaksi', 'desc')
->orderBy('created_at', 'desc')
// Cari saldo_sesudah transaksi tepat sebelum batch ini
$prev = UangSaku::where('id_santri', $idSantri)
->where('tanggal_transaksi', '<', $tanggal)
->orderByDesc('tanggal_transaksi')
->orderByDesc('created_at')
->orderByDesc('id')
->first();
$trans->saldo_sebelum = $saldoSebelumnya ? $saldoSebelumnya->saldo_sesudah : 0;
$trans->saldo_sebelum = $prev ? (float)$prev->saldo_sesudah : 0;
} else {
$trans->saldo_sebelum = $transaksiSetelah[$index - 1]->saldo_sesudah;
$trans->saldo_sebelum = (float)$transaksiSetelah[$index - 1]->saldo_sesudah;
}
if ($trans->jenis_transaksi === 'pemasukan') {
$trans->saldo_sesudah = $trans->saldo_sebelum + $trans->nominal;
} else {
$trans->saldo_sesudah = $trans->saldo_sebelum - $trans->nominal;
}
$trans->saldo_sesudah = $trans->jenis_transaksi === 'pemasukan'
? $trans->saldo_sebelum + (float)$trans->nominal
: $trans->saldo_sebelum - (float)$trans->nominal;
$trans->saveQuietly(); // Save tanpa trigger event
$trans->saveQuietly();
}
}
}

View File

@ -6,183 +6,363 @@
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\Santri;
use App\Models\SantriAccount;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
class UserController extends Controller
{
// ══════════════════ AKUN SANTRI (WEB) ══════════════════
/**
* Tampilkan daftar akun Santri.
* Daftar akun santri
*/
public function santriAccounts()
{
$users = User::where('role', 'santri')->with('santri')->get();
$santris_tanpa_akun = Santri::whereDoesntHave('user', function($query) {
$query->where('role', 'santri');
$users = SantriAccount::where('role', 'santri')->with('santri')->get();
$santris_tanpa_akun = Santri::whereDoesntHave('santriAccount', function ($q) {
$q->where('role', 'santri');
})->get();
return view('admin.users.santri_accounts', compact('users', 'santris_tanpa_akun'));
}
/**
* Tampilkan daftar akun Wali Santri.
* Buat akun santri untuk satu santri langsung (1 klik)
*/
public function buatAkunSantri(Request $request, string $idSantri)
{
$santri = Santri::where('id_santri', $idSantri)->firstOrFail();
if (!$santri->nis) {
return redirect()->back()
->with('error', 'Santri ' . $santri->nama_lengkap . ' belum memiliki NIS.');
}
$sudahAda = SantriAccount::where('role', 'santri')
->where('id_santri', $idSantri)->exists();
if ($sudahAda) {
return redirect()->back()
->with('error', 'Santri ' . $santri->nama_lengkap . ' sudah memiliki akun.');
}
SantriAccount::create([
'id_santri' => $santri->id_santri,
'username' => $santri->nama_lengkap,
'password' => Hash::make($santri->nis),
'role' => 'santri',
]);
return redirect()->back()
->with('success', 'Akun santri ' . $santri->nama_lengkap . ' berhasil dibuat. Username: ' . $santri->nama_lengkap . ' | Password: ' . $santri->nis);
}
/**
* Buat akun santri untuk semua santri yang belum punya akun (1 klik massal)
*/
public function buatSemuaAkunSantri(Request $request)
{
$santriList = Santri::whereDoesntHave('santriAccount', function ($q) {
$q->where('role', 'santri');
})->whereNotNull('nis')->get();
if ($santriList->isEmpty()) {
return redirect()->back()
->with('info', 'Semua santri sudah memiliki akun.');
}
$berhasil = 0;
foreach ($santriList as $santri) {
SantriAccount::create([
'id_santri' => $santri->id_santri,
'username' => $santri->nama_lengkap,
'password' => Hash::make($santri->nis),
'role' => 'santri',
]);
$berhasil++;
}
return redirect()->back()
->with('success', $berhasil . ' akun santri berhasil dibuat sekaligus.');
}
/**
* Hapus akun santri
*/
public function destroySantriAccount(string $id)
{
$account = SantriAccount::where('role', 'santri')->findOrFail($id);
$nama = $account->santri ? $account->santri->nama_lengkap : $account->username;
$account->delete();
return redirect()->back()
->with('success', 'Akun santri ' . $nama . ' berhasil dihapus.');
}
// ══════════════════ AKUN WALI (MOBILE) ══════════════════
/**
* Daftar akun wali
*/
public function waliAccounts()
{
$users = User::where('role', 'wali')->with('santri')->get();
$users = SantriAccount::where('role', 'wali')->with('santri')->get();
$santris_tanpa_wali = Santri::whereDoesntHave('waliUser')->get();
$santris_tanpa_wali = Santri::whereDoesntHave('santriAccount', function ($q) {
$q->where('role', 'wali');
})->get();
return view('admin.users.wali_accounts', compact('users', 'santris_tanpa_wali'));
}
/**
* Tampilkan form untuk membuat akun baru.
* Resolve username untuk akun wali.
*
* Aturan:
* - Default : nama_orang_tua (sama seperti sebelumnya, username = nama ortu)
* - Fallback : "nama_orang_tua - nama_santri"
* hanya dipakai jika nama_orang_tua sudah dipakai
* akun wali lain (cek DB + array in-memory untuk proses massal).
*
* @param Santri $santri
* @param array $usernameYangSudahDipakai username yang sudah dibuat dalam iterasi massal saat ini
*/
public function createAccount(string $role)
private function resolveUsernameWali(Santri $santri, array $usernameYangSudahDipakai = []): string
{
if (!in_array($role, ['santri', 'wali'])) {
abort(404);
$usernameDefault = $santri->nama_orang_tua;
// Cek di database: apakah nama ortu ini sudah jadi username wali lain?
$sudahDiDbOlehLain = SantriAccount::where('role', 'wali')
->where('username', $usernameDefault)
->where('id_santri', '!=', $santri->id_santri)
->exists();
// Cek di array in-memory (untuk proses massal dalam 1 request)
$sudahDiMemoriOlehLain = in_array($usernameDefault, $usernameYangSudahDipakai);
if ($sudahDiDbOlehLain || $sudahDiMemoriOlehLain) {
// Fallback: tambahkan nama santri agar unik
return $usernameDefault . ' - ' . $santri->nama_lengkap;
}
if ($role === 'santri') {
$list_data = Santri::whereDoesntHave('user', function($query) {
$query->where('role', 'santri');
})->get();
} else {
// Wali: ambil santri yang belum punya akun wali
$list_data = Santri::whereDoesntHave('waliUser')->get();
}
return view('admin.users.create_account', compact('role', 'list_data'));
// Normal: cukup nama orang tua saja
return $usernameDefault;
}
/**
* Simpan akun baru.
* Buat akun wali untuk satu santri langsung (1 klik)
*/
public function storeAccount(Request $request, string $role)
public function buatAkunWali(Request $request, string $idSantri)
{
if (!in_array($role, ['santri', 'wali'])) {
abort(404);
$santri = Santri::where('id_santri', $idSantri)->firstOrFail();
if (!$santri->nis) {
return redirect()->back()
->with('error', 'Santri ' . $santri->nama_lengkap . ' belum memiliki NIS.');
}
// Validasi berbeda untuk santri dan wali
$rules = [
'role_id' => [
'required',
Rule::exists('santris', 'id_santri'),
function ($attribute, $value, $fail) use ($role) {
$exists = User::where('role', $role)
->where('role_id', $value)
->exists();
if ($exists) {
$fail("Santri ini sudah memiliki akun {$role}.");
}
},
],
'username' => 'required|string|max:255|unique:users,username',
];
// Untuk wali: password tidak perlu min karena otomatis dari NIS
// Untuk santri: password minimal 8 karakter
if ($role === 'wali') {
$rules['password'] = 'required|string|confirmed';
} else {
$rules['password'] = 'required|string|min:8|confirmed';
if (!$santri->nama_orang_tua) {
return redirect()->back()
->with('error', 'Santri ' . $santri->nama_lengkap . ' belum memiliki data nama orang tua.');
}
$messages = [
'role_id.required' => 'Wajib memilih santri.',
'role_id.exists' => 'Data santri tidak ditemukan.',
'username.unique' => 'Username sudah digunakan.',
'username.required' => 'Username wajib diisi.',
'password.required' => 'Password wajib diisi.',
'password.min' => 'Password minimal 8 karakter.',
'password.confirmed' => 'Konfirmasi password tidak cocok.',
];
$sudahAda = SantriAccount::where('role', 'wali')
->where('id_santri', $idSantri)->exists();
$validated = $request->validate($rules, $messages);
if ($sudahAda) {
return redirect()->back()
->with('error', 'Wali santri ' . $santri->nama_lengkap . ' sudah memiliki akun.');
}
// Ambil data santri
$santri = Santri::where('id_santri', $validated['role_id'])->firstOrFail();
$username = $this->resolveUsernameWali($santri);
// Untuk wali: name = nama orang tua (jika ada) atau nama santri
// Untuk santri: name = nama santri
$name = ($role === 'wali')
? ($santri->nama_orang_tua ?? $santri->nama_lengkap)
: $santri->nama_lengkap;
// Simpan User
User::create([
'name' => $name,
'username' => $validated['username'],
'password' => Hash::make($validated['password']),
'role' => $role,
'role_id' => $validated['role_id'],
SantriAccount::create([
'id_santri' => $santri->id_santri,
'username' => $username,
'password' => Hash::make($santri->nis),
'role' => 'wali',
]);
$successMsg = $role === 'wali'
? "Akun wali untuk santri {$santri->nama_lengkap} berhasil dibuat. Login: Username={$validated['username']}, Password=NIS"
: "Akun santri {$santri->nama_lengkap} berhasil dibuat.";
return redirect()->route('admin.users.'.$role.'_accounts')
->with('success', $successMsg);
return redirect()->back()
->with('success', 'Akun wali untuk ' . $santri->nama_lengkap . ' berhasil dibuat. Username: ' . $username . ' | Password: ' . $santri->nis);
}
/**
* Hapus akun santri/wali.
* Buat akun wali untuk semua santri yang belum punya akun wali (1 klik massal)
*/
public function destroyAccount(string $role, string $userId)
public function buatSemuaAkunWali(Request $request)
{
if (!in_array($role, ['santri', 'wali'])) {
abort(404);
$santriList = Santri::whereDoesntHave('santriAccount', function ($q) {
$q->where('role', 'wali');
})->whereNotNull('nis')->whereNotNull('nama_orang_tua')->get();
if ($santriList->isEmpty()) {
return redirect()->back()
->with('info', 'Semua santri sudah memiliki akun wali.');
}
// Cari user berdasarkan ID
$user = User::findOrFail($userId);
$berhasil = 0;
$gagal = 0;
// Pastikan user yang akan dihapus adalah role yang sesuai
if ($user->role !== $role) {
return redirect()->back()->with('error', 'Akun tidak valid.');
// Lacak username yg dibuat dalam iterasi ini agar
// santri berikut dg nama ortu sama langsung dapat fallback
$usernameYangSudahDipakai = [];
foreach ($santriList as $santri) {
if (!$santri->nama_orang_tua) {
$gagal++;
continue;
}
$userName = $user->name;
$user->delete();
$username = $this->resolveUsernameWali($santri, $usernameYangSudahDipakai);
return redirect()->route('admin.users.'.$role.'_accounts')
->with('success', "Akun {$role} {$userName} berhasil dihapus.");
SantriAccount::create([
'id_santri' => $santri->id_santri,
'username' => $username,
'password' => Hash::make($santri->nis),
'role' => 'wali',
]);
$usernameYangSudahDipakai[] = $username;
$berhasil++;
}
$pesan = $berhasil . ' akun wali berhasil dibuat.';
if ($gagal > 0) {
$pesan .= ' ' . $gagal . ' dilewati karena data orang tua tidak lengkap.';
}
return redirect()->back()->with('success', $pesan);
}
/**
* Reset password akun santri/wali ke default (NIS).
* Hapus akun wali
*/
public function resetPassword(string $role, string $userId)
public function destroyWaliAccount(string $id)
{
if (!in_array($role, ['santri', 'wali'])) {
abort(404);
$account = SantriAccount::where('role', 'wali')->findOrFail($id);
$nama = $account->santri ? $account->santri->nama_lengkap : $account->username;
$account->delete();
return redirect()->back()
->with('success', 'Akun wali ' . $nama . ' berhasil dihapus.');
}
// Cari user berdasarkan ID
$user = User::findOrFail($userId);
// ══════════════════ AKUN ADMIN ══════════════════
// Pastikan user adalah role yang sesuai
if ($user->role !== $role) {
return redirect()->back()->with('error', 'Akun tidak valid.');
/**
* Daftar akun admin
*/
public function adminAccounts()
{
$admins = User::whereIn('role', ['super_admin', 'akademik', 'pamong'])
->orderByRaw("FIELD(role, 'super_admin', 'akademik', 'pamong')")
->orderBy('name')
->get();
return view('admin.users.admin_accounts', compact('admins'));
}
// Ambil santri terkait
$santri = Santri::where('id_santri', $user->role_id)->first();
if (!$santri || !$santri->nis) {
return redirect()->back()->with('error', 'NIS santri tidak ditemukan. Tidak dapat mereset password.');
/**
* Form buat akun admin baru
*/
public function createAdminAccount()
{
return view('admin.users.admin_form', [
'admin' => null,
'action' => route('admin.users.admin_store'),
'method' => 'POST',
]);
}
// Reset password ke NIS
$user->password = Hash::make($santri->nis);
$user->save();
/**
* Simpan akun admin baru
*/
public function storeAdminAccount(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'role' => 'required|in:akademik,pamong',
'password' => 'required|string|min:8|confirmed',
], [
'name.required' => 'Nama wajib diisi.',
'email.required' => 'Email wajib diisi.',
'email.unique' => 'Email sudah digunakan.',
'role.required' => 'Role wajib dipilih.',
'password.required' => 'Password wajib diisi.',
'password.min' => 'Password minimal 8 karakter.',
'password.confirmed'=> 'Konfirmasi password tidak cocok.',
]);
return redirect()->route('admin.users.'.$role.'_accounts')
->with('success', "Password akun {$user->name} berhasil direset ke NIS: {$santri->nis}");
User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'username' => $validated['email'],
'password' => Hash::make($validated['password']),
'role' => $validated['role'],
]);
return redirect()->route('admin.users.admin_accounts')
->with('success', 'Akun ' . $validated['role'] . ' untuk ' . $validated['name'] . ' berhasil dibuat.');
}
/**
* Form edit akun admin
*/
public function editAdminAccount(string $userId)
{
$admin = User::whereIn('role', ['akademik', 'pamong'])->findOrFail($userId);
return view('admin.users.admin_form', [
'admin' => $admin,
'action' => route('admin.users.admin_update', $userId),
'method' => 'PUT',
]);
}
/**
* Update akun admin
*/
public function updateAdminAccount(Request $request, string $userId)
{
$admin = User::whereIn('role', ['akademik', 'pamong'])->findOrFail($userId);
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email,' . $userId,
'role' => 'required|in:akademik,pamong',
'password' => 'nullable|string|min:8|confirmed',
]);
$admin->name = $validated['name'];
$admin->email = $validated['email'];
$admin->username = $validated['email'];
$admin->role = $validated['role'];
if (!empty($validated['password'])) {
$admin->password = Hash::make($validated['password']);
}
$admin->save();
return redirect()->route('admin.users.admin_accounts')
->with('success', 'Akun ' . $admin->name . ' berhasil diperbarui.');
}
/**
* Hapus akun admin
*/
public function destroyAdminAccount(string $userId)
{
$admin = User::whereIn('role', ['akademik', 'pamong'])->findOrFail($userId);
$nama = $admin->name;
$admin->delete();
return redirect()->route('admin.users.admin_accounts')
->with('success', 'Akun ' . $nama . ' berhasil dihapus.');
}
}

View File

@ -23,7 +23,7 @@ public function today(Request $request)
{
try {
$user = $request->user();
$idSantri = $user->role_id; // Santri atau wali punya role_id = id_santri
$idSantri = $user->id_santri; // id_santri dari santri_accounts
$tanggal = $request->get('tanggal', now()->format('Y-m-d'));
$selectedDate = Carbon::parse($tanggal);
@ -120,7 +120,7 @@ public function week(Request $request)
{
try {
$user = $request->user();
$idSantri = $user->role_id;
$idSantri = $user->id_santri;
$startDate = Carbon::now()->startOfWeek();
$endDate = Carbon::now()->endOfWeek();
@ -221,7 +221,7 @@ public function month(Request $request)
{
try {
$user = $request->user();
$idSantri = $user->role_id;
$idSantri = $user->id_santri;
$bulan = $request->get('bulan', now()->format('Y-m'));
$date = Carbon::parse($bulan . '-01');

View File

@ -4,7 +4,7 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\SantriAccount;
use App\Models\Santri;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
@ -13,16 +13,16 @@
class ApiAuthController extends Controller
{
/**
* Login Santri/Wali via Mobile
* Login Wali via Mobile (Sanctum token)
*
* Request:
* - id_santri (username)
* - username
* - password
*
* Response:
* - token
* - user (name, role, role_id)
* - santri (data lengkap santri jika role=santri)
* - user (role, id_santri)
* - santri (data lengkap)
*/
public function login(Request $request)
{
@ -31,47 +31,39 @@ public function login(Request $request)
'password' => 'required|string',
]);
// Cari user berdasarkan username (id_santri)
$user = User::where('username', $request->id_santri)->first();
// -- Cari akun di santri_accounts --
$account = SantriAccount::where('username', $request->id_santri)->first();
// Validasi user dan password
if (!$user || !Hash::check($request->password, $user->password)) {
if (!$account || !Hash::check($request->password, $account->password)) {
throw ValidationException::withMessages([
'id_santri' => ['ID Santri atau password salah.'],
]);
}
// Cek apakah user adalah santri atau wali
if (!in_array($user->role, ['santri', 'wali'])) {
return response()->json([
'success' => false,
'message' => 'Akun ini tidak memiliki akses ke aplikasi mobile.',
], 403);
}
// -- Hapus token lama --
$account->tokens()->delete();
// Hapus token lama (optional, untuk keamanan)
$user->tokens()->delete();
// -- Buat token baru --
$token = $account->createToken('mobile-app')->plainTextToken;
// Buat token baru
$token = $user->createToken('mobile-app')->plainTextToken;
// -- Update last_login --
$account->update(['last_login' => now()]);
// Prepare response data
// -- Response data --
$responseData = [
'success' => true,
'message' => 'Login berhasil',
'token' => $token,
'user' => [
'name' => $user->name,
'role' => $user->role,
'role_id' => $user->role_id,
'name' => $account->santri->nama_lengkap ?? '-',
'role' => $account->role,
'role_id' => $account->id_santri,
],
];
// Jika santri atau wali, sertakan data santri
// Untuk wali, role_id menyimpan id_santri yang diwali (anaknya)
if (in_array($user->role, ['santri', 'wali'])) {
// -- Sertakan data santri --
$santri = Santri::with(['kelasSantri.kelas.kelompok', 'kelasPrimary.kelas'])
->where('id_santri', $user->role_id)
->where('id_santri', $account->id_santri)
->select([
'id_santri',
'nis',
@ -87,10 +79,8 @@ public function login(Request $request)
->first();
if ($santri) {
// Build kelas_list grouped by kelompok
$kelasList = $this->buildKelasListGrouped($santri);
// Get primary kelas name for backward compatibility
$kelasName = 'Belum Ada Kelas';
if ($santri->kelasPrimary && $santri->kelasPrimary->kelas) {
$kelasName = $santri->kelasPrimary->kelas->nama_kelas;
@ -110,13 +100,12 @@ public function login(Request $request)
'nomor_hp_ortu' => $santri->nomor_hp_ortu,
'foto' => $santri->foto,
'foto_url' => $santri->foto_url,
'kelas' => $kelasName, // Backward compatibility
'kelas_list' => $kelasList, // NEW: Multiple kelas grouped
'kelas' => $kelasName,
'kelas_list' => $kelasList,
];
} else {
$responseData['santri'] = null;
}
}
return response()->json($responseData, 200);
}
@ -126,7 +115,6 @@ public function login(Request $request)
*/
public function logout(Request $request)
{
// Hapus token yang sedang digunakan
$request->user()->currentAccessToken()->delete();
return response()->json([
@ -137,24 +125,13 @@ public function logout(Request $request)
/**
* Get Profile Santri yang sedang login
* Untuk role santri: tampilkan data diri sendiri
* Untuk role wali: tampilkan data santri yang diwali (anaknya)
*/
public function profile(Request $request)
{
$user = $request->user();
$account = $request->user();
// Hanya santri dan wali yang bisa akses profil
if (!in_array($user->role, ['santri', 'wali'])) {
return response()->json([
'success' => false,
'message' => 'Hanya santri/wali yang bisa mengakses profil.',
], 403);
}
// Untuk santri dan wali, role_id menyimpan id_santri
$santri = Santri::with(['kelasSantri.kelas.kelompok', 'kelasPrimary.kelas'])
->where('id_santri', $user->role_id)
->where('id_santri', $account->id_santri)
->select([
'id_santri',
'nis',
@ -177,10 +154,8 @@ public function profile(Request $request)
], 404);
}
// Build kelas_list grouped by kelompok
$kelasList = $this->buildKelasListGrouped($santri);
// Get primary kelas name for backward compatibility
$kelasName = 'Belum Ada Kelas';
if ($santri->kelasPrimary && $santri->kelasPrimary->kelas) {
$kelasName = $santri->kelasPrimary->kelas->nama_kelas;
@ -200,10 +175,10 @@ public function profile(Request $request)
'daerah_asal' => $santri->daerah_asal,
'nama_orang_tua' => $santri->nama_orang_tua,
'nomor_hp_ortu' => $santri->nomor_hp_ortu,
'foto_url' => $santri->foto_url, // Accessor dari Model Santri
'foto_url' => $santri->foto_url,
'bergabung_sejak' => $santri->created_at->format('d F Y'),
'kelas' => $kelasName, // Backward compatibility
'kelas_list' => $kelasList, // NEW: Multiple kelas grouped
'kelas' => $kelasName,
'kelas_list' => $kelasList,
]
], 200);
}

View File

@ -15,7 +15,7 @@ class ApiBeritaController extends Controller
public function index(Request $request)
{
try {
$idSantri = $request->user()->role_id;
$idSantri = $request->user()->id_santri;
$santri = Santri::with('kelasPrimary.kelas')->where('id_santri', $idSantri)->first();
@ -82,7 +82,7 @@ public function index(Request $request)
public function show(Request $request, $idBerita)
{
try {
$idSantri = $request->user()->role_id;
$idSantri = $request->user()->id_santri;
$berita = Berita::where('id_berita', $idBerita)
->where('status', 'published')

View File

@ -108,16 +108,7 @@ public function overview(Request $request)
{
try {
$user = $request->user();
// Validasi role
if (!in_array($user->role, ['santri', 'wali'])) {
return response()->json([
'success' => false,
'message' => 'Akses ditolak. Role: ' . $user->role,
], 403);
}
$idSantri = $user->role_id;
$idSantri = $user->id_santri;
$santri = Santri::with(['kelasPrimary.kelas.kelompok', 'kelasSantri.kelas.kelompok'])
->where('id_santri', $idSantri)
@ -221,7 +212,7 @@ public function listMateriByKategori(Request $request, $kategori)
{
try {
$user = $request->user();
$idSantri = $user->role_id;
$idSantri = $user->id_santri;
$validKategori = ['Al-Qur\'an', 'Hadist', 'Materi Tambahan'];
if (!in_array($kategori, $validKategori)) {
@ -304,7 +295,7 @@ public function detailCapaian(Request $request, $idCapaian)
{
try {
$user = $request->user();
$idSantri = $user->role_id;
$idSantri = $user->id_santri;
$capaian = Capaian::where('id_capaian', $idCapaian)
->where('id_santri', $idSantri)
@ -386,7 +377,7 @@ public function grafikProgress(Request $request)
{
try {
$user = $request->user();
$idSantri = $user->role_id;
$idSantri = $user->id_santri;
$semesters = Semester::orderBy('tahun_ajaran')
->orderBy('periode')
@ -435,12 +426,7 @@ public function trendSemester(Request $request)
{
try {
$user = $request->user();
if (!in_array($user->role, ['santri', 'wali'])) {
return response()->json(['success' => false, 'message' => 'Akses ditolak'], 403);
}
$idSantri = $user->role_id;
$idSantri = $user->id_santri;
$santri = Santri::with(['kelasPrimary.kelas.kelompok'])->where('id_santri', $idSantri)->first();
if (!$santri) {
@ -514,12 +500,7 @@ public function dashboard(Request $request)
{
try {
$user = $request->user();
if (!in_array($user->role, ['santri', 'wali'])) {
return response()->json(['success' => false, 'message' => 'Akses ditolak'], 403);
}
$idSantri = $user->role_id;
$idSantri = $user->id_santri;
$santri = Santri::with(['kelasPrimary.kelas.kelompok', 'kelasSantri.kelas.kelompok'])
->where('id_santri', $idSantri)
->first();

View File

@ -20,16 +20,8 @@ public function index(Request $request)
try {
$user = Auth::user();
// Pastikan user adalah santri atau wali
if (!in_array($user->role, ['santri', 'wali'])) {
return response()->json([
'success' => false,
'message' => 'Akses ditolak. Hanya santri/wali yang dapat mengakses.',
], 403);
}
// Ambil id_santri dari role_id (untuk santri dan wali, role_id = id_santri)
$idSantri = $user->role_id;
// Ambil id_santri dari akun yang login
$idSantri = $user->id_santri;
if (!$idSantri) {
return response()->json([
@ -70,7 +62,7 @@ public function index(Request $request)
'success' => true,
'message' => 'Data kepulangan berhasil diambil.',
'data' => [
'kepulangan' => $kepulangan->map(function($item) {
'kepulangan' => collect($kepulangan->items())->map(function($item) {
return [
'id_kepulangan' => $item->id_kepulangan,
'tanggal_izin' => $item->tanggal_izin->format('Y-m-d'),
@ -129,15 +121,7 @@ public function show($idKepulangan)
try {
$user = Auth::user();
// Pastikan user adalah santri atau wali
if (!in_array($user->role, ['santri', 'wali'])) {
return response()->json([
'success' => false,
'message' => 'Akses ditolak.',
], 403);
}
$idSantri = $user->role_id;
$idSantri = $user->id_santri;
// Get kepulangan dengan validasi kepemilikan
$kepulangan = Kepulangan::with('santri')
@ -220,7 +204,7 @@ public function kuota(Request $request)
], 403);
}
$idSantri = $user->role_id;
$idSantri = $user->id_santri;
if (!$idSantri) {
return response()->json([
@ -275,4 +259,67 @@ public function kuota(Request $request)
], 500);
}
}
/**
* Notifikasi status kepulangan santri saat ini
* GET /api/v1/kepulangan/notifikasi
*/
public function notifikasiKepulangan(Request $request)
{
try {
$user = Auth::user();
if (!in_array($user->role, ['santri', 'wali'])) {
return response()->json([
'success' => false,
'message' => 'Akses ditolak.',
], 403);
}
$idSantri = $user->id_santri;
$today = Carbon::today();
// Cari kepulangan yang sedang aktif (tanggal hari ini ada di antara tanggal_pulang dan tanggal_kembali)
$kepulangan = Kepulangan::where('id_santri', $idSantri)
->where('status', 'Disetujui')
->where('tanggal_pulang', '<=', $today)
->where('tanggal_kembali', '>=', $today)
->orderBy('tanggal_kembali', 'desc')
->first();
if (!$kepulangan) {
return response()->json([
'success' => true,
'data' => [
'sedang_pulang' => false,
'tanggal_kembali' => null,
'sisa_hari' => 0,
'status' => null,
],
]);
}
$tanggalKembali = Carbon::parse($kepulangan->tanggal_kembali);
$sisaHari = $today->diffInDays($tanggalKembali, false); // negatif jika sudah lewat
$statusKepulangan = $sisaHari < 0 ? 'terlambat' : 'aktif';
return response()->json([
'success' => true,
'data' => [
'sedang_pulang' => true,
'tanggal_kembali' => $tanggalKembali->format('Y-m-d'),
'tanggal_kembali_formatted' => $tanggalKembali->locale('id')->isoFormat('D MMMM Y'),
'sisa_hari' => (int) $sisaHari,
'status' => $statusKepulangan,
'alasan' => $kepulangan->alasan,
],
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Terjadi kesalahan: ' . $e->getMessage(),
], 500);
}
}
}

View File

@ -16,7 +16,7 @@ public function index(Request $request)
{
try {
// Ambil id_santri dari user yang login (wali)
$idSantri = $request->user()->role_id;
$idSantri = $request->user()->id_santri;
// Cek santri exist
$santri = Santri::where('id_santri', $idSantri)->first();
@ -91,7 +91,7 @@ public function index(Request $request)
public function show(Request $request, $idKesehatan)
{
try {
$idSantri = $request->user()->role_id;
$idSantri = $request->user()->id_santri;
// Cari data kesehatan
$kesehatan = KesehatanSantri::where('id_kesehatan', $idKesehatan)
@ -134,7 +134,7 @@ public function show(Request $request, $idKesehatan)
public function statistik(Request $request)
{
try {
$idSantri = $request->user()->role_id;
$idSantri = $request->user()->id_santri;
// Hitung total per status
$totalDirawat = KesehatanSantri::where('id_santri', $idSantri)

View File

@ -20,16 +20,7 @@ public function store(Request $request)
{
try {
$user = Auth::user();
// Validasi role
if (!in_array($user->role, ['santri', 'wali'])) {
return response()->json([
'success' => false,
'message' => 'Akses ditolak.',
], 403);
}
$idSantri = $user->role_id;
$idSantri = $user->id_santri;
// Validasi input
$validated = $request->validate([
@ -98,15 +89,7 @@ public function index(Request $request)
{
try {
$user = Auth::user();
if (!in_array($user->role, ['santri', 'wali'])) {
return response()->json([
'success' => false,
'message' => 'Akses ditolak.',
], 403);
}
$idSantri = $user->role_id;
$idSantri = $user->id_santri;
// Build query
$page = $request->input('page', 1);
@ -176,7 +159,7 @@ public function preview(Request $request)
{
try {
$user = Auth::user();
$idSantri = $user->role_id;
$idSantri = $user->id_santri;
$validated = $request->validate([
'tanggal_pulang' => 'required|date',

View File

@ -16,7 +16,7 @@ class ApiSppController extends Controller
public function statusBulanIni(Request $request)
{
try {
$idSantri = $request->user()->role_id;
$idSantri = $request->user()->id_santri;
$bulanIni = date('n');
$tahunIni = date('Y');
@ -67,7 +67,7 @@ public function statusBulanIni(Request $request)
public function tunggakan(Request $request)
{
try {
$idSantri = $request->user()->role_id;
$idSantri = $request->user()->id_santri;
// Hitung tunggakan
$tunggakanList = PembayaranSpp::where('id_santri', $idSantri)
@ -104,7 +104,7 @@ public function tunggakan(Request $request)
public function riwayat(Request $request)
{
try {
$idSantri = $request->user()->role_id;
$idSantri = $request->user()->id_santri;
// Query riwayat
$query = PembayaranSpp::where('id_santri', $idSantri)
@ -174,7 +174,7 @@ public function riwayat(Request $request)
public function statistik(Request $request)
{
try {
$idSantri = $request->user()->role_id;
$idSantri = $request->user()->id_santri;
$totalLunas = PembayaranSpp::where('id_santri', $idSantri)
->where('status', 'Lunas')

View File

@ -16,7 +16,7 @@ public function saldo(Request $request)
{
try {
// Ambil id_santri dari user yang login (wali)
$idSantri = $request->user()->role_id;
$idSantri = $request->user()->id_santri;
// Ambil data santri
$santri = Santri::where('id_santri', $idSantri)->first();
@ -77,7 +77,7 @@ public function index(Request $request)
{
try {
// Ambil id_santri dari user yang login (wali)
$idSantri = $request->user()->role_id;
$idSantri = $request->user()->id_santri;
// Query transaksi uang saku
$query = UangSaku::where('id_santri', $idSantri)

View File

@ -101,7 +101,7 @@ public function getRiwayatPelanggaran(Request $request)
try {
// Ambil id_santri dari user yang login
$user = $request->user();
$idSantri = $user->role_id; // role_id menyimpan id_santri
$idSantri = $user->id_santri; // id_santri dari santri_accounts
// Query dengan pagination
$perPage = $request->input('per_page', 10);
@ -168,7 +168,7 @@ public function getStatistik(Request $request)
{
try {
$user = $request->user();
$idSantri = $user->role_id;
$idSantri = $user->id_santri;
// Hanya hitung yang sudah dipublish
$totalPelanggaran = RiwayatPelanggaran::where('id_santri', $idSantri)
@ -221,7 +221,7 @@ public function getDetailRiwayat(Request $request, $idRiwayat)
{
try {
$user = $request->user();
$idSantri = $user->role_id;
$idSantri = $user->id_santri;
$riwayat = RiwayatPelanggaran::with([
'kategori:id_kategori,nama_pelanggaran,poin,kafaroh,id_klasifikasi',

View File

@ -33,21 +33,25 @@ public function authenticate(Request $request)
'password' => ['required', 'string'],
]);
// Clear session lama sebelum login
$request->session()->invalidate();
$request->session()->regenerateToken();
// Start session baru
$request->session()->start();
// Coba login dengan username DAN role harus 'admin'
// -- Coba login dengan username --
if (Auth::attempt([
'username' => $credentials['username'],
'password' => $credentials['password'],
'role' => 'admin'
], $request->boolean('remember'))) {
// Regenerate session untuk keamanan
$user = Auth::user();
$adminRoles = ['super_admin', 'akademik', 'pamong'];
// -- Pastikan hanya role admin yang bisa login via form admin --
if (!in_array($user->role, $adminRoles)) {
Auth::logout();
$request->session()->invalidate();
throw ValidationException::withMessages([
'username' => 'Akun ini bukan akun admin.',
]);
}
// -- Regenerate session untuk keamanan --
$request->session()->regenerate();
return redirect()->intended(route('admin.dashboard'));
@ -112,8 +116,8 @@ public function storeRegister(Request $request)
$user = User::create([
'name' => 'Administrator',
'email' => $request->email,
'username' => $request->email, // WAJIB: Gunakan email sebagai username untuk login
'role' => 'admin',
'username' => $request->email,
'role' => 'super_admin',
'password' => Hash::make($request->password),
]);

View File

@ -0,0 +1,242 @@
<?php
// app/Http/Controllers/Auth/AdminForgotPasswordController.php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\PasswordResetOtp;
use App\Models\User;
use App\Mail\OtpMail;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
class AdminForgotPasswordController extends Controller
{
// ══════════════════ STEP 1 : FORM EMAIL ══════════════════
/**
* Tampilkan form input email
*/
public function showEmailForm()
{
return view('admin.auth.forgot_password');
}
/**
* Kirim OTP ke email super admin
*/
public function sendOtp(Request $request)
{
$request->validate([
'email' => 'required|email',
], [
'email.required' => 'Email wajib diisi.',
'email.email' => 'Format email tidak valid.',
]);
// Cek apakah email terdaftar sebagai super_admin
$user = User::where('email', $request->email)
->where('role', 'super_admin')
->first();
if (!$user) {
return back()->withErrors([
'email' => 'Email tidak ditemukan atau bukan akun Super Admin.',
])->withInput();
}
// Hapus OTP lama untuk email ini
PasswordResetOtp::where('email', $request->email)->delete();
// Generate OTP 6 digit
$otp = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
// Simpan ke database dengan expired 10 menit
PasswordResetOtp::create([
'email' => $request->email,
'otp' => $otp,
'expired_at' => now()->addMinutes(10),
]);
// Kirim email OTP
Mail::to($request->email)->send(new OtpMail($otp, $user->name));
// Redirect ke form verifikasi OTP
return redirect()
->route('admin.forgot.verify_form', ['email' => $request->email])
->with('success', 'Kode OTP telah dikirim ke email Anda. Berlaku 10 menit.');
}
// ══════════════════ STEP 2 : VERIFIKASI OTP ══════════════════
/**
* Tampilkan form input OTP
*/
public function showVerifyForm(Request $request)
{
$email = $request->query('email');
if (!$email) {
return redirect()->route('admin.forgot.email_form');
}
return view('admin.auth.verify_otp', compact('email'));
}
/**
* Proses verifikasi OTP
*/
public function verifyOtp(Request $request)
{
$request->validate([
'email' => 'required|email',
'otp' => 'required|string|size:6',
], [
'otp.required' => 'Kode OTP wajib diisi.',
'otp.size' => 'Kode OTP harus 6 digit.',
]);
$record = PasswordResetOtp::where('email', $request->email)
->where('otp', $request->otp)
->where('is_verified', false)
->first();
if (!$record) {
return back()->withErrors([
'otp' => 'Kode OTP tidak valid.',
])->withInput();
}
if ($record->isExpired()) {
$record->delete();
return back()->withErrors([
'otp' => 'Kode OTP sudah expired. Silakan kirim ulang.',
])->withInput();
}
// Tandai OTP sebagai terverifikasi
$record->update(['is_verified' => true]);
// Redirect ke form reset password
return redirect()
->route('admin.forgot.reset_form', ['email' => $request->email])
->with('success', 'Kode OTP valid. Silakan buat password baru.');
}
/**
* Kirim ulang OTP
*/
public function resendOtp(Request $request)
{
$request->validate([
'email' => 'required|email',
]);
$user = User::where('email', $request->email)
->where('role', 'super_admin')
->first();
if (!$user) {
return back()->withErrors([
'email' => 'Email tidak ditemukan.',
]);
}
// Hapus OTP lama
PasswordResetOtp::where('email', $request->email)->delete();
// Generate OTP baru
$otp = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
PasswordResetOtp::create([
'email' => $request->email,
'otp' => $otp,
'expired_at' => now()->addMinutes(10),
]);
Mail::to($request->email)->send(new OtpMail($otp, $user->name));
return back()->with('success', 'Kode OTP baru telah dikirim ke email Anda.');
}
// ══════════════════ STEP 3 : RESET PASSWORD ══════════════════
/**
* Tampilkan form reset password (hanya jika OTP sudah diverifikasi)
*/
public function showResetForm(Request $request)
{
$email = $request->query('email');
if (!$email) {
return redirect()->route('admin.forgot.email_form');
}
// Pastikan OTP sudah diverifikasi
$verified = PasswordResetOtp::where('email', $email)
->where('is_verified', true)
->exists();
if (!$verified) {
return redirect()->route('admin.forgot.email_form')
->withErrors(['email' => 'Silakan verifikasi OTP terlebih dahulu.']);
}
return view('admin.auth.reset_password', compact('email'));
}
/**
* Proses reset password
*/
public function resetPassword(Request $request)
{
$request->validate([
'email' => 'required|email',
'password' => [
'required',
'string',
'min:8',
'confirmed',
'regex:/[A-Z]/', // minimal 1 huruf besar
'regex:/[a-z]/', // minimal 1 huruf kecil
'regex:/[0-9]/', // minimal 1 angka
'regex:/[^A-Za-z0-9]/', // minimal 1 simbol
],
], [
'password.required' => 'Password baru wajib diisi.',
'password.min' => 'Password minimal 8 karakter.',
'password.confirmed' => 'Konfirmasi password tidak cocok.',
'password.regex' => 'Password harus mengandung huruf besar, huruf kecil, angka, dan simbol.',
]);
// Cek ulang apakah OTP sudah terverifikasi
$verified = PasswordResetOtp::where('email', $request->email)
->where('is_verified', true)
->exists();
if (!$verified) {
return redirect()->route('admin.forgot.email_form')
->withErrors(['email' => 'Sesi tidak valid. Silakan ulangi proses.']);
}
// Update password user
$user = User::where('email', $request->email)
->where('role', 'super_admin')
->first();
if (!$user) {
return redirect()->route('admin.forgot.email_form')
->withErrors(['email' => 'Akun tidak ditemukan.']);
}
$user->password = Hash::make($request->password);
$user->save();
// Hapus semua record OTP untuk email ini
PasswordResetOtp::where('email', $request->email)->delete();
return redirect()->route('admin.login')
->with('success', 'Password berhasil diubah! Silakan login dengan password baru.');
}
}

View File

@ -1,26 +1,24 @@
<?php
// app/Http/Controllers/Auth/SantriAuthController.php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class SantriAuthController extends Controller
{
/**
* Tampilkan halaman login santri/wali
*/
public function login()
{
if (Auth::guard('santri')->check()) {
return redirect()->route('santri.dashboard');
}
return view('santri.auth.login');
}
/**
* Proses login santri/wali dengan auto-clear session on failed
*/
public function authenticate(Request $request)
{
$credentials = $request->validate([
@ -31,63 +29,50 @@ public function authenticate(Request $request)
'password.required' => 'Password wajib diisi.',
]);
// ✅ TAMBAHAN 1: Clear old session data
$request->session()->forget(['login_attempts', 'last_attempt_time']);
$request->session()->forget(['login_attempts']);
// Coba login dengan guard default
if (Auth::attempt($credentials, $request->boolean('remember'))) {
$user = Auth::user();
// Cek apakah user adalah santri atau wali
if ($user->role === 'santri' || $user->role === 'wali') {
// ✅ TAMBAHAN 2: Regenerate & clear
$request->session()->regenerate();
$request->session()->forget(['login_attempts', 'last_attempt_time']);
return redirect()->intended(route('santri.dashboard'))
->with('success', 'Selamat datang, ' . $user->name . '!');
}
// ✅ TAMBAHAN 3: Role tidak sesuai - clear session
Auth::logout();
$request->session()->invalidate();
if (Auth::guard('santri')->attempt($credentials, $request->boolean('remember'))) {
$request->session()->regenerate();
return redirect()->back()->withErrors([
'username' => 'Akun Anda tidak memiliki akses ke halaman ini. Gunakan login Admin jika Anda admin.'
])->withInput($request->except('password'));
// Gunakan DB::table langsung — hindari masalah model cast/mutator
$account = Auth::guard('santri')->user();
DB::table('santri_accounts')
->where('id', $account->id)
->update(['last_login' => now()]);
$nama = $account->santri
? $account->santri->nama_lengkap
: $account->username;
return redirect()->route('santri.dashboard')
->with('success', 'Selamat datang, ' . $nama . '!');
}
// ✅ TAMBAHAN 4: Track & auto-flush
$attempts = $request->session()->get('login_attempts', 0) + 1;
$request->session()->put('login_attempts', $attempts);
$request->session()->put('last_attempt_time', now());
if ($attempts >= 3) {
$request->session()->flush();
$request->session()->regenerate();
return redirect()->back()->withErrors([
'username' => 'Terlalu banyak percobaan login gagal. Session telah direset. Silakan coba lagi.'
'username' => 'Terlalu banyak percobaan. Session direset, silakan coba lagi.',
])->withInput($request->except('password'));
}
throw ValidationException::withMessages([
'username' => "Login gagal (Percobaan ke-{$attempts}/3). Username/Password salah atau akun tidak terdaftar.",
'username' => 'Login gagal (Percobaan ke-' . $attempts . '/3). Username atau password salah.',
]);
}
/**
* Logout santri/wali
*/
public function logout(Request $request)
{
Auth::logout();
Auth::guard('santri')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('santri.login')
->with('success', 'Anda berhasil logout.');
->with('success', 'Berhasil logout.');
}
}

View File

@ -6,6 +6,7 @@
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use App\Models\Santri;
use App\Models\SantriKelas;
use App\Models\User;
use App\Models\Kegiatan;
use App\Models\AbsensiKegiatan;
@ -16,6 +17,7 @@
use App\Models\Kepulangan;
use App\Models\PengajuanKepulangan;
use App\Models\PembayaranSpp;
use App\Models\Keuangan; // ← TAMBAHAN: untuk data kas pondok
use App\Models\UangSaku;
use App\Models\Capaian;
use App\Models\Semester;
@ -48,16 +50,23 @@ public function admin()
$tahunIni = (int) $today->format('Y');
// ────────────────────────── KPI CARDS ──────────────────────────
$totalSantriAktif = Cache::remember('dash_santri_aktif', 300, fn () => Santri::aktif()->count());
$user = Auth::user();
$totalSantriAktif = Cache::remember('dash_santri_aktif', 300, function () {
return Santri::aktif()->count();
});
// Kegiatan hari ini + status absensi
$kegiatanHariIni = Kegiatan::with(['kategori', 'absensis' => fn ($q) => $q->whereDate('tanggal', $today)])
$kegiatanHariIni = Kegiatan::with(['kategori', 'absensis' => function ($q) use ($today) {
$q->whereDate('tanggal', $today);
}])
->where('hari', $hariIni)
->orderBy('waktu_mulai')
->get();
$totalKegiatan = $kegiatanHariIni->count();
$sudahAbsensi = $kegiatanHariIni->filter(fn ($k) => $k->absensis->isNotEmpty())->count();
$sudahAbsensi = $kegiatanHariIni->filter(function ($k) {
return $k->absensis->isNotEmpty();
})->count();
$belumAbsensi = $totalKegiatan - $sudahAbsensi;
// Santri di UKP (sedang dirawat)
@ -66,10 +75,13 @@ public function admin()
// Pengajuan kepulangan menunggu approval
$kepulanganMenunggu = PengajuanKepulangan::where('status', 'Menunggu')->count();
// Santri aktif yang belum punya akun wali
// Santri aktif yang belum punya akun wali (super_admin only)
$santriTanpaWali = 0;
if ($user->role === 'super_admin') {
$santriTanpaWali = Santri::aktif()
->whereDoesntHave('waliUser')
->count();
}
$kpiCards = compact(
'totalSantriAktif', 'totalKegiatan', 'sudahAbsensi',
@ -95,16 +107,19 @@ public function admin()
});
// ────────────────────────── ALERT PANEL ──────────────────────────
// 1) Santri alpa beruntun (≥3 hari berturut-turut dalam 7 hari terakhir)
// 1) Santri alpa beruntun (semua role bisa lihat)
$santriAlpaBeruntun = $this->getSantriAlpaBeruntun();
// 2) SPP jatuh tempo (belum lunas & batas_bayar sudah lewat)
// 2) SPP jatuh tempo (super_admin only)
$sppJatuhTempo = collect([]);
if ($user->role === 'super_admin') {
$sppJatuhTempo = PembayaranSpp::telat()
->with('santri:id_santri,nama_lengkap')
->select('id_pembayaran', 'id_santri', 'bulan', 'tahun', 'nominal', 'batas_bayar')
->orderBy('batas_bayar')
->limit(10)
->get();
}
// 3) Pengajuan kepulangan menunggu review
$kepulanganPending = PengajuanKepulangan::where('status', 'Menunggu')
@ -119,22 +134,45 @@ public function admin()
// ──────────────── GRAFIK TREN KEHADIRAN (4 MINGGU) ────────────────
$trenKehadiran = $this->getTrenKehadiran($today);
// ──────────────── RINGKASAN SPP BULAN INI ────────────────
$sppBulanIni = Cache::remember("dash_spp_{$bulanIni}_{$tahunIni}", 300, function () use ($bulanIni, $tahunIni) {
// ──────────────── RINGKASAN SPP + KEUANGAN BULAN INI ─────────────
// Default (untuk non super_admin atau jika query gagal)
$sppBulanIni = [
'lunas' => 0,
'belum' => 0,
'terkumpul' => 0,
'totalTagihan' => 0,
'pemasukanLain' => 0, // pemasukan kas pondok selain SPP
'pengeluaran' => 0, // pengeluaran kas pondok
];
if ($user->role === 'super_admin') {
// Pakai cache key baru "dash_spp_full_" agar tidak tumpang-tindih
// dengan cache key lama "dash_spp_" yang belum punya key keuangan
$sppBulanIni = Cache::remember("dash_spp_full_{$bulanIni}_{$tahunIni}", 300, function () use ($bulanIni, $tahunIni) {
// ── Data SPP ──
$lunas = PembayaranSpp::where('bulan', $bulanIni)->where('tahun', $tahunIni)->lunas()->count();
$belum = PembayaranSpp::where('bulan', $bulanIni)->where('tahun', $tahunIni)->belumLunas()->count();
$terkumpul = PembayaranSpp::where('bulan', $bulanIni)->where('tahun', $tahunIni)->lunas()->sum('nominal');
$totalTagihan = PembayaranSpp::where('bulan', $bulanIni)->where('tahun', $tahunIni)->sum('nominal');
$terkumpul = (float) PembayaranSpp::where('bulan', $bulanIni)->where('tahun', $tahunIni)->lunas()->sum('nominal');
$totalTagihan = (float) PembayaranSpp::where('bulan', $bulanIni)->where('tahun', $tahunIni)->sum('nominal');
return compact('lunas', 'belum', 'terkumpul', 'totalTagihan');
// ── Data Keuangan Pondok (non-SPP) ──
$pemasukanLain = (float) Keuangan::pemasukan()
->whereMonth('tanggal', $bulanIni)
->whereYear('tanggal', $tahunIni)
->sum('nominal');
$pengeluaran = (float) Keuangan::pengeluaran()
->whereMonth('tanggal', $bulanIni)
->whereYear('tanggal', $tahunIni)
->sum('nominal');
return compact('lunas', 'belum', 'terkumpul', 'totalTagihan', 'pemasukanLain', 'pengeluaran');
});
// ──────────────── FEED AKTIVITAS TERBARU ────────────────
$feedAktivitas = $this->getFeedAktivitas($today);
}
return view('admin.dashboardAdmin', compact(
'kpiCards', 'kegiatanHariIni', 'alerts',
'trenKehadiran', 'sppBulanIni', 'feedAktivitas',
'trenKehadiran', 'sppBulanIni',
'hariIni', 'today'
));
@ -156,7 +194,6 @@ private function getSantriAlpaBeruntun(int $threshold = 3): \Illuminate\Support\
{
$weekAgo = Carbon::today()->subDays(7);
// Ambil data alpa per santri 7 hari terakhir
$alpaData = AbsensiKegiatan::where('status', 'Alpa')
->whereDate('tanggal', '>=', $weekAgo)
->select('id_santri')
@ -190,7 +227,6 @@ private function getTrenKehadiran(Carbon $today): array
$kategoris = KategoriKegiatan::select('kategori_id', 'nama_kategori')->get();
// 4 minggu terakhir → label "Mg 1" s.d "Mg 4"
for ($i = 3; $i >= 0; $i--) {
$start = $today->copy()->subWeeks($i)->startOfWeek(Carbon::MONDAY);
$end = $start->copy()->endOfWeek(Carbon::SUNDAY);
@ -216,13 +252,12 @@ private function getTrenKehadiran(Carbon $today): array
}
/**
* Feed aktivitas terbaru: absensi, pelanggaran, pembayaran SPP, transaksi uang saku
* Feed aktivitas terbaru
*/
private function getFeedAktivitas(Carbon $today): \Illuminate\Support\Collection
{
$items = collect();
// Absensi terbaru
AbsensiKegiatan::with(['santri:id_santri,nama_lengkap', 'kegiatan:kegiatan_id,nama_kegiatan'])
->whereDate('tanggal', $today)
->orderByDesc('created_at')
@ -235,7 +270,6 @@ private function getFeedAktivitas(Carbon $today): \Illuminate\Support\Collection
'time' => $a->created_at,
]));
// Pelanggaran terbaru (7 hari)
RiwayatPelanggaran::with(['santri:id_santri,nama_lengkap', 'kategori:id_kategori,nama_pelanggaran'])
->whereDate('tanggal', '>=', $today->copy()->subDays(7))
->terbaru()
@ -248,7 +282,6 @@ private function getFeedAktivitas(Carbon $today): \Illuminate\Support\Collection
'time' => $p->created_at,
]));
// Pembayaran SPP terbaru (7 hari)
PembayaranSpp::with('santri:id_santri,nama_lengkap')
->lunas()
->whereNotNull('tanggal_bayar')
@ -267,41 +300,38 @@ private function getFeedAktivitas(Carbon $today): \Illuminate\Support\Collection
}
/**
* Dashboard Santri/Wali - FIXED VERSION
* Dashboard Santri
*/
public function santri()
{
try {
$user = Auth::user();
$account = auth('santri')->user();
Log::info('=== DASHBOARD SANTRI START ===');
Log::info('User ID: ' . $user->id);
Log::info('Role: ' . $user->role);
Log::info('Role ID: ' . $user->role_id);
Log::info('Account ID: ' . $account->id);
Log::info('Role: ' . $account->role);
Log::info('ID Santri: ' . $account->id_santri);
// Validasi role
if (!in_array($user->role, ['santri', 'wali'])) {
Log::error('Role tidak sesuai: ' . $user->role);
abort(403, 'Akses ditolak. Role Anda: ' . $user->role);
}
// ✅ Ambil data santri
$santri = Santri::where('id_santri', $user->role_id)
->select('id_santri', 'nama_lengkap', 'kelas')
$santri = Santri::with([
'kelasPrimary.kelas.kelompok',
])
->where('id_santri', $account->id_santri)
->select('id_santri', 'nama_lengkap')
->first();
if (!$santri) {
Log::error('Santri tidak ditemukan dengan role_id: ' . $user->role_id);
Log::error('Santri tidak ditemukan dengan id_santri: ' . $account->id_santri);
abort(404, 'Data santri tidak ditemukan.');
}
Log::info('Santri ditemukan: ' . $santri->nama_lengkap);
$namaKelas = $santri->kelas;
$idSantri = $santri->id_santri;
$today = Carbon::today();
$weekAgo = Carbon::now()->subDays(7);
// Ambil semester aktif dengan FALLBACK
// Ambil semester aktif dengan FALLBACK
$semesterAktif = null;
try {
$semesterAktif = Semester::aktif()
@ -321,67 +351,58 @@ public function santri()
$semesterAktif = null;
}
// ✅ AMBIL PROGRES AL-QUR'AN dengan FALLBACK
// Progres Al-Qur'an
$progresAlquran = 0;
try {
$query = Capaian::where('id_santri', $idSantri);
if ($semesterAktif) {
$query->where('id_semester', $semesterAktif->id_semester);
}
$progresAlquran = $query->whereHas('materi', function($q) {
$progresAlquran = $query->whereHas('materi', function ($q) {
$q->where('kategori', 'Al-Qur\'an');
})->avg('persentase') ?? 0;
Log::info('Progres Al-Quran: ' . $progresAlquran);
} catch (\Exception $e) {
Log::warning('Error progres Al-Quran: ' . $e->getMessage());
$progresAlquran = 0;
}
// ✅ AMBIL PROGRES HADIST dengan FALLBACK
// Progres Hadist
$progresHadist = 0;
try {
$query = Capaian::where('id_santri', $idSantri);
if ($semesterAktif) {
$query->where('id_semester', $semesterAktif->id_semester);
}
$progresHadist = $query->whereHas('materi', function($q) {
$progresHadist = $query->whereHas('materi', function ($q) {
$q->where('kategori', 'Hadist');
})->avg('persentase') ?? 0;
Log::info('Progres Hadist: ' . $progresHadist);
} catch (\Exception $e) {
Log::warning('Error progres Hadist: ' . $e->getMessage());
$progresHadist = 0;
}
// ✅ AMBIL PROGRES MATERI TAMBAHAN dengan FALLBACK
// Progres Materi Tambahan
$progresMateriTambahan = 0;
try {
$query = Capaian::where('id_santri', $idSantri);
if ($semesterAktif) {
$query->where('id_semester', $semesterAktif->id_semester);
}
$progresMateriTambahan = $query->whereHas('materi', function($q) {
$progresMateriTambahan = $query->whereHas('materi', function ($q) {
$q->where('kategori', 'Materi Tambahan');
})->avg('persentase') ?? 0;
Log::info('Progres Materi Tambahan: ' . $progresMateriTambahan);
} catch (\Exception $e) {
Log::warning('Error progres Materi Tambahan: ' . $e->getMessage());
$progresMateriTambahan = 0;
}
// ✅ DATA UNTUK GRAFIK 1: Progress per Materi dengan FALLBACK
// Data untuk grafik: Progress per Materi
$capaianPerMateri = collect([]);
try {
$query = Capaian::with(['materi' => function($q) {
$query = Capaian::with(['materi' => function ($q) {
$q->select('id_materi', 'nama_kitab', 'kategori', 'total_halaman');
}])
->where('id_santri', $idSantri);
@ -401,17 +422,15 @@ public function santri()
$capaianPerMateri = collect([]);
}
// ✅ DATA UNTUK GRAFIK 2: Distribusi Status dengan FALLBACK
// Data untuk grafik: Distribusi Status
$distribusiStatus = [
'selesai' => 0,
'hampir_selesai' => 0,
'sedang_berjalan' => 0,
'baru_dimulai' => 0,
];
try {
$baseQuery = Capaian::where('id_santri', $idSantri);
if ($semesterAktif) {
$baseQuery->where('id_semester', $semesterAktif->id_semester);
}
@ -428,22 +447,19 @@ public function santri()
Log::warning('Error distribusi status: ' . $e->getMessage());
}
// ✅ Data dashboard utama
$data = [
'nama_santri' => $santri->nama_lengkap,
'kelas' => $santri->kelas,
'kelas' => $namaKelas,
'progres_quran' => round($progresAlquran, 1),
'progres_hadist' => round($progresHadist, 1),
'progres_materi_tambahan' => round($progresMateriTambahan, 1),
'saldo_uang_saku' => method_exists($santri, 'getSaldoUangSakuAttribute')
? $santri->saldo_uang_saku
: 0,
'saldo_uang_saku' => $santri->saldo_uang_saku ?? 0,
'poin_pelanggaran' => RiwayatPelanggaran::where('id_santri', $idSantri)->sum('poin') ?? 0,
];
Log::info('Data array: ' . json_encode($data));
// ✅ Query status kesehatan dengan FALLBACK
// Status kesehatan
$statusKesehatan = null;
try {
$statusKesehatan = KesehatanSantri::where('id_santri', $idSantri)
@ -455,7 +471,7 @@ public function santri()
Log::warning('Error status kesehatan: ' . $e->getMessage());
}
// ✅ Query kepulangan aktif dengan FALLBACK
// Kepulangan aktif
$kepulanganAktif = null;
try {
$kepulanganAktif = Kepulangan::where('id_santri', $idSantri)
@ -468,17 +484,17 @@ public function santri()
Log::warning('Error kepulangan aktif: ' . $e->getMessage());
}
// ✅ Query berita terbaru dengan FALLBACK
// Berita terbaru
$beritaTerbaru = collect([]);
try {
$beritaTerbaru = Berita::select('id_berita', 'judul', 'created_at')
->where('status', 'published')
->where('created_at', '>=', $weekAgo)
->where(function($query) use ($santri) {
->where(function ($query) use ($namaKelas) {
$query->where('target_berita', 'semua')
->orWhere(function($q) use ($santri) {
->orWhere(function ($q) use ($namaKelas) {
$q->where('target_berita', 'kelas_tertentu')
->whereJsonContains('target_kelas', $santri->kelas);
->whereJsonContains('target_kelas', $namaKelas);
});
})
->orderBy('created_at', 'desc')
@ -493,11 +509,10 @@ public function santri()
Log::info('=== DASHBOARD SANTRI SUCCESS ===');
// Return view dengan semua data
return view('santri.dashboardSantri', compact(
'data',
'santri',
'user',
'account',
'beritaTerbaru',
'statusKesehatan',
'kepulanganAktif',
@ -513,12 +528,10 @@ public function santri()
Log::error('Line: ' . $e->getLine());
Log::error('Trace: ' . $e->getTraceAsString());
// Tampilkan error detail jika debug mode
if (config('app.debug')) {
abort(500, 'Error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
} else {
}
abort(500, 'Terjadi kesalahan saat memuat dashboard. Silakan hubungi administrator.');
}
}
}
}

View File

@ -7,153 +7,329 @@
use App\Models\Kegiatan;
use App\Models\Santri;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class RiwayatKegiatanSantriController extends Controller
{
private function getSantriId()
{
return auth('santri')->user()->id_santri;
}
/**
* Halaman utama: Jadwal Harian + Riwayat Absensi
* Resolve date range.
* Jadwal & Riwayat default: today
* Statistik default: this_week
*/
private function resolveDateRange(Request $request, string $defaultPreset = 'today'): array
{
$preset = $request->input('preset', $defaultPreset);
$now = Carbon::now();
switch ($preset) {
case 'today':
return [$now->copy()->startOfDay(), $now->copy()->endOfDay(), 'today'];
case 'this_week':
return [$now->copy()->startOfWeek(), $now->copy()->endOfWeek(), 'this_week'];
case 'last_30':
return [$now->copy()->subDays(29)->startOfDay(), $now->copy()->endOfDay(), 'last_30'];
case 'this_month':
return [$now->copy()->startOfMonth(), $now->copy()->endOfMonth(), 'this_month'];
case 'last_month':
$lm = $now->copy()->subMonth();
return [$lm->copy()->startOfMonth(), $lm->copy()->endOfMonth(), 'last_month'];
default:
// custom
$from = $request->filled('date_from')
? Carbon::parse($request->date_from)->startOfDay()
: $now->copy()->startOfDay();
$to = $request->filled('date_to')
? Carbon::parse($request->date_to)->endOfDay()
: $now->copy()->endOfDay();
if ($from->gt($to)) [$from, $to] = [$to, $from];
return [$from, $to, 'custom'];
}
}
public function index(Request $request)
{
$user = Auth::user();
$idSantri = $this->getSantriId();
if ($user->role !== 'santri') {
abort(403, 'Akses ditolak.');
}
$santri = Santri::where('id_santri', $user->role_id)
->select('id_santri', 'nama_lengkap', 'kelas')
// ✅ FIX: No 'kelas' column, use relasi
$santri = Santri::where('id_santri', $idSantri)
->with(['kelasPrimary.kelas'])
->select('id_santri', 'nama_lengkap', 'nis', 'status')
->firstOrFail();
$idSantri = $santri->id_santri;
$today = Carbon::today();
$hariIni = Carbon::now()->locale('id')->dayName; // Senin, Selasa, etc.
$namaKelas = optional(optional($santri->kelasPrimary)->kelas)->nama_kelas ?? '-';
$kelasSantriId = optional($santri->kelasPrimary)->id_kelas;
// ✅ JADWAL KEGIATAN HARI INI (Tetap)
$jadwalHariIni = Kegiatan::with('kategori')
->where('hari', ucfirst($hariIni))
->select('kegiatan_id', 'kategori_id', 'nama_kegiatan', 'waktu_mulai', 'waktu_selesai', 'materi')
->orderBy('waktu_mulai')
->get();
// -- Aktif tab (dari request, default: statistik) --
$activeTab = $request->input('tab', 'statistik');
// ✅ CEK STATUS ABSENSI HARI INI
$absensiHariIni = AbsensiKegiatan::where('id_santri', $idSantri)
->whereDate('tanggal', $today)
->pluck('status', 'kegiatan_id')
->toArray();
// -- Tiap tab punya preset/range masing-masing --
// Statistik: default this_week
// Jadwal & Riwayat: default today
// Request bisa bawa preset_stat, preset_jadwal, preset_riwayat
// atau preset global (backward compat)
// ✅ RIWAYAT ABSENSI (dengan filter)
$query = AbsensiKegiatan::with('kegiatan.kategori')
->where('id_santri', $idSantri);
// Filter Bulan
if ($request->filled('bulan')) {
$bulan = Carbon::parse($request->bulan);
$query->whereMonth('tanggal', $bulan->month)
->whereYear('tanggal', $bulan->year);
// Statistik range
$statPresetReq = $request->input('preset_stat', $request->input('preset', 'this_week'));
[$statFrom, $statTo, $statPreset] = $this->resolveDateRange(
$request->merge(['preset' => $statPresetReq,
'date_from' => $request->input('stat_date_from'),
'date_to' => $request->input('stat_date_to')]),
'this_week'
);
if ($statPreset === 'custom') {
$statFrom = $request->filled('stat_date_from') ? Carbon::parse($request->stat_date_from)->startOfDay() : $statFrom;
$statTo = $request->filled('stat_date_to') ? Carbon::parse($request->stat_date_to)->endOfDay() : $statTo;
}
// Filter Status
if ($request->filled('status')) {
$query->where('status', $request->status);
}
// Jadwal range
$jadPresetReq = $request->input('preset_jad', $request->input('preset', 'today'));
[$jadFrom, $jadTo, $jadPreset] = $this->resolveDateRange(
$request->merge(['preset' => $jadPresetReq,
'date_from' => $request->input('jad_date_from'),
'date_to' => $request->input('jad_date_to')]),
'today'
);
$riwayats = $query->orderBy('tanggal', 'desc')
->orderBy('waktu_absen', 'desc')
->paginate(15)
->appends(request()->query());
// Riwayat range
$riwPresetReq = $request->input('preset_riw', $request->input('preset', 'today'));
[$riwFrom, $riwTo, $riwPreset] = $this->resolveDateRange(
$request->merge(['preset' => $riwPresetReq,
'date_from' => $request->input('riw_date_from'),
'date_to' => $request->input('riw_date_to')]),
'today'
);
// ✅ STATISTIK KEHADIRAN (30 HARI TERAKHIR)
$stats30Hari = AbsensiKegiatan::where('id_santri', $idSantri)
->whereDate('tanggal', '>=', Carbon::now()->subDays(30))
// -- Mapping hari --
$hariMapDb = [
'Senin' => 'Senin', 'Selasa' => 'Selasa', 'Rabu' => 'Rabu',
'Kamis' => 'Kamis', 'Jumat' => 'Jumat', 'Sabtu' => 'Sabtu',
'Minggu' => 'Ahad',
];
$hariCarbon = Carbon::now()->locale('id')->dayName;
$hariIni = $hariMapDb[$hariCarbon] ?? $hariCarbon;
// ── KPI stats (pakai stat range) ──────────────────────────────────
$statFromStr = $statFrom->format('Y-m-d');
$statToStr = $statTo->format('Y-m-d');
$statsRange = AbsensiKegiatan::where('id_santri', $idSantri)
->whereBetween('tanggal', [$statFromStr, $statToStr])
->select('status', DB::raw('count(*) as total'))
->groupBy('status')
->pluck('total', 'status')
->toArray();
$totalKegiatan30Hari = array_sum($stats30Hari);
$persentaseKehadiran = $totalKegiatan30Hari > 0
? round(($stats30Hari['Hadir'] ?? 0) / $totalKegiatan30Hari * 100, 1)
: 0;
$totalRange = array_sum($statsRange);
$hadirRange = $statsRange['Hadir'] ?? 0;
$izinRange = $statsRange['Izin'] ?? 0;
$sakitRange = $statsRange['Sakit'] ?? 0;
$alpaRange = $statsRange['Alpa'] ?? 0;
$persentaseKehadiran = $totalRange > 0 ? round($hadirRange / $totalRange * 100, 1) : 0;
// ✅ DATA GRAFIK: Kehadiran per Minggu (4 Minggu Terakhir)
$dataGrafikMingguan = [];
for ($i = 3; $i >= 0; $i--) {
$startWeek = Carbon::now()->subWeeks($i)->startOfWeek();
$endWeek = Carbon::now()->subWeeks($i)->endOfWeek();
// ── JADWAL ────────────────────────────────────────────────────────
$hariDalamRange = [];
$cursor = $jadFrom->copy();
while ($cursor->lte($jadTo)) {
$hariDb = $hariMapDb[$cursor->locale('id')->dayName] ?? $cursor->locale('id')->dayName;
$hariDalamRange[$hariDb] = true;
$cursor->addDay();
}
$hariDalamRange = array_keys($hariDalamRange);
$hadir = AbsensiKegiatan::where('id_santri', $idSantri)
->whereBetween('tanggal', [$startWeek, $endWeek])
->where('status', 'Hadir')
->count();
$jadwalDalamRange = Kegiatan::with('kategori')
->whereIn('hari', $hariDalamRange)
->where(function ($q) use ($kelasSantriId) {
$q->doesntHave('kelasKegiatan')
->orWhereHas('kelasKegiatan', function ($q2) use ($kelasSantriId) {
if ($kelasSantriId) {
$q2->where('kelas.id', $kelasSantriId);
}
});
})
->select('kegiatan_id', 'kategori_id', 'nama_kegiatan', 'waktu_mulai', 'waktu_selesai', 'hari', 'materi')
->orderByRaw("FIELD(hari, 'Senin','Selasa','Rabu','Kamis','Jumat','Sabtu','Ahad')")
->orderBy('waktu_mulai')
->get();
$total = AbsensiKegiatan::where('id_santri', $idSantri)
->whereBetween('tanggal', [$startWeek, $endWeek])
->count();
// Status absensi per kegiatan dalam range jadwal
$absensiDalamRange = AbsensiKegiatan::where('id_santri', $idSantri)
->whereBetween('tanggal', [$jadFrom->format('Y-m-d'), $jadTo->format('Y-m-d')])
->pluck('status', 'kegiatan_id')
->toArray();
$dataGrafikMingguan[] = [
'minggu' => 'Minggu ' . (4 - $i),
'hadir' => $hadir,
'total' => $total,
'persentase' => $total > 0 ? round($hadir / $total * 100, 1) : 0,
];
// Status khusus hari ini (untuk badge)
$absensiHariIni = AbsensiKegiatan::where('id_santri', $idSantri)
->whereDate('tanggal', Carbon::today())
->pluck('status', 'kegiatan_id')
->toArray();
// ── RIWAYAT ───────────────────────────────────────────────────────
$riwFromStr = $riwFrom->format('Y-m-d');
$riwToStr = $riwTo->format('Y-m-d');
$queryRiwayat = AbsensiKegiatan::with('kegiatan.kategori')
->where('id_santri', $idSantri)
->whereBetween('tanggal', [$riwFromStr, $riwToStr]);
if ($request->filled('filter_status')) {
$queryRiwayat->where('status', $request->filter_status);
}
if ($request->filled('filter_kategori')) {
$queryRiwayat->whereHas('kegiatan', fn($q) => $q->where('kategori_id', $request->filter_kategori));
}
// ✅ STATISTIK PER KATEGORI KEGIATAN
$statsByKategori = AbsensiKegiatan::where('id_santri', $idSantri)
$riwayats = $queryRiwayat->orderBy('tanggal', 'desc')
->orderBy('waktu_absen', 'desc')
->paginate(15)
->appends(request()->query());
// ── STREAK ────────────────────────────────────────────────────────
$streak = 0;
AbsensiKegiatan::where('id_santri', $idSantri)
->orderByDesc('tanggal')->orderByDesc('waktu_absen')
->select('status')->limit(60)
->each(function($a) use (&$streak) {
if ($a->status === 'Hadir') $streak++;
else return false;
});
// ── GRAFIK TREN (stat range) ──────────────────────────────────────
$diffDays = $statFrom->diffInDays($statTo);
$dataGrafik = [];
if ($diffDays <= 31) {
$cur = $statFrom->copy();
while ($cur->lte($statTo)) {
$d = $cur->format('Y-m-d');
$hadir = AbsensiKegiatan::where('id_santri', $idSantri)->whereDate('tanggal', $d)->where('status', 'Hadir')->count();
$total = AbsensiKegiatan::where('id_santri', $idSantri)->whereDate('tanggal', $d)->count();
$dataGrafik[] = ['label' => $cur->format('d/m'), 'hadir' => $hadir, 'total' => $total];
$cur->addDay();
}
} else {
$cur = $statFrom->copy()->startOfWeek();
while ($cur->lte($statTo)) {
$wStart = $cur->copy()->max($statFrom);
$wEnd = $cur->copy()->endOfWeek()->min($statTo);
$hadir = AbsensiKegiatan::where('id_santri', $idSantri)->whereBetween('tanggal', [$wStart->format('Y-m-d'), $wEnd->format('Y-m-d')])->where('status', 'Hadir')->count();
$total = AbsensiKegiatan::where('id_santri', $idSantri)->whereBetween('tanggal', [$wStart->format('Y-m-d'), $wEnd->format('Y-m-d')])->count();
$dataGrafik[] = ['label' => $wStart->format('d/m') . '' . $wEnd->format('d/m'), 'hadir' => $hadir, 'total' => $total];
$cur->addWeek();
}
}
// ── CONSISTENCY SCORE per KEGIATAN (stat range) ───────────────────
// Score = % hadir, dengan label badge berdasarkan level
$consistencyScores = AbsensiKegiatan::where('absensi_kegiatans.id_santri', $idSantri)
->whereBetween('absensi_kegiatans.tanggal', [$statFromStr, $statToStr])
->join('kegiatans', 'absensi_kegiatans.kegiatan_id', '=', 'kegiatans.kegiatan_id')
->join('kategori_kegiatans', 'kegiatans.kategori_id', '=', 'kategori_kegiatans.kategori_id')
->select(
'kegiatans.kegiatan_id',
'kegiatans.nama_kegiatan',
'kategori_kegiatans.nama_kategori',
DB::raw('COUNT(*) as total'),
DB::raw('SUM(CASE WHEN absensi_kegiatans.status = "Hadir" THEN 1 ELSE 0 END) as hadir'),
DB::raw('COUNT(*) as total')
DB::raw('SUM(CASE WHEN absensi_kegiatans.status = "Alpa" THEN 1 ELSE 0 END) as alpa'),
DB::raw('SUM(CASE WHEN absensi_kegiatans.status IN ("Izin","Sakit") THEN 1 ELSE 0 END) as dispensasi')
)
->groupBy('kategori_kegiatans.nama_kategori')
->get();
->groupBy('kegiatans.kegiatan_id', 'kegiatans.nama_kegiatan', 'kategori_kegiatans.nama_kategori')
->get()
->map(function ($row) {
$score = $row->total > 0 ? round($row->hadir / $row->total * 100) : 0;
// Badge tier
if ($score >= 90) { $badge = 'Konsisten'; $tier = 'top'; }
elseif ($score >= 75) { $badge = 'Baik'; $tier = 'good'; }
elseif ($score >= 60) { $badge = 'Cukup'; $tier = 'fair'; }
elseif ($score >= 40) { $badge = 'Perlu Perhatian'; $tier = 'warn'; }
else { $badge = 'Kritis'; $tier = 'crit'; }
$row->score = $score;
$row->badge = $badge;
$row->tier = $tier;
return $row;
})
->sortByDesc('score')
->values();
// ── HEATMAP: kalender bulan aktif (stat range, max tampil 1 bulan) ─
// Kita buat kalender bulan-bulan dalam stat range, dengan angka tanggal
$heatmapMonths = [];
$cur = $statFrom->copy()->startOfMonth();
while ($cur->lte($statTo)) {
$monthKey = $cur->format('Y-m');
$daysInMonth = $cur->daysInMonth;
$firstDayOfWeek = $cur->copy()->startOfMonth()->dayOfWeekIso; // 1=Mon..7=Sun
$days = [];
for ($d = 1; $d <= $daysInMonth; $d++) {
$date = $cur->format('Y-m') . '-' . str_pad($d, 2, '0', STR_PAD_LEFT);
$rows = AbsensiKegiatan::where('id_santri', $idSantri)->whereDate('tanggal', $date)->get();
$level = 0;
if ($rows->count() > 0) {
$pct = round($rows->where('status', 'Hadir')->count() / $rows->count() * 100);
$level = $pct >= 90 ? 4 : ($pct >= 70 ? 3 : ($pct >= 50 ? 2 : 1));
}
$days[] = [
'day' => $d,
'date' => $date,
'level' => $level,
'count' => $rows->where('status', 'Hadir')->count(),
'total' => $rows->count(),
'is_today' => $date === Carbon::today()->format('Y-m-d'),
'in_range' => $date >= $statFromStr && $date <= $statToStr,
];
}
$heatmapMonths[] = [
'label' => $cur->locale('id')->isoFormat('MMMM YYYY'),
'firstDayOfWeek'=> $firstDayOfWeek,
'days' => $days,
];
$cur->addMonth();
}
$kategoriList = \App\Models\KategoriKegiatan::select('kategori_id', 'nama_kategori')->get();
return view('santri.kegiatan.index', compact(
'santri',
'jadwalHariIni',
'absensiHariIni',
'riwayats',
'stats30Hari',
'totalKegiatan30Hari',
'persentaseKehadiran',
'dataGrafikMingguan',
'statsByKategori',
'hariIni'
'santri', 'namaKelas',
'jadwalDalamRange', 'absensiDalamRange', 'absensiHariIni', 'hariIni',
'jadPreset', 'jadFrom', 'jadTo',
'riwayats', 'riwPreset', 'riwFrom', 'riwTo',
'statsRange', 'totalRange', 'hadirRange', 'izinRange', 'sakitRange', 'alpaRange',
'persentaseKehadiran', 'streak',
'dataGrafik', 'statPreset', 'statFrom', 'statTo', 'statFromStr', 'statToStr', 'diffDays',
'consistencyScores',
'heatmapMonths',
'kategoriList',
'activeTab', 'hariIni'
));
}
/**
* Detail Riwayat Absensi per Kegiatan
*/
public function show($kegiatan_id)
public function show($kegiatan_id, Request $request)
{
$user = Auth::user();
$idSantri = $this->getSantriId();
if ($user->role !== 'santri') {
abort(403, 'Akses ditolak.');
}
$santri = Santri::where('id_santri', $user->role_id)
->select('id_santri', 'nama_lengkap')
$santri = Santri::where('id_santri', $idSantri)
->with(['kelasPrimary.kelas'])
->select('id_santri', 'nama_lengkap', 'nis', 'status')
->firstOrFail();
$kegiatan = Kegiatan::with('kategori')
->where('kegiatan_id', $kegiatan_id)
->firstOrFail();
// Riwayat absensi untuk kegiatan ini
$riwayats = AbsensiKegiatan::where('id_santri', $santri->id_santri)
->where('kegiatan_id', $kegiatan_id)
->orderBy('tanggal', 'desc')
->paginate(20);
// Statistik kehadiran untuk kegiatan ini
$stats = AbsensiKegiatan::where('id_santri', $santri->id_santri)
->where('kegiatan_id', $kegiatan_id)
->select('status', DB::raw('count(*) as total'))
@ -163,16 +339,33 @@ public function show($kegiatan_id)
$totalAbsensi = array_sum($stats);
$persentaseHadir = $totalAbsensi > 0
? round(($stats['Hadir'] ?? 0) / $totalAbsensi * 100, 1)
: 0;
? round(($stats['Hadir'] ?? 0) / $totalAbsensi * 100, 1) : 0;
$trendBulanan = [];
for ($i = 5; $i >= 0; $i--) {
$bulan = Carbon::now()->subMonths($i);
$data = AbsensiKegiatan::where('id_santri', $idSantri)
->where('kegiatan_id', $kegiatan_id)
->whereMonth('tanggal', $bulan->month)
->whereYear('tanggal', $bulan->year)
->select('status', DB::raw('count(*) as total'))
->groupBy('status')
->pluck('total', 'status')
->toArray();
$trendBulanan[] = [
'bulan' => $bulan->locale('id')->isoFormat('MMM YY'),
'hadir' => $data['Hadir'] ?? 0,
'total' => array_sum($data),
];
}
// Referrer tab untuk tombol kembali
$fromTab = $request->input('from_tab', 'riwayat');
return view('santri.kegiatan.show', compact(
'santri',
'kegiatan',
'riwayats',
'stats',
'totalAbsensi',
'persentaseHadir'
'santri', 'kegiatan', 'riwayats',
'stats', 'totalAbsensi', 'persentaseHadir',
'trendBulanan', 'fromTab'
));
}
}

View File

@ -1,4 +1,5 @@
<?php
// app/Http/Controllers/Santri/SantriBeritaController.php
namespace App\Http\Controllers\Santri;
@ -7,22 +8,27 @@
use App\Models\Santri;
use App\Models\SantriKelas;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class SantriBeritaController extends Controller
{
// -- Helper: Ambil id_santri dari akun yang login --
private function getSantriId()
{
return auth('santri')->user()->id_santri;
}
/**
* Tampilkan daftar berita yang bisa diakses santri
*/
public function index(Request $request)
{
$user = Auth::user();
$idSantri = $this->getSantriId();
$santri = Santri::where('id_santri', $user->role_id)
$santri = Santri::where('id_santri', $idSantri)
->select('id_santri')
->firstOrFail();
// Ambil id kelas santri
// -- Ambil id kelas santri --
$kelasIds = SantriKelas::where('id_santri', $santri->id_santri)
->pluck('id_kelas')->toArray();
@ -52,9 +58,9 @@ public function index(Request $request)
*/
public function show($id_berita)
{
$user = Auth::user();
$idSantri = $this->getSantriId();
$santri = Santri::where('id_santri', $user->role_id)
$santri = Santri::where('id_santri', $idSantri)
->select('id_santri')
->firstOrFail();

View File

@ -1,4 +1,5 @@
<?php
// app/Http/Controllers/Santri/SantriCapaianController.php
namespace App\Http\Controllers\Santri;
@ -6,105 +7,135 @@
use App\Models\Capaian;
use App\Models\Santri;
use App\Models\Semester;
use App\Services\CapaianAccessService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class SantriCapaianController extends Controller
{
/**
* Tampilkan daftar capaian santri yang sedang login
*/
public function index(Request $request)
private function getSantriId()
{
$user = Auth::user();
// Validasi role
if (!in_array($user->role, ['santri', 'wali'])) {
abort(403, 'Unauthorized access');
return auth('santri')->user()->id_santri;
}
$idSantri = $user->role_id;
public function index(Request $request)
{
$idSantri = $this->getSantriId();
// Cache data santri selama 10 menit
$santri = Cache::remember("santri_capaian_{$idSantri}", 600, function () use ($idSantri) {
// Ambil data santri
$santri = Cache::remember("santri_{$idSantri}_profile", 600, function () use ($idSantri) {
return Santri::where('id_santri', $idSantri)
->select('id_santri', 'nama_lengkap', 'kelas', 'nis')
->with(['kelasPrimary.kelas'])
->select('id_santri', 'nama_lengkap', 'nis', 'status')
->firstOrFail();
});
// Get semester aktif
$semesterAktif = Semester::aktif()->first();
$selectedSemester = $request->input('id_semester', $semesterAktif?->id_semester);
$selectedSemester = $request->input('id_semester',
$semesterAktif ? $semesterAktif->id_semester : null
);
// Query capaian dengan relasi
$query = Capaian::with(['materi:id_materi,nama_kitab,kategori,total_halaman', 'semester:id_semester,nama_semester'])
// Capaian untuk tab Ringkasan / Daftar / Grafik (filter semester)
$query = Capaian::with([
'materi:id_materi,nama_kitab,kategori,total_halaman,halaman_mulai,halaman_akhir',
'semester:id_semester,nama_semester',
])
->where('id_santri', $idSantri)
->select('id', 'id_capaian', 'id_santri', 'id_materi', 'id_semester', 'halaman_selesai', 'persentase', 'tanggal_input');
->select('id', 'id_capaian', 'id_santri', 'id_materi',
'id_semester', 'halaman_selesai', 'persentase', 'tanggal_input');
// Filter semester
if ($selectedSemester) {
$query->where('id_semester', $selectedSemester);
}
$capaians = $query->orderBy('tanggal_input', 'desc')->get();
// Statistik Umum
// Statistik umum
$totalCapaian = $capaians->count();
$rataRataPersentase = $capaians->avg('persentase') ?? 0;
$materiSelesai = $capaians->where('persentase', '>=', 100)->count();
// Statistik per Kategori
// Statistik per kategori
$statistikKategori = [
'Al-Qur\'an' => [
'count' => 0,
'avg' => 0,
'selesai' => 0,
],
'Hadist' => [
'count' => 0,
'avg' => 0,
'selesai' => 0,
],
'Materi Tambahan' => [
'count' => 0,
'avg' => 0,
'selesai' => 0,
],
"Al-Qur'an" => ['count' => 0, 'avg' => 0, 'selesai' => 0],
'Hadist' => ['count' => 0, 'avg' => 0, 'selesai' => 0],
'Materi Tambahan' => ['count' => 0, 'avg' => 0, 'selesai' => 0],
];
foreach ($capaians as $capaian) {
$kategori = $capaian->materi->kategori;
$statistikKategori[$kategori]['count']++;
$statistikKategori[$kategori]['avg'] += $capaian->persentase;
if ($capaian->persentase >= 100) {
$statistikKategori[$kategori]['selesai']++;
$kat = $capaian->materi->kategori ?? 'Materi Tambahan';
if (!isset($statistikKategori[$kat])) continue;
$statistikKategori[$kat]['count']++;
$statistikKategori[$kat]['avg'] += $capaian->persentase;
if ($capaian->persentase >= 100) $statistikKategori[$kat]['selesai']++;
}
}
// Hitung rata-rata
foreach ($statistikKategori as $kategori => $data) {
foreach ($statistikKategori as $kat => $data) {
if ($data['count'] > 0) {
$statistikKategori[$kategori]['avg'] = $data['avg'] / $data['count'];
$statistikKategori[$kat]['avg'] = round($data['avg'] / $data['count'], 2);
}
}
// Distribusi persentase untuk chart
// Distribusi persentase
$distribusiPersentase = [
'0-25%' => $capaians->whereBetween('persentase', [0, 25])->count(),
'26-50%' => $capaians->whereBetween('persentase', [26, 50])->count(),
'51-75%' => $capaians->whereBetween('persentase', [51, 75])->count(),
'76-99%' => $capaians->whereBetween('persentase', [76, 99])->count(),
'0-25%' => $capaians->filter(fn($c) => $c->persentase >= 0 && $c->persentase <= 25)->count(),
'26-50%' => $capaians->filter(fn($c) => $c->persentase > 25 && $c->persentase <= 50)->count(),
'51-75%' => $capaians->filter(fn($c) => $c->persentase > 50 && $c->persentase <= 75)->count(),
'76-99%' => $capaians->filter(fn($c) => $c->persentase > 75 && $c->persentase < 100)->count(),
'100%' => $capaians->where('persentase', '>=', 100)->count(),
];
// Data untuk semester dropdown
// PREDIKSI: ambil SEMUA capaian tanpa filter semester
$allCapaians = Capaian::with([
'materi:id_materi,nama_kitab,kategori',
'semester:id_semester,nama_semester,tahun_ajaran,periode',
])
->where('id_santri', $idSantri)
->select('id', 'id_santri', 'id_materi', 'id_semester', 'persentase')
->get();
// Susun history per semester (urut cronologis)
$allSemesters = Semester::orderBy('tahun_ajaran')->orderBy('periode')->get();
$historyData = [];
foreach ($allSemesters as $sem) {
$semCap = $allCapaians->where('id_semester', $sem->id_semester);
if ($semCap->isNotEmpty()) {
$historyData[] = [
'sem' => $sem->nama_semester,
'avg' => round($semCap->avg('persentase'), 2),
];
}
}
// Hitung growth rate (rata-rata kenaikan antar semester)
$growthRate = 0;
if (count($historyData) >= 2) {
$diffs = [];
for ($i = 1; $i < count($historyData); $i++) {
$diffs[] = $historyData[$i]['avg'] - $historyData[$i - 1]['avg'];
}
$growthRate = round(array_sum($diffs) / count($diffs), 2);
} elseif (count($historyData) === 1) {
$growthRate = round($historyData[0]['avg'], 2);
}
$progressHistory = [
'history' => $historyData,
'growth_rate' => $growthRate,
'all_capaians' => $allCapaians,
];
// Semester dropdown
$semesters = Semester::select('id_semester', 'nama_semester', 'tahun_ajaran')
->orderBy('tahun_ajaran', 'desc')
->orderBy('periode', 'desc')
->get();
// Status akses input capaian mandiri
$capaianAccessOpen = CapaianAccessService::isOpen();
$capaianAccessConfig = CapaianAccessService::getConfig();
$capaianSisaWaktu = CapaianAccessService::getSisaWaktu();
return view('santri.capaian.index', compact(
'santri',
'capaians',
@ -113,102 +144,28 @@ public function index(Request $request)
'materiSelesai',
'statistikKategori',
'distribusiPersentase',
'progressHistory',
'semesters',
'selectedSemester',
'semesterAktif'
'semesterAktif',
'capaianAccessOpen',
'capaianAccessConfig',
'capaianSisaWaktu'
));
}
/**
* Tampilkan detail capaian tertentu
*/
public function show($id)
{
$user = Auth::user();
if (!in_array($user->role, ['santri', 'wali'])) {
abort(403, 'Unauthorized access');
}
$idSantri = $this->getSantriId();
$capaian = Capaian::with([
'materi:id_materi,nama_kitab,kategori,halaman_mulai,halaman_akhir,total_halaman',
'semester:id_semester,nama_semester,tahun_ajaran',
'santri:id_santri,nama_lengkap,kelas'
'santri:id_santri,nama_lengkap,nis',
])
->where('id_santri', $user->role_id)
->where('id_santri', $idSantri)
->findOrFail($id);
return view('santri.capaian.show', compact('capaian'));
}
/**
* API untuk data grafik (AJAX)
*/
public function apiGrafikData(Request $request)
{
$user = Auth::user();
$type = $request->input('type', 'kategori');
$idSemester = $request->input('id_semester');
$query = Capaian::with('materi:id_materi,kategori')
->where('id_santri', $user->role_id)
->select('id', 'id_materi', 'persentase', 'id_semester');
if ($idSemester) {
$query->where('id_semester', $idSemester);
}
$capaians = $query->get();
$data = [];
switch ($type) {
case 'kategori':
$avgAlquran = $capaians->filter(fn($c) => $c->materi->kategori == 'Al-Qur\'an')->avg('persentase') ?? 0;
$avgHadist = $capaians->filter(fn($c) => $c->materi->kategori == 'Hadist')->avg('persentase') ?? 0;
$avgTambahan = $capaians->filter(fn($c) => $c->materi->kategori == 'Materi Tambahan')->avg('persentase') ?? 0;
$data = [
'labels' => ['Al-Qur\'an', 'Hadist', 'Materi Tambahan'],
'datasets' => [[
'label' => 'Rata-rata Progress (%)',
'data' => [
round($avgAlquran, 2),
round($avgHadist, 2),
round($avgTambahan, 2)
],
'backgroundColor' => [
'rgba(111, 186, 157, 0.8)',
'rgba(129, 198, 232, 0.8)',
'rgba(255, 213, 107, 0.8)',
],
]]
];
break;
case 'distribusi':
$data = [
'labels' => ['0-25%', '26-50%', '51-75%', '76-99%', '100%'],
'datasets' => [[
'label' => 'Jumlah Materi',
'data' => [
$capaians->whereBetween('persentase', [0, 25])->count(),
$capaians->whereBetween('persentase', [26, 50])->count(),
$capaians->whereBetween('persentase', [51, 75])->count(),
$capaians->whereBetween('persentase', [76, 99])->count(),
$capaians->where('persentase', '>=', 100)->count(),
],
'backgroundColor' => [
'rgba(255, 139, 148, 0.8)',
'rgba(255, 171, 145, 0.8)',
'rgba(255, 213, 107, 0.8)',
'rgba(129, 198, 232, 0.8)',
'rgba(111, 186, 157, 0.8)',
],
]]
];
break;
}
return response()->json($data);
}
}

View File

@ -0,0 +1,180 @@
<?php
// app/Http/Controllers/Santri/SantriCapaianInputController.php
namespace App\Http\Controllers\Santri;
use App\Http\Controllers\Controller;
use App\Models\Capaian;
use App\Models\Materi;
use App\Models\Semester;
use App\Models\Santri;
use App\Services\CapaianAccessService;
use Illuminate\Http\Request;
class SantriCapaianInputController extends Controller
{
private function getSantri(): Santri
{
$idSantri = auth('santri')->user()->id_santri;
return Santri::where('id_santri', $idSantri)
->with(['kelasSantri.kelas'])
->firstOrFail();
}
/**
* Form input capaian untuk santri.
* GET /santri/capaian/input
*/
public function create(Request $request)
{
// Cek apakah akses sedang dibuka
if (!CapaianAccessService::isOpen()) {
return redirect()->route('santri.capaian.index')
->with('error', 'Saat ini belum ada jadwal input capaian. Silakan tunggu informasi dari admin.');
}
$santri = $this->getSantri();
$accessConfig = CapaianAccessService::getConfig();
$sisaWaktu = CapaianAccessService::getSisaWaktu();
// Ambil semester yang berlaku
$idSemesterConfig = $accessConfig['id_semester'] ?? null;
if ($idSemesterConfig) {
$semesterAktif = Semester::where('id_semester', $idSemesterConfig)->first();
} else {
$semesterAktif = Semester::aktif()->first();
}
// Materi sesuai kelas santri
$kelasNames = $santri->kelasSantri->map(fn($sk) => $sk->kelas?->nama_kelas)->filter()->unique()->toArray();
$materiOptions = Materi::whereIn('kelas', $kelasNames ?: [''])
->orderBy('kategori')->orderBy('nama_kitab')->get();
// Capaian yang sudah ada di semester ini
$existingCapaians = [];
if ($semesterAktif) {
$existingCapaians = Capaian::where('id_santri', $santri->id_santri)
->where('id_semester', $semesterAktif->id_semester)
->pluck('persentase', 'id_materi')
->toArray();
}
$semesters = Semester::orderBy('tahun_ajaran', 'desc')->get();
return view('santri.capaian.input', compact(
'santri', 'semesterAktif', 'semesters', 'materiOptions',
'existingCapaians', 'accessConfig', 'sisaWaktu'
));
}
/**
* Simpan/update capaian oleh santri.
* POST /santri/capaian/input
*/
public function store(Request $request)
{
// Double-check akses masih terbuka
if (!CapaianAccessService::isOpen()) {
return redirect()->route('santri.capaian.index')
->with('error', 'Waktu input capaian telah berakhir.');
}
$santri = $this->getSantri();
$validated = $request->validate([
'id_materi' => 'required|exists:materi,id_materi',
'id_semester' => 'required|exists:semester,id_semester',
'halaman_selesai'=> 'required|string',
'catatan' => 'nullable|string|max:500',
'tanggal_input' => 'required|date',
]);
// Pastikan semester yang dikirim sesuai dengan yang diizinkan
$accessConfig = CapaianAccessService::getConfig();
if (!empty($accessConfig['id_semester']) && $accessConfig['id_semester'] !== $validated['id_semester']) {
return back()->with('error', 'Semester tidak sesuai dengan jadwal input yang dibuka admin.');
}
// Validasi materi sesuai kelas santri
$kelasNames = $santri->kelasSantri->map(fn($sk) => $sk->kelas?->nama_kelas)->filter()->unique()->toArray();
$materi = Materi::where('id_materi', $validated['id_materi'])
->whereIn('kelas', $kelasNames ?: [''])->first();
if (!$materi) {
return back()->with('error', 'Materi tidak sesuai dengan kelas Anda.');
}
// Upsert capaian (create or update)
$existing = Capaian::where('id_santri', $santri->id_santri)
->where('id_materi', $validated['id_materi'])
->where('id_semester', $validated['id_semester'])
->first();
if ($existing) {
$existing->update([
'halaman_selesai' => $validated['halaman_selesai'],
'catatan' => $validated['catatan'],
'tanggal_input' => $validated['tanggal_input'],
]);
$msg = "Capaian {$materi->nama_kitab} berhasil diperbarui.";
} else {
Capaian::create([
'id_santri' => $santri->id_santri,
'id_materi' => $validated['id_materi'],
'id_semester' => $validated['id_semester'],
'halaman_selesai'=> $validated['halaman_selesai'],
'catatan' => $validated['catatan'],
'tanggal_input' => $validated['tanggal_input'],
]);
$msg = "Capaian {$materi->nama_kitab} berhasil disimpan.";
}
return redirect()->route('santri.capaian.input.create')
->with('success', $msg);
}
/**
* AJAX: Ambil detail materi + existing capaian santri ini.
* POST /santri/capaian/input/ajax/detail-materi
*/
public function ajaxDetailMateri(Request $request)
{
$santri = $this->getSantri();
$materi = Materi::where('id_materi', $request->id_materi)->first();
if (!$materi) return response()->json(['error' => 'Materi tidak ditemukan'], 404);
$existing = null;
if ($request->filled('id_semester')) {
$existing = Capaian::where('id_santri', $santri->id_santri)
->where('id_materi', $request->id_materi)
->where('id_semester', $request->id_semester)
->first();
}
return response()->json([
'materi' => $materi,
'existing_capaian' => $existing,
]);
}
/**
* AJAX: Hitung persentase preview.
*/
public function ajaxHitungPersentase(Request $request)
{
if (empty($request->halaman_selesai) || empty($request->id_materi)) {
return response()->json(['persentase' => 0, 'jumlah' => 0]);
}
try {
$persentase = Capaian::calculatePersentase($request->halaman_selesai, $request->id_materi);
$pages = Capaian::parseHalamanSelesai($request->halaman_selesai);
return response()->json([
'persentase' => number_format($persentase, 2),
'jumlah' => count($pages),
]);
} catch (\Exception $e) {
return response()->json(['error' => $e->getMessage()], 400);
}
}
}

View File

@ -1,4 +1,5 @@
<?php
// app/Http/Controllers/Santri/SantriKepulanganController.php
namespace App\Http\Controllers\Santri;
@ -6,78 +7,76 @@
use App\Models\Kepulangan;
use App\Models\Santri;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Carbon\Carbon;
class SantriKepulanganController extends Controller
{
// -- Helper: Ambil id_santri dari akun yang login --
private function getSantriId()
{
return auth('santri')->user()->id_santri;
}
/**
* Tampilkan riwayat kepulangan santri yang sedang login
*/
public function index(Request $request)
{
$user = Auth::user();
$idSantri = $this->getSantriId();
// Ambil data santri
$santri = Santri::where('id_santri', $user->role_id)
->select('id_santri', 'nama_lengkap', 'kelas')
->firstOrFail();
// -- Ambil data santri (tanpa kolom 'kelas' yang mungkin tidak ada) --
$santri = Santri::where('id_santri', $idSantri)->firstOrFail();
// Tahun untuk filter
// -- Tahun untuk filter --
$tahunSekarang = $request->filled('tahun') ? $request->tahun : Carbon::now()->year;
// Query riwayat kepulangan
// -- Query riwayat kepulangan --
$query = Kepulangan::query()
->select([
'id',
'id_kepulangan',
'id_santri',
'tanggal_izin',
'tanggal_pulang',
'tanggal_kembali',
'durasi_izin',
'alasan',
'status',
'approved_at',
'created_at'
])
->where('id_santri', $santri->id_santri)
->whereYear('tanggal_pulang', $tahunSekarang);
// Filter status jika ada
// -- Filter status jika ada --
if ($request->filled('status')) {
$query->where('status', $request->status);
}
// Urutkan terbaru dan paginate
// -- Urutkan terbaru dan paginate --
$riwayatKepulangan = $query->orderBy('tanggal_pulang', 'desc')
->paginate(10)
->appends($request->all());
// Hitung statistik tahun ini
// -- Hitung statistik tahun ini --
$allKepulanganTahunIni = Kepulangan::where('id_santri', $santri->id_santri)
->whereYear('tanggal_pulang', $tahunSekarang)
->get();
$statistik = [
'total_izin' => Kepulangan::where('id_santri', $santri->id_santri)
->whereYear('tanggal_pulang', $tahunSekarang)
->count(),
'disetujui' => Kepulangan::where('id_santri', $santri->id_santri)
->where('status', 'Disetujui')
->whereYear('tanggal_pulang', $tahunSekarang)
->count(),
'total_hari' => Kepulangan::where('id_santri', $santri->id_santri)
->where('status', 'Disetujui')
->whereYear('tanggal_pulang', $tahunSekarang)
->sum('durasi_izin'),
'menunggu' => Kepulangan::where('id_santri', $santri->id_santri)
->where('status', 'Menunggu')
->whereYear('tanggal_pulang', $tahunSekarang)
->count(),
'total_izin' => $allKepulanganTahunIni->count(),
'disetujui' => $allKepulanganTahunIni->where('status', 'Disetujui')->count(),
'ditolak' => $allKepulanganTahunIni->where('status', 'Ditolak')->count(),
'menunggu' => $allKepulanganTahunIni->where('status', 'Menunggu')->count(),
'selesai' => $allKepulanganTahunIni->where('status', 'Selesai')->count(),
'total_hari' => $allKepulanganTahunIni->whereIn('status', ['Disetujui', 'Selesai'])->sum('durasi_izin'),
];
// Hitung sisa kuota (maksimal 12 hari/tahun)
$statistik['sisa_kuota'] = max(0, 12 - $statistik['total_hari']);
$statistik['over_limit'] = $statistik['total_hari'] > 12;
$statistik['persen_kuota'] = min(100, round(($statistik['total_hari'] / 12) * 100));
// Data untuk filter
// -- Cek apakah sedang aktif pulang --
$sedangPulang = Kepulangan::where('id_santri', $santri->id_santri)
->where('status', 'Disetujui')
->whereDate('tanggal_pulang', '<=', Carbon::today())
->whereDate('tanggal_kembali', '>=', Carbon::today())
->first();
// -- Cek apakah ada yang terlambat --
$terlambat = Kepulangan::where('id_santri', $santri->id_santri)
->where('status', 'Disetujui')
->whereDate('tanggal_kembali', '<', Carbon::today())
->first();
// -- Data untuk filter --
$statusOptions = [
'Menunggu' => 'Menunggu Approval',
'Disetujui' => 'Disetujui',
@ -85,7 +84,7 @@ public function index(Request $request)
'Selesai' => 'Selesai'
];
// Tahun options (5 tahun terakhir)
// -- Tahun options (5 tahun terakhir) --
$tahunOptions = range(Carbon::now()->year, Carbon::now()->year - 4);
return view('santri.kepulangan.index', compact(
@ -94,7 +93,9 @@ public function index(Request $request)
'statistik',
'statusOptions',
'tahunOptions',
'tahunSekarang'
'tahunSekarang',
'sedangPulang',
'terlambat'
));
}
@ -103,26 +104,41 @@ public function index(Request $request)
*/
public function show($id_kepulangan)
{
$user = Auth::user();
$idSantri = $this->getSantriId();
$santri = Santri::where('id_santri', $user->role_id)
->select('id_santri', 'nama_lengkap', 'kelas')
->firstOrFail();
$santri = Santri::where('id_santri', $idSantri)->firstOrFail();
// Ambil data kepulangan dengan validasi kepemilikan
// -- Ambil data kepulangan dengan validasi kepemilikan --
$kepulangan = Kepulangan::where('id_kepulangan', $id_kepulangan)
->where('id_santri', $santri->id_santri)
->firstOrFail();
// Hitung total hari izin tahun ini
// -- Hitung total hari izin tahun ini --
$tahunSekarang = Carbon::now()->year;
$totalHariTahunIni = Kepulangan::where('id_santri', $santri->id_santri)
->where('status', 'Disetujui')
->whereIn('status', ['Disetujui', 'Selesai'])
->whereYear('tanggal_pulang', $tahunSekarang)
->sum('durasi_izin');
$sisaKuota = max(0, 12 - $totalHariTahunIni);
$persenKuota = min(100, round(($totalHariTahunIni / 12) * 100));
return view('santri.kepulangan.show', compact('kepulangan', 'santri', 'totalHariTahunIni', 'sisaKuota'));
// -- Riwayat kepulangan lain tahun ini --
$riwayatLain = Kepulangan::where('id_santri', $santri->id_santri)
->where('id_kepulangan', '!=', $id_kepulangan)
->whereYear('tanggal_pulang', $tahunSekarang)
->whereIn('status', ['Disetujui', 'Selesai'])
->orderBy('tanggal_pulang', 'desc')
->limit(5)
->get();
return view('santri.kepulangan.show', compact(
'kepulangan',
'santri',
'totalHariTahunIni',
'sisaKuota',
'persenKuota',
'riwayatLain'
));
}
}

View File

@ -1,4 +1,5 @@
<?php
// app/Http/Controllers/Santri/SantriKesehatanController.php
namespace App\Http\Controllers\Santri;
@ -6,25 +7,30 @@
use App\Models\KesehatanSantri;
use App\Models\Santri;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class SantriKesehatanController extends Controller
{
private function getSantriId()
{
return auth('santri')->user()->id_santri;
}
/**
* Tampilkan riwayat kesehatan santri yang sedang login dengan filter tanggal
* Tampilkan riwayat kesehatan santri yang sedang login
*/
public function index(Request $request)
{
$user = Auth::user();
$idSantri = $this->getSantriId();
// Ambil data santri
$santri = Santri::where('id_santri', $user->role_id)
->select('id_santri', 'nama_lengkap', 'kelas')
// ✅ Fix: hapus 'kelas' dari select, tambah eager load kelasPrimary
$santri = Santri::with('kelasPrimary.kelas')
->where('id_santri', $idSantri)
->select('id_santri', 'nama_lengkap', 'jenis_kelamin', 'status')
->firstOrFail();
// ✅ TENTUKAN RANGE TANGGAL
// Jika tidak ada filter, default bulan ini
// -- Tentukan range tanggal --
$tanggalDari = $request->filled('tanggal_dari')
? Carbon::parse($request->tanggal_dari)
: Carbon::now()->startOfMonth();
@ -33,21 +39,20 @@ public function index(Request $request)
? Carbon::parse($request->tanggal_sampai)
: Carbon::now()->endOfMonth();
// Validasi: tanggal_sampai tidak boleh lebih kecil dari tanggal_dari
// -- Validasi tanggal --
if ($tanggalSampai->lt($tanggalDari)) {
return back()->withErrors([
'tanggal_sampai' => 'Tanggal sampai harus lebih besar atau sama dengan tanggal dari.'
'tanggal_sampai' => 'Tanggal sampai harus lebih besar dari tanggal dari.'
])->withInput();
}
// ✅ QUERY DASAR DENGAN FILTER TANGGAL
$baseQuery = KesehatanSantri::where('id_santri', $santri->id_santri)
// -- Statistik berdasarkan filter tanggal --
$baseQuery = KesehatanSantri::where('id_santri', $idSantri)
->whereBetween('tanggal_masuk', [
$tanggalDari->format('Y-m-d'),
$tanggalSampai->format('Y-m-d')
$tanggalSampai->format('Y-m-d'),
]);
// ✅ HITUNG STATISTIK BERDASARKAN FILTER TANGGAL
$statistik = [
'total_kunjungan' => (clone $baseQuery)->count(),
'sedang_dirawat' => (clone $baseQuery)->where('status', 'dirawat')->count(),
@ -55,39 +60,60 @@ public function index(Request $request)
'izin' => (clone $baseQuery)->where('status', 'izin')->count(),
];
// ✅ QUERY RIWAYAT KESEHATAN UNTUK TABEL
$query = KesehatanSantri::query()
->select([
'id',
'id_kesehatan',
'id_santri',
'tanggal_masuk',
'tanggal_keluar',
'keluhan',
'status',
'created_at'
])
->where('id_santri', $santri->id_santri)
->whereBetween('tanggal_masuk', [
$tanggalDari->format('Y-m-d'),
$tanggalSampai->format('Y-m-d')
// -- Cek apakah SAAT INI sedang dirawat (semua waktu, bukan filter) --
$sedangDirawatSekarang = KesehatanSantri::where('id_santri', $idSantri)
->where('status', 'dirawat')
->latest('tanggal_masuk')
->first();
// -- Data grafik: kunjungan per bulan (6 bulan terakhir) --
$dataGrafik = KesehatanSantri::where('id_santri', $idSantri)
->where('tanggal_masuk', '>=', Carbon::now()->subMonths(6)->startOfMonth())
->select(
DB::raw('YEAR(tanggal_masuk) as tahun'),
DB::raw('MONTH(tanggal_masuk) as bulan'),
DB::raw('COUNT(*) as total'),
DB::raw('SUM(CASE WHEN status = "sembuh" THEN 1 ELSE 0 END) as sembuh'),
DB::raw('SUM(CASE WHEN status = "dirawat" THEN 1 ELSE 0 END) as dirawat'),
DB::raw('SUM(CASE WHEN status = "izin" THEN 1 ELSE 0 END) as izin')
)
->groupBy('tahun', 'bulan')
->orderBy('tahun')
->orderBy('bulan')
->get()
->map(fn($item) => [
'label' => Carbon::createFromDate($item->tahun, $item->bulan, 1)
->locale('id')->isoFormat('MMM YY'),
'total' => $item->total,
'sembuh' => $item->sembuh,
'dirawat' => $item->dirawat,
'izin' => $item->izin,
]);
// -- Statistik total keseluruhan (all time) --
$totalAllTime = KesehatanSantri::where('id_santri', $idSantri)->count();
$totalHariDirawat = KesehatanSantri::where('id_santri', $idSantri)->get()
->sum('lama_dirawat');
// -- Query riwayat dengan filter --
$query = KesehatanSantri::where('id_santri', $idSantri)
->whereBetween('tanggal_masuk', [
$tanggalDari->format('Y-m-d'),
$tanggalSampai->format('Y-m-d'),
]);
// Filter status jika ada
if ($request->filled('status')) {
$query->where('status', $request->status);
}
// Urutkan terbaru dan paginate
$riwayatKesehatan = $query->orderBy('tanggal_masuk', 'desc')
->paginate(10)
->appends($request->all()); // Append query string untuk pagination
->appends($request->all());
// Data untuk filter
$statusOptions = [
'dirawat' => 'Sedang Dirawat',
'sembuh' => 'Sembuh',
'izin' => 'Izin Sakit'
'izin' => 'Izin Sakit',
];
return view('santri.kesehatan.index', compact(
@ -96,7 +122,11 @@ public function index(Request $request)
'statistik',
'statusOptions',
'tanggalDari',
'tanggalSampai'
'tanggalSampai',
'sedangDirawatSekarang',
'dataGrafik',
'totalAllTime',
'totalHariDirawat'
));
}
@ -105,17 +135,29 @@ public function index(Request $request)
*/
public function show($id)
{
$user = Auth::user();
$idSantri = $this->getSantriId();
$santri = Santri::where('id_santri', $user->role_id)
->select('id_santri', 'nama_lengkap', 'kelas')
// ✅ Fix: hapus 'kelas' dari select
$santri = Santri::with('kelasPrimary.kelas')
->where('id_santri', $idSantri)
->select('id_santri', 'nama_lengkap', 'jenis_kelamin', 'status')
->firstOrFail();
// Ambil data kesehatan dengan validasi kepemilikan
$kesehatanSantri = KesehatanSantri::where('id', $id)
->where('id_santri', $santri->id_santri)
->where('id_santri', $idSantri)
->firstOrFail();
return view('santri.kesehatan.show', compact('kesehatanSantri', 'santri'));
// -- Riwayat lain santri ini (untuk konteks) --
$riwayatLain = KesehatanSantri::where('id_santri', $idSantri)
->where('id', '!=', $id)
->orderBy('tanggal_masuk', 'desc')
->take(3)
->get();
return view('santri.kesehatan.show', compact(
'kesehatanSantri',
'santri',
'riwayatLain'
));
}
}

View File

@ -7,26 +7,26 @@
use App\Models\RiwayatPelanggaran;
use App\Models\KategoriPelanggaran;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
class SantriPelanggaranController extends Controller
{
// -- Helper: Ambil id_santri dari akun yang login --
private function getSantriId()
{
return auth('santri')->user()->id_santri;
}
/**
* Tampilkan daftar riwayat pelanggaran santri yang sedang login
*/
public function index(Request $request)
{
$user = Auth::user();
$idSantri = $this->getSantriId();
// Validasi role
if (!in_array($user->role, ['santri', 'wali'])) {
abort(403, 'Akses ditolak.');
}
// Query riwayat pelanggaran dengan relasi
// -- Query riwayat pelanggaran dengan relasi --
$query = RiwayatPelanggaran::with(['kategori:id,id_kategori,nama_pelanggaran,poin'])
->where('id_santri', $user->role_id)
->where('id_santri', $idSantri)
->select([
'id',
'id_riwayat',
@ -38,7 +38,7 @@ public function index(Request $request)
'created_at'
]);
// Filter berdasarkan tanggal (opsional)
// -- Filter berdasarkan tanggal --
if ($request->filled('tanggal_mulai')) {
$query->whereDate('tanggal', '>=', $request->tanggal_mulai);
}
@ -47,18 +47,18 @@ public function index(Request $request)
$query->whereDate('tanggal', '<=', $request->tanggal_selesai);
}
// Filter bulan ini (jika ada parameter)
// -- Filter bulan ini --
if ($request->has('bulan_ini') && $request->bulan_ini == '1') {
$query->bulanIni();
}
// Urutkan dari terbaru
// -- Urutkan dari terbaru --
$riwayat = $query->terbaru()->paginate(15);
// Statistik pelanggaran santri
$totalPelanggaran = RiwayatPelanggaran::where('id_santri', $user->role_id)->count();
$totalPoin = RiwayatPelanggaran::where('id_santri', $user->role_id)->sum('poin');
$pelanggaranBulanIni = RiwayatPelanggaran::where('id_santri', $user->role_id)
// -- Statistik pelanggaran santri --
$totalPelanggaran = RiwayatPelanggaran::where('id_santri', $idSantri)->count();
$totalPoin = RiwayatPelanggaran::where('id_santri', $idSantri)->sum('poin');
$pelanggaranBulanIni = RiwayatPelanggaran::where('id_santri', $idSantri)
->bulanIni()
->count();
@ -75,14 +75,12 @@ public function index(Request $request)
*/
public function show(RiwayatPelanggaran $riwayatPelanggaran)
{
$user = Auth::user();
// Validasi: pastikan pelanggaran milik santri yang login
if ($riwayatPelanggaran->id_santri !== $user->role_id) {
// -- Validasi: pastikan pelanggaran milik santri yang login --
if ($riwayatPelanggaran->id_santri !== $this->getSantriId()) {
abort(403, 'Anda tidak memiliki akses ke data ini.');
}
// Load relasi kategori
// -- Load relasi kategori --
$riwayatPelanggaran->load('kategori:id,id_kategori,nama_pelanggaran,poin');
return view('santri.pelanggaran.show', compact('riwayatPelanggaran'));
@ -93,7 +91,7 @@ public function show(RiwayatPelanggaran $riwayatPelanggaran)
*/
public function kategoriList()
{
// Cache daftar kategori selama 1 jam
// -- Cache daftar kategori selama 1 jam --
$kategoriList = Cache::remember('kategori_pelanggaran_list', 3600, function () {
return KategoriPelanggaran::select([
'id',

View File

@ -0,0 +1,72 @@
<?php
// app/Http/Controllers/Santri/SantriPembinaanController.php
namespace App\Http\Controllers\Santri;
use App\Http\Controllers\Controller;
use App\Models\PembinaanSanksi;
use Illuminate\Support\Facades\Cache;
class SantriPembinaanController extends Controller
{
/**
* Tampilkan daftar konten pembinaan & sanksi
*/
public function index()
{
// Cache 30 menit karena konten jarang berubah
$pembinaanList = Cache::remember('pembinaan_sanksi_aktif', 1800, function () {
return PembinaanSanksi::aktif()
->byUrutan()
->select(['id', 'id_pembinaan', 'judul', 'konten', 'urutan', 'updated_at'])
->get();
});
return view('santri.pembinaan.index', compact('pembinaanList'));
}
/**
* Tampilkan detail satu konten pembinaan & sanksi
*/
public function show($id_pembinaan)
{
$pembinaan = PembinaanSanksi::aktif()
->where('id_pembinaan', $id_pembinaan)
->firstOrFail();
// Konten sebelum dan sesudah untuk navigasi
$prev = PembinaanSanksi::aktif()
->byUrutan()
->where('urutan', '<', $pembinaan->urutan)
->orWhere(function ($q) use ($pembinaan) {
$q->where('urutan', $pembinaan->urutan)
->where('id', '<', $pembinaan->id);
})
->orderBy('urutan', 'desc')
->first();
$next = PembinaanSanksi::aktif()
->byUrutan()
->where('urutan', '>', $pembinaan->urutan)
->orWhere(function ($q) use ($pembinaan) {
$q->where('urutan', $pembinaan->urutan)
->where('id', '>', $pembinaan->id);
})
->orderBy('urutan', 'asc')
->first();
// Semua konten untuk sidebar
$pembinaanList = Cache::remember('pembinaan_sanksi_aktif', 1800, function () {
return PembinaanSanksi::aktif()->byUrutan()
->select(['id', 'id_pembinaan', 'judul', 'urutan'])
->get();
});
return view('santri.pembinaan.show', compact(
'pembinaan',
'pembinaanList',
'prev',
'next'
));
}
}

View File

@ -5,46 +5,43 @@
use App\Http\Controllers\Controller;
use App\Models\Santri;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
class SantriProfileController extends Controller
{
private function getSantriId()
{
return auth('santri')->user()->id_santri;
}
/**
* Tampilkan halaman profil santri yang sedang login
* Tampilkan halaman profil santri yang sedang login (READ ONLY)
*/
public function index()
{
// Ambil data user yang sedang login
$user = Auth::guard('web')->user();
$idSantri = $this->getSantriId();
// Pastikan user adalah santri
if ($user->role !== 'santri') {
abort(403, 'Unauthorized access');
}
// Cache data santri selama 10 menit untuk mengurangi query database
$santri = Cache::remember(
'santri_profile_' . $user->role_id,
600, // 10 menit
function () use ($user) {
return Santri::where('id_santri', $user->role_id)
'santri_profile_' . $idSantri,
600,
function () use ($idSantri) {
return Santri::with(['kelasSantri.kelas.kelompok', 'kelasPrimary.kelas.kelompok'])
->where('id_santri', $idSantri)
->select([
'id',
'id_santri',
'nis',
'nama_lengkap',
'jenis_kelamin',
'kelas',
'status',
'alamat_santri',
'daerah_asal',
'nama_orang_tua',
'nomor_hp_ortu',
'rfid_uid',
'foto', // ✅ TAMBAHAN INI - PENTING!
'created_at'
'foto',
'created_at',
'updated_at',
])
->firstOrFail();
}
@ -52,59 +49,4 @@ function () use ($user) {
return view('santri.profil.index', compact('santri'));
}
/**
* Tampilkan form edit profil (data terbatas yang bisa diedit santri)
*/
public function edit()
{
$user = Auth::guard('web')->user();
if ($user->role !== 'santri') {
abort(403, 'Unauthorized access');
}
$santri = Santri::where('id_santri', $user->role_id)
->select([
'id',
'id_santri',
'nama_lengkap',
'jenis_kelamin', // ✅ TAMBAHAN untuk fallback foto default
'alamat_santri',
'nomor_hp_ortu',
'foto' // ✅ TAMBAHAN INI - PENTING!
])
->firstOrFail();
return view('santri.profil.edit', compact('santri'));
}
/**
* Update profil santri (hanya field tertentu yang boleh diedit)
*/
public function update(Request $request)
{
$user = Auth::guard('web')->user();
if ($user->role !== 'santri') {
abort(403, 'Unauthorized access');
}
$validated = $request->validate([
'alamat_santri' => 'nullable|string|max:500',
'nomor_hp_ortu' => 'nullable|string|max:20|regex:/^[0-9+\-\s()]+$/',
], [
'nomor_hp_ortu.regex' => 'Format nomor HP tidak valid. Hanya boleh berisi angka, +, -, spasi, dan tanda kurung.',
'alamat_santri.max' => 'Alamat maksimal 500 karakter.',
]);
$santri = Santri::where('id_santri', $user->role_id)->firstOrFail();
$santri->update($validated);
// Clear cache setelah update
Cache::forget('santri_profile_' . $user->role_id);
return redirect()->route('santri.profil.index')
->with('success', 'Profil berhasil diperbarui.');
}
}

View File

@ -5,71 +5,62 @@
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use App\Models\UangSaku;
use App\Models\Santri;
class SantriUangSakuController extends Controller
{
private function getSantriId()
{
return auth('santri')->user()->id_santri;
}
/**
* Tampilkan riwayat uang saku santri yang sedang login
*/
public function index(Request $request)
{
try {
$user = Auth::user();
$idSantri = $this->getSantriId();
// Validasi role
if (!in_array($user->role, ['santri', 'wali'])) {
abort(403, 'Akses ditolak');
}
$santri = Santri::with(['kelasPrimary.kelas'])
->where('id_santri', $idSantri)
->firstOrFail();
// Ambil data santri
$santri = Santri::where('id_santri', $user->role_id)->first();
// -- Query uang saku --
$query = UangSaku::where('id_santri', $idSantri);
if (!$santri) {
abort(404, 'Data santri tidak ditemukan');
}
// Query uang saku dengan pagination dan filter
$query = UangSaku::where('id_santri', $santri->id_santri)
->with('santri:id_santri,nama_lengkap,kelas');
// Filter berdasarkan jenis transaksi
// -- Filter jenis transaksi --
if ($request->filled('jenis_transaksi')) {
$query->where('jenis_transaksi', $request->jenis_transaksi);
}
// Filter berdasarkan tanggal
// -- Filter tanggal --
if ($request->filled('tanggal_dari')) {
$query->whereDate('tanggal_transaksi', '>=', $request->tanggal_dari);
}
if ($request->filled('tanggal_sampai')) {
$query->whereDate('tanggal_transaksi', '<=', $request->tanggal_sampai);
}
// Search
// -- Search keterangan --
if ($request->filled('search')) {
$search = $request->search;
$query->where(function($q) use ($search) {
$query->where(function ($q) use ($search) {
$q->where('keterangan', 'like', "%{$search}%")
->orWhere('id_uang_saku', 'like', "%{$search}%");
});
}
// Urutkan dari yang terbaru
$query->orderBy('tanggal_transaksi', 'desc')
->orderBy('created_at', 'desc');
$riwayatUangSaku = $query->orderBy('tanggal_transaksi', 'desc')
->orderBy('created_at', 'desc')
->paginate(15)
->withQueryString();
// Pagination
$riwayatUangSaku = $query->paginate(15)->withQueryString();
// -- Statistik: bulan ini atau sesuai filter tanggal --
$statistikQuery = UangSaku::where('id_santri', $idSantri);
// ✅ Hitung statistik berdasarkan filter atau bulan ini
$statistikQuery = UangSaku::where('id_santri', $santri->id_santri);
// Jika ada filter tanggal, gunakan filter tersebut
if ($request->filled('tanggal_dari') || $request->filled('tanggal_sampai')) {
if ($request->filled('tanggal_dari')) {
$statistikQuery->whereDate('tanggal_transaksi', '>=', $request->tanggal_dari);
@ -78,32 +69,14 @@ public function index(Request $request)
$statistikQuery->whereDate('tanggal_transaksi', '<=', $request->tanggal_sampai);
}
} else {
// Jika tidak ada filter, tampilkan data bulan ini saja
$statistikQuery->whereMonth('tanggal_transaksi', now()->month)
->whereYear('tanggal_transaksi', now()->year);
}
// Clone query untuk menghitung pemasukan dan pengeluaran
$totalPemasukan = (clone $statistikQuery)->where('jenis_transaksi', 'pemasukan')->sum('nominal');
$totalPengeluaran = (clone $statistikQuery)->where('jenis_transaksi', 'pengeluaran')->sum('nominal');
// Saldo terakhir tetap dari data terbaru (tidak terpengaruh filter)
$saldoTerakhir = $santri->saldo_uang_saku;
// Info periode untuk ditampilkan di view
if ($request->filled('tanggal_dari') || $request->filled('tanggal_sampai')) {
$periodeTeks = 'Periode: ';
if ($request->filled('tanggal_dari')) {
$periodeTeks .= \Carbon\Carbon::parse($request->tanggal_dari)->format('d/m/Y');
}
$periodeTeks .= ' - ';
if ($request->filled('tanggal_sampai')) {
$periodeTeks .= \Carbon\Carbon::parse($request->tanggal_sampai)->format('d/m/Y');
}
} else {
$periodeTeks = 'Bulan Ini: ' . now()->isoFormat('MMMM YYYY');
}
return view('santri.uang-saku.index', compact(
'riwayatUangSaku',
'santri',
@ -113,39 +86,33 @@ public function index(Request $request)
));
} catch (\Exception $e) {
Log::error('Error di Riwayat Uang Saku Santri: ' . $e->getMessage());
return back()->with('error', 'Terjadi kesalahan saat memuat riwayat uang saku');
Log::error('Error Riwayat Uang Saku: ' . $e->getMessage());
return back()->with('error', 'Terjadi kesalahan saat memuat data uang saku.');
}
}
/**
* Tampilkan detail transaksi
* Tampilkan detail satu transaksi
*/
public function show($id)
{
try {
$user = Auth::user();
$idSantri = $this->getSantriId();
// Ambil data santri
$santri = Santri::where('id_santri', $user->role_id)->first();
if (!$santri) {
abort(404, 'Data santri tidak ditemukan');
}
// Ambil transaksi dengan validasi kepemilikan
// Pastikan transaksi ini milik santri yang login
$transaksi = UangSaku::where('id', $id)
->where('id_santri', $santri->id_santri)
->with('santri:id_santri,nama_lengkap,kelas')
->where('id_santri', $idSantri)
->with(['santri' => function ($q) {
$q->with('kelasPrimary.kelas')
->select('id_santri', 'nama_lengkap');
}])
->firstOrFail();
return view('santri.uang-saku.show', compact('transaksi', 'santri'));
return view('santri.uang-saku.show', compact('transaksi'));
} catch (\Exception $e) {
Log::error('Error di Detail Uang Saku: ' . $e->getMessage());
return back()->with('error', 'Transaksi tidak ditemukan atau Anda tidak memiliki akses');
Log::error('Error Detail Uang Saku: ' . $e->getMessage());
return back()->with('error', 'Transaksi tidak ditemukan atau Anda tidak memiliki akses.');
}
}
}

View File

@ -36,8 +36,11 @@ class Kernel extends HttpKernel
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
\App\Http\Middleware\ClearStuckSession::class,
// AuthenticateSession dipindah dari global ke alias 'auth.session'
// agar tidak menyebabkan redirect loop pada user tanpa remember_token
// \Illuminate\Session\Middleware\AuthenticateSession::class,
// ClearStuckSession dihapus karena menyebabkan session flush setelah login
// \App\Http\Middleware\ClearStuckSession::class,
],
'api' => [
@ -67,5 +70,6 @@ class Kernel extends HttpKernel
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
'role' => \App\Http\Middleware\Role::class,
'santri.auth' => \App\Http\Middleware\CheckSantriAuth::class,
];
}

View File

@ -0,0 +1,42 @@
<?php
// app/Http/Middleware/CheckSantriAuth.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class CheckSantriAuth
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
if (!Auth::guard('santri')->check()) {
// Jika request AJAX/API, return JSON
if ($request->expectsJson()) {
return response()->json(['message' => 'Unauthenticated.'], 401);
}
return redirect()->route('santri.login')
->with('error', 'Silakan login terlebih dahulu.');
}
$account = Auth::guard('santri')->user();
// PERBAIKAN: pastikan akun masih valid dan punya id_santri
if (!$account || !$account->id_santri) {
Auth::guard('santri')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('santri.login')
->with('error', 'Akun tidak valid. Silakan login ulang.');
}
return $next($request);
}
}

View File

@ -2,7 +2,6 @@
namespace App\Http\Middleware;
use App\Providers\RouteServiceProvider;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@ -10,18 +9,19 @@
class RedirectIfAuthenticated
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next, string ...$guards): Response
{
$guards = empty($guards) ? [null] : $guards;
$path = $request->path();
foreach ($guards as $guard) {
if (Auth::guard($guard)->check()) {
return redirect(RouteServiceProvider::HOME);
if (str_starts_with($path, 'santri')) {
// Halaman guest santri → redirect hanya jika guard santri aktif
if (Auth::guard('santri')->check()) {
return redirect()->route('santri.dashboard');
}
} else {
// Halaman guest admin → redirect hanya jika guard web aktif
if (Auth::check()) {
return redirect()->route('admin.dashboard');
}
}

View File

@ -9,45 +9,27 @@
class Role
{
public function handle(Request $request, Closure $next, string $roles): Response
/**
* Handle an incoming request.
* Menerima daftar role yang diizinkan sebagai parameter middleware.
* Contoh: role:super_admin,akademik,pamong
*
* Laravel memecah parameter setelah ':' menjadi argumen terpisah per koma,
* sehingga kita harus gunakan variadic (...$roles) bukan string tunggal.
*/
public function handle(Request $request, Closure $next, string ...$roles): Response
{
// 1. Cek apakah pengguna sudah login
// -- Cek apakah pengguna sudah login --
if (!Auth::check()) {
// Clear session jika belum login tapi masih ada session
$request->session()->flush();
$request->session()->regenerate();
return redirect('/admin/login');
return redirect()->route('admin.login');
}
// Ambil role pengguna saat ini
$currentRole = Auth::user()->role;
// Pisahkan daftar role yang diizinkan
$allowedRoles = explode(',', $roles);
// 2. Cek apakah role pengguna termasuk dalam daftar yang diizinkan
if (!in_array($currentRole, $allowedRoles)) {
// ✅ TAMBAHAN: Redirect ke dashboard yang sesuai, jangan abort
if ($currentRole === 'admin') {
// -- Cek apakah role pengguna termasuk dalam daftar yang diizinkan --
if (!in_array(Auth::user()->role, $roles)) {
return redirect()->route('admin.dashboard')
->with('error', 'Anda tidak memiliki akses ke halaman tersebut.');
}
if ($currentRole === 'santri' || $currentRole === 'wali') {
return redirect()->route('santri.dashboard')
->with('error', 'Anda tidak memiliki akses ke halaman tersebut.');
}
// Jika role tidak dikenali, logout paksa
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerate();
return redirect('/admin/login')
->with('error', 'Role tidak valid. Silakan login kembali.');
}
return $next($request);
}
}

View File

@ -0,0 +1,43 @@
<?php
// app/Mail/OtpMail.php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class OtpMail extends Mailable
{
use Queueable, SerializesModels;
public string $otp;
public string $nama;
public function __construct(string $otp, string $nama)
{
$this->otp = $otp;
$this->nama = $nama;
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Kode OTP Reset Password - SIM PKPPS',
);
}
public function content(): Content
{
return new Content(
view: 'emails.otp',
);
}
public function attachments(): array
{
return [];
}
}

View File

@ -85,6 +85,8 @@ public function getStatusBadgeAttribute()
'Izin' => '<span class="badge badge-warning"><i class="fas fa-info-circle"></i> Izin</span>',
'Sakit' => '<span class="badge badge-info"><i class="fas fa-heartbeat"></i> Sakit</span>',
'Alpa' => '<span class="badge badge-danger"><i class="fas fa-times"></i> Alpa</span>',
'Terlambat' => '<span class="badge" style="background: #FF9800; color: white;"><i class="fas fa-clock"></i> Terlambat</span>',
'Pulang' => '<span class="badge" style="background: #FFF3E0; color: #E65100;"><i class="fas fa-home"></i> Pulang</span>',
];
return $badges[$this->status] ?? $this->status;
@ -120,6 +122,8 @@ public function getStatusBadgeClassAttribute()
'Izin' => 'badge-info',
'Sakit' => 'badge-warning',
'Alpa' => 'badge-danger',
'Terlambat' => 'badge-warning',
'Pulang' => 'badge-secondary',
default => 'badge-secondary',
};
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PasswordResetOtp extends Model
{
protected $table = 'password_reset_otps';
protected $fillable = [
'email',
'otp',
'expired_at',
'is_verified',
];
protected $casts = [
'expired_at' => 'datetime',
'is_verified' => 'boolean',
];
/**
* Cek apakah OTP sudah expired
*/
public function isExpired(): bool
{
return now()->greaterThan($this->expired_at);
}
}

View File

@ -33,9 +33,10 @@ class PembayaranSpp extends Model
'updated_at' => 'datetime',
];
/**
* Boot method untuk auto-generate ID
*/
// ══════════════════════════════════════════════════════
// BOOT
// ══════════════════════════════════════════════════════
protected static function boot()
{
parent::boot();
@ -49,47 +50,152 @@ protected static function boot()
});
}
/**
* Relasi: Pembayaran SPP milik satu Santri
*/
// ══════════════════════════════════════════════════════
// RELASI
// ══════════════════════════════════════════════════════
public function santri()
{
return $this->belongsTo(Santri::class, 'id_santri', 'id_santri');
}
// ══════════════════════════════════════════════════════
// CICILAN HELPERS
//
// Status di DB tetap "Belum Lunas" (tidak ubah enum).
// Cicilan dideteksi dari keterangan berformat JSON:
// {"terbayar": 150000, "catatan": "Cicilan ke-1"}
//
// Keterangan teks biasa (non-JSON) tetap terbaca normal.
// ══════════════════════════════════════════════════════
/**
* Accessor: Nama bulan dalam bahasa Indonesia
* Cek apakah record ini berstatus cicilan
* (status Belum Lunas + ada data terbayar di keterangan).
*/
public function getBulanNamaAttribute()
public function isCicilan(): bool
{
if ($this->status !== 'Belum Lunas') return false;
$data = $this->getCicilanData();
return $data !== null && ($data['terbayar'] ?? 0) > 0;
}
/**
* Ambil array cicilan dari keterangan, atau null jika bukan JSON cicilan.
*/
public function getCicilanData(): ?array
{
if (!$this->keterangan) return null;
$decoded = json_decode($this->keterangan, true);
if (json_last_error() !== JSON_ERROR_NONE) return null;
if (!array_key_exists('terbayar', $decoded)) return null;
return $decoded;
}
/**
* Nominal yang sudah dibayar.
*/
public function getNominalTerbayarAttribute(): float
{
if ($this->status === 'Lunas') return (float) $this->nominal;
$data = $this->getCicilanData();
return $data ? (float) ($data['terbayar'] ?? 0) : 0;
}
/**
* Sisa yang belum dibayar.
*/
public function getNominalSisaAttribute(): float
{
return max(0, (float) $this->nominal - $this->nominal_terbayar);
}
/**
* Persentase cicilan (0100).
*/
public function getPorsentaseCicilanAttribute(): int
{
if (!$this->nominal || (float) $this->nominal == 0) return 0;
return (int) min(100, round(($this->nominal_terbayar / (float) $this->nominal) * 100));
}
/**
* Simpan progres cicilan ke keterangan (JSON).
* Status DB tidak diubah tetap "Belum Lunas".
*/
public function setCicilan(float $terbayar, ?string $catatan = null): void
{
// Jika keterangan sebelumnya teks biasa, pindahkan sebagai catatan
if ($this->keterangan && !$this->getCicilanData()) {
$catatan = $catatan ?? $this->keterangan;
}
$data = ['terbayar' => $terbayar];
if ($catatan) $data['catatan'] = $catatan;
$this->keterangan = json_encode($data);
}
/**
* Baca catatan teks (dari JSON atau teks biasa).
*/
public function getCatatanTeksAttribute(): ?string
{
if (!$this->keterangan) return null;
$data = $this->getCicilanData();
if ($data) return $data['catatan'] ?? null;
return $this->keterangan;
}
// ══════════════════════════════════════════════════════
// ACCESSORS
// ══════════════════════════════════════════════════════
public function getBulanNamaAttribute(): string
{
$bulanIndo = [
1 => 'Januari', 2 => 'Februari', 3 => 'Maret',
4 => 'April', 5 => 'Mei', 6 => 'Juni',
7 => 'Juli', 8 => 'Agustus', 9 => 'September',
10 => 'Oktober', 11 => 'November', 12 => 'Desember'
10 => 'Oktober',11 => 'November', 12 => 'Desember'
];
return $bulanIndo[$this->bulan] ?? '-';
}
/**
* Accessor: Periode lengkap (Januari 2024)
*/
public function getPeriodeLengkapAttribute()
public function getPeriodeLengkapAttribute(): string
{
return $this->bulan_nama . ' ' . $this->tahun;
}
public function getNominalFormatAttribute(): string
{
return 'Rp ' . number_format($this->nominal, 0, ',', '.');
}
public function getNominalTerbayarFormatAttribute(): string
{
return 'Rp ' . number_format($this->nominal_terbayar, 0, ',', '.');
}
public function getNominalSisaFormatAttribute(): string
{
return 'Rp ' . number_format($this->nominal_sisa, 0, ',', '.');
}
/**
* Accessor: Status Badge HTML
* Status Badge HTML mengenali cicilan dari keterangan JSON,
* bukan dari nilai kolom status.
*/
public function getStatusBadgeAttribute()
public function getStatusBadgeAttribute(): string
{
if ($this->status === 'Lunas') {
return '<span class="badge badge-success"><i class="fas fa-check-circle"></i> Lunas</span>';
}
// Cek apakah telat
if ($this->isCicilan()) {
return '<span class="badge badge-cicilan"><i class="fas fa-coins"></i> Cicilan ' . $this->porsentase_cicilan . '%</span>';
}
if ($this->isTelat()) {
return '<span class="badge badge-danger"><i class="fas fa-exclamation-triangle"></i> Belum Lunas (Telat)</span>';
}
@ -97,73 +203,49 @@ public function getStatusBadgeAttribute()
return '<span class="badge badge-warning"><i class="fas fa-clock"></i> Belum Lunas</span>';
}
/**
* Cek apakah pembayaran sudah telat
*/
public function isTelat()
{
if ($this->status === 'Lunas') {
return false;
}
// ══════════════════════════════════════════════════════
// HELPERS
// ══════════════════════════════════════════════════════
public function isTelat(): bool
{
if ($this->status === 'Lunas') return false;
return Carbon::now()->isAfter($this->batas_bayar);
}
/**
* Accessor: Nominal format Rupiah
*/
public function getNominalFormatAttribute()
{
return 'Rp ' . number_format($this->nominal, 0, ',', '.');
}
// ══════════════════════════════════════════════════════
// SCOPES
// ══════════════════════════════════════════════════════
/**
* Scope: Filter pembayaran belum lunas
*/
public function scopeBelumLunas($query)
{
return $query->where('status', 'Belum Lunas');
}
/**
* Scope: Filter pembayaran lunas
*/
public function scopeLunas($query)
{
return $query->where('status', 'Lunas');
}
/**
* Scope: Filter pembayaran telat
*/
public function scopeTelat($query)
{
return $query->where('status', 'Belum Lunas')
->where('batas_bayar', '<', Carbon::now());
}
/**
* Scope: Filter by tahun
*/
public function scopeTahun($query, $tahun)
{
return $query->where('tahun', $tahun);
}
/**
* Scope: Filter by bulan
*/
public function scopeBulan($query, $bulan)
{
return $query->where('bulan', $bulan);
}
/**
* Scope: Search
*/
public function scopeSearch($query, $search)
{
return $query->whereHas('santri', function($q) use ($search) {
return $query->whereHas('santri', function ($q) use ($search) {
$q->where('nama_lengkap', 'like', "%{$search}%")
->orWhere('id_santri', 'like', "%{$search}%")
->orWhere('nis', 'like', "%{$search}%");

View File

@ -52,20 +52,28 @@ protected static function boot()
}
/**
* Relasi: Santri memiliki satu User Account (hasOne)
* Relasi: Santri memiliki banyak akun (santri_accounts)
*/
public function santriAccount()
{
return $this->hasMany(SantriAccount::class, 'id_santri', 'id_santri');
}
/**
* Relasi: Santri memiliki satu User Account (hasOne) - LEGACY
*/
public function user()
{
return $this->hasOne(User::class, 'role_id', 'id_santri')
return $this->hasOne(SantriAccount::class, 'id_santri', 'id_santri')
->where('role', 'santri');
}
/**
* Relasi: Santri memiliki satu akun Wali (orang tua)
* Relasi: Santri memiliki satu akun Wali (orang tua) - LEGACY
*/
public function waliUser()
{
return $this->hasOne(User::class, 'role_id', 'id_santri')
return $this->hasOne(SantriAccount::class, 'id_santri', 'id_santri')
->where('role', 'wali');
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class SantriAccount extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
protected $table = 'santri_accounts';
protected $fillable = [
'id_santri',
'username',
'password',
'role',
'last_login',
];
protected $hidden = [
'password',
'remember_token',
];
// HAPUS 'password' => 'hashed' — hanya Laravel 10+
protected $casts = [
'last_login' => 'datetime',
];
// Ganti dengan mutator manual
public function setPasswordAttribute(string $value): void
{
// Cegah double hash
if (
!str_starts_with($value, '$2y$') &&
!str_starts_with($value, '$argon2i$') &&
!str_starts_with($value, '$argon2id$')
) {
$this->attributes['password'] = bcrypt($value);
} else {
$this->attributes['password'] = $value;
}
}
public function santri()
{
return $this->belongsTo(Santri::class, 'id_santri', 'id_santri');
}
public function isSantri(): bool
{
return $this->role === 'santri';
}
public function isWali(): bool
{
return $this->role === 'wali';
}
}

View File

@ -18,7 +18,6 @@ class User extends Authenticatable
'username',
'password',
'role',
'role_id',
];
protected $hidden = [
@ -31,35 +30,47 @@ class User extends Authenticatable
'password' => 'hashed',
];
/**
* Relasi ke Santri
*/
public function santri()
{
return $this->belongsTo(Santri::class, 'role_id', 'id_santri');
}
// ══════════════════ HELPER METHODS ══════════════════
/**
* Relasi ke Wali
* Cek apakah user adalah admin (semua role admin)
*/
public function wali()
{
return $this->belongsTo(Wali::class, 'role_id', 'id_wali');
}
// Helper methods
public function isAdmin()
{
return $this->role === 'admin';
return in_array($this->role, ['super_admin', 'akademik', 'pamong']);
}
public function isSantri()
/**
* Cek apakah user adalah super admin
*/
public function isSuperAdmin()
{
return $this->role === 'santri';
return $this->role === 'super_admin';
}
public function isWali()
/**
* Cek apakah user adalah akademik
*/
public function isAkademik()
{
return $this->role === 'wali';
return $this->role === 'akademik';
}
/**
* Cek apakah user adalah pamong
*/
public function isPamong()
{
return $this->role === 'pamong';
}
/**
* Cek apakah user memiliki salah satu role yang diberikan.
* Contoh: $user->hasRole('super_admin', 'akademik')
*/
public function hasRole()
{
$roles = func_get_args();
return in_array($this->role, $roles);
}
}

View File

@ -3,6 +3,7 @@
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Pagination\Paginator;
class AppServiceProvider extends ServiceProvider
{
@ -19,6 +20,7 @@ public function register(): void
*/
public function boot(): void
{
//
Paginator::defaultView('vendor.pagination.custom');
Paginator::defaultSimpleView('vendor.pagination.custom');
}
}

View File

@ -0,0 +1,119 @@
<?php
// app/Services/CapaianAccessService.php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
/**
* Service untuk mengelola akses input capaian oleh santri.
* Menggunakan Laravel Cache (no migration needed).
* Data disimpan di cache dengan key 'capaian_access_config'.
*/
class CapaianAccessService
{
const CACHE_KEY = 'capaian_access_config';
const CACHE_TTL = 60 * 24 * 30; // 30 hari (dalam menit)
/**
* Ambil konfigurasi akses saat ini.
*/
public static function getConfig(): array
{
return Cache::get(self::CACHE_KEY, [
'is_open' => false,
'opened_by' => null,
'opened_at' => null,
'closed_at' => null,
'catatan' => null,
'id_semester' => null,
// Opsional: auto-close setelah X jam (null = manual)
'auto_close_at'=> null,
]);
}
/**
* Buka akses input capaian untuk santri.
*/
public static function open(array $params = []): void
{
$config = self::getConfig();
$autoCloseAt = null;
if (!empty($params['durasi_jam'])) {
$autoCloseAt = now()->addHours((int) $params['durasi_jam'])->toIso8601String();
}
$config = array_merge($config, [
'is_open' => true,
'opened_by' => $params['opened_by'] ?? auth()->user()?->name,
'opened_at' => now()->toIso8601String(),
'closed_at' => null,
'catatan' => $params['catatan'] ?? null,
'id_semester' => $params['id_semester'] ?? null,
'auto_close_at' => $autoCloseAt,
]);
Cache::put(self::CACHE_KEY, $config, now()->addMinutes(self::CACHE_TTL));
}
/**
* Tutup akses input capaian.
*/
public static function close(): void
{
$config = self::getConfig();
$config['is_open'] = false;
$config['closed_at'] = now()->toIso8601String();
Cache::put(self::CACHE_KEY, $config, now()->addMinutes(self::CACHE_TTL));
}
/**
* Cek apakah akses sedang dibuka.
* Otomatis tutup jika sudah melewati auto_close_at.
*/
public static function isOpen(): bool
{
$config = self::getConfig();
if (!$config['is_open']) return false;
// Auto-close check
if (!empty($config['auto_close_at'])) {
if (now()->isAfter($config['auto_close_at'])) {
self::close();
return false;
}
}
return true;
}
/**
* Cek apakah semester yang dibuka cocok dengan semester tertentu.
* Jika id_semester di config null, berarti semua semester boleh.
*/
public static function isOpenForSemester(?string $idSemester): bool
{
if (!self::isOpen()) return false;
$config = self::getConfig();
if (empty($config['id_semester'])) return true; // semua semester
return $config['id_semester'] === $idSemester;
}
/**
* Ambil sisa waktu auto-close dalam format human readable.
*/
public static function getSisaWaktu(): ?string
{
$config = self::getConfig();
if (empty($config['auto_close_at'])) return null;
$close = \Carbon\Carbon::parse($config['auto_close_at']);
if (now()->isAfter($close)) return 'Sudah berakhir';
return now()->diffForHumans($close, ['parts' => 2, 'syntax' => \Carbon\CarbonInterface::DIFF_ABSOLUTE]);
}
}

View File

@ -1,2 +0,0 @@
*
!.gitignore

74
sim-pkpps/bootstrap/cache/packages.php vendored Normal file
View File

@ -0,0 +1,74 @@
<?php return array (
'barryvdh/laravel-dompdf' =>
array (
'aliases' =>
array (
'PDF' => 'Barryvdh\\DomPDF\\Facade\\Pdf',
'Pdf' => 'Barryvdh\\DomPDF\\Facade\\Pdf',
),
'providers' =>
array (
0 => 'Barryvdh\\DomPDF\\ServiceProvider',
),
),
'laravel/breeze' =>
array (
'providers' =>
array (
0 => 'Laravel\\Breeze\\BreezeServiceProvider',
),
),
'laravel/sail' =>
array (
'providers' =>
array (
0 => 'Laravel\\Sail\\SailServiceProvider',
),
),
'laravel/sanctum' =>
array (
'providers' =>
array (
0 => 'Laravel\\Sanctum\\SanctumServiceProvider',
),
),
'laravel/tinker' =>
array (
'providers' =>
array (
0 => 'Laravel\\Tinker\\TinkerServiceProvider',
),
),
'nesbot/carbon' =>
array (
'providers' =>
array (
0 => 'Carbon\\Laravel\\ServiceProvider',
),
),
'nunomaduro/collision' =>
array (
'providers' =>
array (
0 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
),
),
'nunomaduro/termwind' =>
array (
'providers' =>
array (
0 => 'Termwind\\Laravel\\TermwindServiceProvider',
),
),
'spatie/laravel-ignition' =>
array (
'aliases' =>
array (
'Flare' => 'Spatie\\LaravelIgnition\\Facades\\Flare',
),
'providers' =>
array (
0 => 'Spatie\\LaravelIgnition\\IgnitionServiceProvider',
),
),
);

257
sim-pkpps/bootstrap/cache/services.php vendored Normal file
View File

@ -0,0 +1,257 @@
<?php return array (
'providers' =>
array (
0 => 'Illuminate\\Auth\\AuthServiceProvider',
1 => 'Illuminate\\Broadcasting\\BroadcastServiceProvider',
2 => 'Illuminate\\Bus\\BusServiceProvider',
3 => 'Illuminate\\Cache\\CacheServiceProvider',
4 => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
5 => 'Illuminate\\Cookie\\CookieServiceProvider',
6 => 'Illuminate\\Database\\DatabaseServiceProvider',
7 => 'Illuminate\\Encryption\\EncryptionServiceProvider',
8 => 'Illuminate\\Filesystem\\FilesystemServiceProvider',
9 => 'Illuminate\\Foundation\\Providers\\FoundationServiceProvider',
10 => 'Illuminate\\Hashing\\HashServiceProvider',
11 => 'Illuminate\\Mail\\MailServiceProvider',
12 => 'Illuminate\\Notifications\\NotificationServiceProvider',
13 => 'Illuminate\\Pagination\\PaginationServiceProvider',
14 => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider',
15 => 'Illuminate\\Pipeline\\PipelineServiceProvider',
16 => 'Illuminate\\Queue\\QueueServiceProvider',
17 => 'Illuminate\\Redis\\RedisServiceProvider',
18 => 'Illuminate\\Session\\SessionServiceProvider',
19 => 'Illuminate\\Translation\\TranslationServiceProvider',
20 => 'Illuminate\\Validation\\ValidationServiceProvider',
21 => 'Illuminate\\View\\ViewServiceProvider',
22 => 'Barryvdh\\DomPDF\\ServiceProvider',
23 => 'Laravel\\Breeze\\BreezeServiceProvider',
24 => 'Laravel\\Sail\\SailServiceProvider',
25 => 'Laravel\\Sanctum\\SanctumServiceProvider',
26 => 'Laravel\\Tinker\\TinkerServiceProvider',
27 => 'Carbon\\Laravel\\ServiceProvider',
28 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
29 => 'Termwind\\Laravel\\TermwindServiceProvider',
30 => 'Spatie\\LaravelIgnition\\IgnitionServiceProvider',
31 => 'App\\Providers\\AppServiceProvider',
32 => 'App\\Providers\\AuthServiceProvider',
33 => 'App\\Providers\\EventServiceProvider',
34 => 'App\\Providers\\RouteServiceProvider',
),
'eager' =>
array (
0 => 'Illuminate\\Auth\\AuthServiceProvider',
1 => 'Illuminate\\Cookie\\CookieServiceProvider',
2 => 'Illuminate\\Database\\DatabaseServiceProvider',
3 => 'Illuminate\\Encryption\\EncryptionServiceProvider',
4 => 'Illuminate\\Filesystem\\FilesystemServiceProvider',
5 => 'Illuminate\\Foundation\\Providers\\FoundationServiceProvider',
6 => 'Illuminate\\Notifications\\NotificationServiceProvider',
7 => 'Illuminate\\Pagination\\PaginationServiceProvider',
8 => 'Illuminate\\Session\\SessionServiceProvider',
9 => 'Illuminate\\View\\ViewServiceProvider',
10 => 'Barryvdh\\DomPDF\\ServiceProvider',
11 => 'Laravel\\Sanctum\\SanctumServiceProvider',
12 => 'Carbon\\Laravel\\ServiceProvider',
13 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
14 => 'Termwind\\Laravel\\TermwindServiceProvider',
15 => 'Spatie\\LaravelIgnition\\IgnitionServiceProvider',
16 => 'App\\Providers\\AppServiceProvider',
17 => 'App\\Providers\\AuthServiceProvider',
18 => 'App\\Providers\\EventServiceProvider',
19 => 'App\\Providers\\RouteServiceProvider',
),
'deferred' =>
array (
'Illuminate\\Broadcasting\\BroadcastManager' => 'Illuminate\\Broadcasting\\BroadcastServiceProvider',
'Illuminate\\Contracts\\Broadcasting\\Factory' => 'Illuminate\\Broadcasting\\BroadcastServiceProvider',
'Illuminate\\Contracts\\Broadcasting\\Broadcaster' => 'Illuminate\\Broadcasting\\BroadcastServiceProvider',
'Illuminate\\Bus\\Dispatcher' => 'Illuminate\\Bus\\BusServiceProvider',
'Illuminate\\Contracts\\Bus\\Dispatcher' => 'Illuminate\\Bus\\BusServiceProvider',
'Illuminate\\Contracts\\Bus\\QueueingDispatcher' => 'Illuminate\\Bus\\BusServiceProvider',
'Illuminate\\Bus\\BatchRepository' => 'Illuminate\\Bus\\BusServiceProvider',
'Illuminate\\Bus\\DatabaseBatchRepository' => 'Illuminate\\Bus\\BusServiceProvider',
'cache' => 'Illuminate\\Cache\\CacheServiceProvider',
'cache.store' => 'Illuminate\\Cache\\CacheServiceProvider',
'cache.psr6' => 'Illuminate\\Cache\\CacheServiceProvider',
'memcached.connector' => 'Illuminate\\Cache\\CacheServiceProvider',
'Illuminate\\Cache\\RateLimiter' => 'Illuminate\\Cache\\CacheServiceProvider',
'Illuminate\\Foundation\\Console\\AboutCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Cache\\Console\\ClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Cache\\Console\\ForgetCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ClearCompiledCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Auth\\Console\\ClearResetsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ConfigCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ConfigClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ConfigShowCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\DbCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\MonitorCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\PruneCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\ShowCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\TableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\WipeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\DownCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EnvironmentCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EnvironmentDecryptCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EnvironmentEncryptCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EventCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EventClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EventListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\KeyGenerateCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\OptimizeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\OptimizeClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\PackageDiscoverCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Cache\\Console\\PruneStaleTagsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\ClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\ListFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\FlushFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\ForgetFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\ListenCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\MonitorCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\PruneBatchesCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\PruneFailedJobsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\RestartCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\RetryCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\RetryBatchCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\WorkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\RouteCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\RouteClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\RouteListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\DumpCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Seeds\\SeedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Console\\Scheduling\\ScheduleFinishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Console\\Scheduling\\ScheduleListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Console\\Scheduling\\ScheduleRunCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Console\\Scheduling\\ScheduleClearCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Console\\Scheduling\\ScheduleTestCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Console\\Scheduling\\ScheduleWorkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Console\\Scheduling\\ScheduleInterruptCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\ShowModelCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\StorageLinkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\StorageUnlinkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\UpCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ViewCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ViewClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Cache\\Console\\CacheTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\CastMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ChannelListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ChannelMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ComponentMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ConsoleMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Routing\\Console\\ControllerMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\DocsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EventGenerateCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EventMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ExceptionMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Factories\\FactoryMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\JobMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\LangPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ListenerMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\MailMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Routing\\Console\\MiddlewareMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ModelMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\NotificationMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Notifications\\Console\\NotificationTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ObserverMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\PolicyMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ProviderMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\FailedTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\TableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\BatchesTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\RequestMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ResourceMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\RuleMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ScopeMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Seeds\\SeederMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Session\\Console\\SessionTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ServeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\StubPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\TestMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\VendorPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ViewMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'migrator' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'migration.repository' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'migration.creator' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\MigrateCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\FreshCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\InstallCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\RefreshCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\ResetCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\RollbackCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\StatusCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\MigrateMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'composer' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'hash' => 'Illuminate\\Hashing\\HashServiceProvider',
'hash.driver' => 'Illuminate\\Hashing\\HashServiceProvider',
'mail.manager' => 'Illuminate\\Mail\\MailServiceProvider',
'mailer' => 'Illuminate\\Mail\\MailServiceProvider',
'Illuminate\\Mail\\Markdown' => 'Illuminate\\Mail\\MailServiceProvider',
'auth.password' => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider',
'auth.password.broker' => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider',
'Illuminate\\Contracts\\Pipeline\\Hub' => 'Illuminate\\Pipeline\\PipelineServiceProvider',
'pipeline' => 'Illuminate\\Pipeline\\PipelineServiceProvider',
'queue' => 'Illuminate\\Queue\\QueueServiceProvider',
'queue.connection' => 'Illuminate\\Queue\\QueueServiceProvider',
'queue.failer' => 'Illuminate\\Queue\\QueueServiceProvider',
'queue.listener' => 'Illuminate\\Queue\\QueueServiceProvider',
'queue.worker' => 'Illuminate\\Queue\\QueueServiceProvider',
'redis' => 'Illuminate\\Redis\\RedisServiceProvider',
'redis.connection' => 'Illuminate\\Redis\\RedisServiceProvider',
'translator' => 'Illuminate\\Translation\\TranslationServiceProvider',
'translation.loader' => 'Illuminate\\Translation\\TranslationServiceProvider',
'validator' => 'Illuminate\\Validation\\ValidationServiceProvider',
'validation.presence' => 'Illuminate\\Validation\\ValidationServiceProvider',
'Illuminate\\Contracts\\Validation\\UncompromisedVerifier' => 'Illuminate\\Validation\\ValidationServiceProvider',
'Laravel\\Breeze\\Console\\InstallCommand' => 'Laravel\\Breeze\\BreezeServiceProvider',
'Laravel\\Sail\\Console\\InstallCommand' => 'Laravel\\Sail\\SailServiceProvider',
'Laravel\\Sail\\Console\\PublishCommand' => 'Laravel\\Sail\\SailServiceProvider',
'command.tinker' => 'Laravel\\Tinker\\TinkerServiceProvider',
),
'when' =>
array (
'Illuminate\\Broadcasting\\BroadcastServiceProvider' =>
array (
),
'Illuminate\\Bus\\BusServiceProvider' =>
array (
),
'Illuminate\\Cache\\CacheServiceProvider' =>
array (
),
'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider' =>
array (
),
'Illuminate\\Hashing\\HashServiceProvider' =>
array (
),
'Illuminate\\Mail\\MailServiceProvider' =>
array (
),
'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider' =>
array (
),
'Illuminate\\Pipeline\\PipelineServiceProvider' =>
array (
),
'Illuminate\\Queue\\QueueServiceProvider' =>
array (
),
'Illuminate\\Redis\\RedisServiceProvider' =>
array (
),
'Illuminate\\Translation\\TranslationServiceProvider' =>
array (
),
'Illuminate\\Validation\\ValidationServiceProvider' =>
array (
),
'Laravel\\Breeze\\BreezeServiceProvider' =>
array (
),
'Laravel\\Sail\\SailServiceProvider' =>
array (
),
'Laravel\\Tinker\\TinkerServiceProvider' =>
array (
),
),
);

View File

@ -7,10 +7,12 @@
"require": {
"php": "^8.1",
"barryvdh/laravel-dompdf": "^3.1",
"doctrine/dbal": "^3.10",
"guzzlehttp/guzzle": "^7.2",
"laravel/framework": "^10.10",
"laravel/sanctum": "^3.3",
"laravel/tinker": "^2.8"
"laravel/tinker": "^2.8",
"mpdf/mpdf": "^8.2"
},
"require-dev": {
"fakerphp/faker": "^1.9.1",

721
sim-pkpps/composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "3b130f909d8c7acd5973043a958c243d",
"content-hash": "a470717879bee7fca3c22f312f8d5be7",
"packages": [
{
"name": "barryvdh/laravel-dompdf",
@ -287,6 +287,259 @@
},
"time": "2024-07-08T12:26:09+00:00"
},
{
"name": "doctrine/dbal",
"version": "3.10.4",
"source": {
"type": "git",
"url": "https://github.com/doctrine/dbal.git",
"reference": "63a46cb5aa6f60991186cc98c1d1b50c09311868"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/dbal/zipball/63a46cb5aa6f60991186cc98c1d1b50c09311868",
"reference": "63a46cb5aa6f60991186cc98c1d1b50c09311868",
"shasum": ""
},
"require": {
"composer-runtime-api": "^2",
"doctrine/deprecations": "^0.5.3|^1",
"doctrine/event-manager": "^1|^2",
"php": "^7.4 || ^8.0",
"psr/cache": "^1|^2|^3",
"psr/log": "^1|^2|^3"
},
"conflict": {
"doctrine/cache": "< 1.11"
},
"require-dev": {
"doctrine/cache": "^1.11|^2.0",
"doctrine/coding-standard": "14.0.0",
"fig/log-test": "^1",
"jetbrains/phpstorm-stubs": "2023.1",
"phpstan/phpstan": "2.1.30",
"phpstan/phpstan-strict-rules": "^2",
"phpunit/phpunit": "9.6.29",
"slevomat/coding-standard": "8.24.0",
"squizlabs/php_codesniffer": "4.0.0",
"symfony/cache": "^5.4|^6.0|^7.0|^8.0",
"symfony/console": "^4.4|^5.4|^6.0|^7.0|^8.0"
},
"suggest": {
"symfony/console": "For helpful console commands such as SQL execution and import of files."
},
"bin": [
"bin/doctrine-dbal"
],
"type": "library",
"autoload": {
"psr-4": {
"Doctrine\\DBAL\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Guilherme Blanco",
"email": "guilhermeblanco@gmail.com"
},
{
"name": "Roman Borschel",
"email": "roman@code-factory.org"
},
{
"name": "Benjamin Eberlei",
"email": "kontakt@beberlei.de"
},
{
"name": "Jonathan Wage",
"email": "jonwage@gmail.com"
}
],
"description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.",
"homepage": "https://www.doctrine-project.org/projects/dbal.html",
"keywords": [
"abstraction",
"database",
"db2",
"dbal",
"mariadb",
"mssql",
"mysql",
"oci8",
"oracle",
"pdo",
"pgsql",
"postgresql",
"queryobject",
"sasql",
"sql",
"sqlite",
"sqlserver",
"sqlsrv"
],
"support": {
"issues": "https://github.com/doctrine/dbal/issues",
"source": "https://github.com/doctrine/dbal/tree/3.10.4"
},
"funding": [
{
"url": "https://www.doctrine-project.org/sponsorship.html",
"type": "custom"
},
{
"url": "https://www.patreon.com/phpdoctrine",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal",
"type": "tidelift"
}
],
"time": "2025-11-29T10:46:08+00:00"
},
{
"name": "doctrine/deprecations",
"version": "1.1.6",
"source": {
"type": "git",
"url": "https://github.com/doctrine/deprecations.git",
"reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
"reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"conflict": {
"phpunit/phpunit": "<=7.5 || >=14"
},
"require-dev": {
"doctrine/coding-standard": "^9 || ^12 || ^14",
"phpstan/phpstan": "1.4.10 || 2.1.30",
"phpstan/phpstan-phpunit": "^1.0 || ^2",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0",
"psr/log": "^1 || ^2 || ^3"
},
"suggest": {
"psr/log": "Allows logging deprecations via PSR-3 logger implementation"
},
"type": "library",
"autoload": {
"psr-4": {
"Doctrine\\Deprecations\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.",
"homepage": "https://www.doctrine-project.org/",
"support": {
"issues": "https://github.com/doctrine/deprecations/issues",
"source": "https://github.com/doctrine/deprecations/tree/1.1.6"
},
"time": "2026-02-07T07:09:04+00:00"
},
{
"name": "doctrine/event-manager",
"version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/doctrine/event-manager.git",
"reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/event-manager/zipball/dda33921b198841ca8dbad2eaa5d4d34769d18cf",
"reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"conflict": {
"doctrine/common": "<2.9"
},
"require-dev": {
"doctrine/coding-standard": "^14",
"phpdocumentor/guides-cli": "^1.4",
"phpstan/phpstan": "^2.1.32",
"phpunit/phpunit": "^10.5.58"
},
"type": "library",
"autoload": {
"psr-4": {
"Doctrine\\Common\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Guilherme Blanco",
"email": "guilhermeblanco@gmail.com"
},
{
"name": "Roman Borschel",
"email": "roman@code-factory.org"
},
{
"name": "Benjamin Eberlei",
"email": "kontakt@beberlei.de"
},
{
"name": "Jonathan Wage",
"email": "jonwage@gmail.com"
},
{
"name": "Johannes Schmitt",
"email": "schmittjoh@gmail.com"
},
{
"name": "Marco Pivetta",
"email": "ocramius@gmail.com"
}
],
"description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.",
"homepage": "https://www.doctrine-project.org/projects/event-manager.html",
"keywords": [
"event",
"event dispatcher",
"event manager",
"event system",
"events"
],
"support": {
"issues": "https://github.com/doctrine/event-manager/issues",
"source": "https://github.com/doctrine/event-manager/tree/2.1.1"
},
"funding": [
{
"url": "https://www.doctrine-project.org/sponsorship.html",
"type": "custom"
},
{
"url": "https://www.patreon.com/phpdoctrine",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager",
"type": "tidelift"
}
],
"time": "2026-01-29T07:11:08+00:00"
},
{
"name": "doctrine/inflector",
"version": "2.1.0",
@ -2291,6 +2544,239 @@
],
"time": "2025-03-24T10:02:05+00:00"
},
{
"name": "mpdf/mpdf",
"version": "v8.2.7",
"source": {
"type": "git",
"url": "https://github.com/mpdf/mpdf.git",
"reference": "b59670a09498689c33ce639bac8f5ba26721dab3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/mpdf/mpdf/zipball/b59670a09498689c33ce639bac8f5ba26721dab3",
"reference": "b59670a09498689c33ce639bac8f5ba26721dab3",
"shasum": ""
},
"require": {
"ext-gd": "*",
"ext-mbstring": "*",
"mpdf/psr-http-message-shim": "^1.0 || ^2.0",
"mpdf/psr-log-aware-trait": "^2.0 || ^3.0",
"myclabs/deep-copy": "^1.7",
"paragonie/random_compat": "^1.4|^2.0|^9.99.99",
"php": "^5.6 || ^7.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"psr/http-message": "^1.0 || ^2.0",
"psr/log": "^1.0 || ^2.0 || ^3.0",
"setasign/fpdi": "^2.1"
},
"require-dev": {
"mockery/mockery": "^1.3.0",
"mpdf/qrcode": "^1.1.0",
"squizlabs/php_codesniffer": "^3.5.0",
"tracy/tracy": "~2.5",
"yoast/phpunit-polyfills": "^1.0"
},
"suggest": {
"ext-bcmath": "Needed for generation of some types of barcodes",
"ext-xml": "Needed mainly for SVG manipulation",
"ext-zlib": "Needed for compression of embedded resources, such as fonts"
},
"type": "library",
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"Mpdf\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"GPL-2.0-only"
],
"authors": [
{
"name": "Matěj Humpál",
"role": "Developer, maintainer"
},
{
"name": "Ian Back",
"role": "Developer (retired)"
}
],
"description": "PHP library generating PDF files from UTF-8 encoded HTML",
"homepage": "https://mpdf.github.io",
"keywords": [
"pdf",
"php",
"utf-8"
],
"support": {
"docs": "https://mpdf.github.io",
"issues": "https://github.com/mpdf/mpdf/issues",
"source": "https://github.com/mpdf/mpdf"
},
"funding": [
{
"url": "https://www.paypal.me/mpdf",
"type": "custom"
}
],
"time": "2025-12-01T10:18:02+00:00"
},
{
"name": "mpdf/psr-http-message-shim",
"version": "v2.0.1",
"source": {
"type": "git",
"url": "https://github.com/mpdf/psr-http-message-shim.git",
"reference": "f25a0153d645e234f9db42e5433b16d9b113920f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/mpdf/psr-http-message-shim/zipball/f25a0153d645e234f9db42e5433b16d9b113920f",
"reference": "f25a0153d645e234f9db42e5433b16d9b113920f",
"shasum": ""
},
"require": {
"psr/http-message": "^2.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Mpdf\\PsrHttpMessageShim\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Dorison",
"email": "mark@chromatichq.com"
},
{
"name": "Kristofer Widholm",
"email": "kristofer@chromatichq.com"
},
{
"name": "Nigel Cunningham",
"email": "nigel.cunningham@technocrat.com.au"
}
],
"description": "Shim to allow support of different psr/message versions.",
"support": {
"issues": "https://github.com/mpdf/psr-http-message-shim/issues",
"source": "https://github.com/mpdf/psr-http-message-shim/tree/v2.0.1"
},
"time": "2023-10-02T14:34:03+00:00"
},
{
"name": "mpdf/psr-log-aware-trait",
"version": "v3.0.0",
"source": {
"type": "git",
"url": "https://github.com/mpdf/psr-log-aware-trait.git",
"reference": "a633da6065e946cc491e1c962850344bb0bf3e78"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/mpdf/psr-log-aware-trait/zipball/a633da6065e946cc491e1c962850344bb0bf3e78",
"reference": "a633da6065e946cc491e1c962850344bb0bf3e78",
"shasum": ""
},
"require": {
"psr/log": "^3.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Mpdf\\PsrLogAwareTrait\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Dorison",
"email": "mark@chromatichq.com"
},
{
"name": "Kristofer Widholm",
"email": "kristofer@chromatichq.com"
}
],
"description": "Trait to allow support of different psr/log versions.",
"support": {
"issues": "https://github.com/mpdf/psr-log-aware-trait/issues",
"source": "https://github.com/mpdf/psr-log-aware-trait/tree/v3.0.0"
},
"time": "2023-05-03T06:19:36+00:00"
},
{
"name": "myclabs/deep-copy",
"version": "1.13.4",
"source": {
"type": "git",
"url": "https://github.com/myclabs/DeepCopy.git",
"reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
"reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"conflict": {
"doctrine/collections": "<1.6.8",
"doctrine/common": "<2.13.3 || >=3 <3.2.2"
},
"require-dev": {
"doctrine/collections": "^1.6.8",
"doctrine/common": "^2.13.3 || ^3.2.2",
"phpspec/prophecy": "^1.10",
"phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
},
"type": "library",
"autoload": {
"files": [
"src/DeepCopy/deep_copy.php"
],
"psr-4": {
"DeepCopy\\": "src/DeepCopy/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Create deep copies (clones) of your objects",
"keywords": [
"clone",
"copy",
"duplicate",
"object",
"object graph"
],
"support": {
"issues": "https://github.com/myclabs/DeepCopy/issues",
"source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
},
"funding": [
{
"url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
"type": "tidelift"
}
],
"time": "2025-08-01T08:46:24+00:00"
},
{
"name": "nesbot/carbon",
"version": "2.73.0",
@ -2692,6 +3178,56 @@
],
"time": "2024-11-21T10:36:35+00:00"
},
{
"name": "paragonie/random_compat",
"version": "v9.99.100",
"source": {
"type": "git",
"url": "https://github.com/paragonie/random_compat.git",
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a",
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a",
"shasum": ""
},
"require": {
"php": ">= 7"
},
"require-dev": {
"phpunit/phpunit": "4.*|5.*",
"vimeo/psalm": "^1"
},
"suggest": {
"ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes."
},
"type": "library",
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "security@paragonie.com",
"homepage": "https://paragonie.com"
}
],
"description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7",
"keywords": [
"csprng",
"polyfill",
"pseudorandom",
"random"
],
"support": {
"email": "info@paragonie.com",
"issues": "https://github.com/paragonie/random_compat/issues",
"source": "https://github.com/paragonie/random_compat"
},
"time": "2020-10-15T08:29:30+00:00"
},
{
"name": "phpoption/phpoption",
"version": "1.9.4",
@ -2767,6 +3303,55 @@
],
"time": "2025-08-21T11:53:16+00:00"
},
{
"name": "psr/cache",
"version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/cache.git",
"reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
"reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Cache\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for caching libraries",
"keywords": [
"cache",
"psr",
"psr-6"
],
"support": {
"source": "https://github.com/php-fig/cache/tree/3.0.0"
},
"time": "2021-02-03T23:26:27+00:00"
},
{
"name": "psr/clock",
"version": "1.0.0",
@ -3521,6 +4106,78 @@
},
"time": "2025-07-11T13:20:48+00:00"
},
{
"name": "setasign/fpdi",
"version": "v2.6.4",
"source": {
"type": "git",
"url": "https://github.com/Setasign/FPDI.git",
"reference": "4b53852fde2734ec6a07e458a085db627c60eada"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Setasign/FPDI/zipball/4b53852fde2734ec6a07e458a085db627c60eada",
"reference": "4b53852fde2734ec6a07e458a085db627c60eada",
"shasum": ""
},
"require": {
"ext-zlib": "*",
"php": "^7.1 || ^8.0"
},
"conflict": {
"setasign/tfpdf": "<1.31"
},
"require-dev": {
"phpunit/phpunit": "^7",
"setasign/fpdf": "~1.8.6",
"setasign/tfpdf": "~1.33",
"squizlabs/php_codesniffer": "^3.5",
"tecnickcom/tcpdf": "^6.8"
},
"suggest": {
"setasign/fpdf": "FPDI will extend this class but as it is also possible to use TCPDF or tFPDF as an alternative. There's no fixed dependency configured."
},
"type": "library",
"autoload": {
"psr-4": {
"setasign\\Fpdi\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jan Slabon",
"email": "jan.slabon@setasign.com",
"homepage": "https://www.setasign.com"
},
{
"name": "Maximilian Kresse",
"email": "maximilian.kresse@setasign.com",
"homepage": "https://www.setasign.com"
}
],
"description": "FPDI is a collection of PHP classes facilitating developers to read pages from existing PDF documents and use them as templates in FPDF. Because it is also possible to use FPDI with TCPDF, there are no fixed dependencies defined. Please see suggestions for packages which evaluates the dependencies automatically.",
"homepage": "https://www.setasign.com/fpdi",
"keywords": [
"fpdf",
"fpdi",
"pdf"
],
"support": {
"issues": "https://github.com/Setasign/FPDI/issues",
"source": "https://github.com/Setasign/FPDI/tree/v2.6.4"
},
"funding": [
{
"url": "https://tidelift.com/funding/github/packagist/setasign/fpdi",
"type": "tidelift"
}
],
"time": "2025-08-05T09:57:14+00:00"
},
{
"name": "symfony/console",
"version": "v6.4.25",
@ -6500,66 +7157,6 @@
},
"time": "2024-05-16T03:13:13+00:00"
},
{
"name": "myclabs/deep-copy",
"version": "1.13.4",
"source": {
"type": "git",
"url": "https://github.com/myclabs/DeepCopy.git",
"reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
"reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"conflict": {
"doctrine/collections": "<1.6.8",
"doctrine/common": "<2.13.3 || >=3 <3.2.2"
},
"require-dev": {
"doctrine/collections": "^1.6.8",
"doctrine/common": "^2.13.3 || ^3.2.2",
"phpspec/prophecy": "^1.10",
"phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
},
"type": "library",
"autoload": {
"files": [
"src/DeepCopy/deep_copy.php"
],
"psr-4": {
"DeepCopy\\": "src/DeepCopy/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Create deep copies (clones) of your objects",
"keywords": [
"clone",
"copy",
"duplicate",
"object",
"object graph"
],
"support": {
"issues": "https://github.com/myclabs/DeepCopy/issues",
"source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
},
"funding": [
{
"url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
"type": "tidelift"
}
],
"time": "2025-08-01T08:46:24+00:00"
},
{
"name": "nunomaduro/collision",
"version": "v7.12.0",
@ -8674,5 +9271,5 @@
"php": "^8.1"
},
"platform-dev": {},
"plugin-api-version": "2.6.0"
"plugin-api-version": "2.9.0"
}

View File

@ -70,7 +70,7 @@
|
*/
'timezone' => 'UTC',
'timezone' => 'Asia/Jakarta',
/*
|--------------------------------------------------------------------------

View File

@ -40,6 +40,10 @@
'driver' => 'session',
'provider' => 'users',
],
'santri' => [
'driver' => 'session',
'provider' => 'santri_accounts',
],
],
/*
@ -64,6 +68,10 @@
'driver' => 'eloquent',
'model' => App\Models\User::class,
],
'santri_accounts' => [
'driver' => 'eloquent',
'model' => App\Models\SantriAccount::class,
],
// 'users' => [
// 'driver' => 'database',

View File

@ -33,7 +33,7 @@
|
*/
'guard' => ['web'],
'guard' => ['santri'],
/*
|--------------------------------------------------------------------------

View File

@ -0,0 +1,44 @@
<?php
// database/migrations/2026_02_24_000001_update_users_role_enum.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// -- Langkah 1: Perluas enum dengan semua nilai (lama + baru) --
DB::statement("ALTER TABLE `users` MODIFY `role` ENUM('admin','super_admin','akademik','pamong','santri','wali') NOT NULL DEFAULT 'admin'");
// -- Langkah 2: Update data lama 'admin' → 'super_admin' --
DB::table('users')
->where('role', 'admin')
->update(['role' => 'super_admin']);
// -- Langkah 3: Hapus 'admin' dari enum, set default baru --
DB::statement("ALTER TABLE `users` MODIFY `role` ENUM('super_admin','akademik','pamong','santri','wali') NOT NULL DEFAULT 'super_admin'");
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// -- Langkah 1: Perluas enum kembali dengan 'admin' --
DB::statement("ALTER TABLE `users` MODIFY `role` ENUM('admin','super_admin','akademik','pamong','santri','wali') NOT NULL DEFAULT 'super_admin'");
// -- Langkah 2: Kembalikan role admin roles ke 'admin' --
DB::table('users')
->whereIn('role', ['super_admin', 'akademik', 'pamong'])
->update(['role' => 'admin']);
// -- Langkah 3: Kembalikan ke enum lama --
DB::statement("ALTER TABLE `users` MODIFY `role` ENUM('admin','santri','wali') NOT NULL DEFAULT 'admin'");
}
};

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Modify enum to add Terlambat and Pulang
DB::statement("ALTER TABLE absensi_kegiatans MODIFY COLUMN status ENUM('Hadir', 'Izin', 'Sakit', 'Alpa', 'Terlambat', 'Pulang') NOT NULL");
}
/**
* Reverse the migrations.
*/
public function down(): void
{
DB::statement("ALTER TABLE absensi_kegiatans MODIFY COLUMN status ENUM('Hadir', 'Izin', 'Sakit', 'Alpa') NOT NULL");
}
};

View File

@ -0,0 +1,39 @@
<?php
// database/migrations/2026_02_25_100001_create_santri_accounts_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('santri_accounts', function (Blueprint $table) {
$table->id();
$table->string('id_santri');
$table->string('username')->unique();
$table->string('password');
$table->enum('role', ['santri', 'wali'])->default('santri');
$table->rememberToken();
$table->timestamp('last_login')->nullable();
$table->timestamps();
$table->foreign('id_santri')
->references('id_santri')
->on('santris')
->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('santri_accounts');
}
};

View File

@ -0,0 +1,43 @@
<?php
// database/migrations/2026_02_25_100002_remove_santri_wali_from_users_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// -- Hapus semua user dengan role santri/wali --
DB::table('users')->whereIn('role', ['santri', 'wali'])->delete();
// -- Ubah enum role hanya untuk admin (raw SQL karena DBAL tidak support enum) --
DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('super_admin','akademik','pamong') NOT NULL DEFAULT 'akademik'");
// -- Hapus kolom role_id jika ada --
if (Schema::hasColumn('users', 'role_id')) {
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('role_id');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('super_admin','akademik','pamong','santri','wali') NOT NULL DEFAULT 'akademik'");
if (!Schema::hasColumn('users', 'role_id')) {
Schema::table('users', function (Blueprint $table) {
$table->string('role_id')->nullable()->after('role');
});
}
}
};

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Already applied manually — ensures 'Khatam' is a valid status value
DB::statement("ALTER TABLE santris MODIFY COLUMN status ENUM('Aktif','Lulus','Tidak Aktif','Khatam') NOT NULL DEFAULT 'Aktif'");
}
/**
* Reverse the migrations.
*/
public function down(): void
{
DB::statement("ALTER TABLE santris MODIFY COLUMN status ENUM('Aktif','Lulus','Tidak Aktif') NOT NULL DEFAULT 'Aktif'");
}
};

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('password_reset_otps', function (Blueprint $table) {
$table->id();
$table->string('email')->index();
$table->string('otp', 6);
$table->timestamp('expired_at');
$table->boolean('is_verified')->default(false);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('password_reset_otps');
}
};

View File

@ -12,11 +12,8 @@ class DatabaseSeeder extends Seeder
*/
public function run(): void
{
// \App\Models\User::factory(10)->create();
// \App\Models\User::factory()->create([
// 'name' => 'Test User',
// 'email' => 'test@example.com',
// ]);
$this->call([
SantriSeeder::class,
]);
}
}

View File

@ -0,0 +1,329 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class SantriSeeder extends Seeder
{
/**
* ============================================================
* REFERENSI KODE KELAS (dari KelasSeeder)
* ============================================================
* Kelas Pondok (KEL001):
* KLS001 = PB
* KLS002 = Lambatan
* KLS003 = Cepatan
*
* Sekolah Formal (KEL002):
* KLS004 = SD 1 | KLS005 = SD 2 | KLS006 = SD 3
* KLS007 = SD 4 | KLS008 = SD 5 | KLS009 = SD 6
* KLS010 = SMP 7 | KLS011 = SMP 8 | KLS012 = SMP 9
* KLS013 = SMA 10 | KLS014 = SMA 11 | KLS015 = SMA 12
* ============================================================
*
* CARA EDIT KELAS SANTRI:
* Setiap santri punya array 'kelas' berisi kode kelas yang diikuti.
* - is_primary: true = kelas utama (kelas pondok)
* - is_primary: false = kelas tambahan (kelas formal)
* Ubah kode kelas di bagian 'kelas' sesuai data asli!
*/
public function run(): void
{
// Hanya hapus data S003 ke atas, S001 & S002 tetap aman
DB::statement('SET FOREIGN_KEY_CHECKS=0;');
DB::table('santri_kelas')
->whereIn('id_santri', array_map(fn($n) => 'S' . str_pad($n, 3, '0', STR_PAD_LEFT), range(3, 99)))
->delete();
DB::table('santris')
->whereIn('id_santri', array_map(fn($n) => 'S' . str_pad($n, 3, '0', STR_PAD_LEFT), range(3, 99)))
->delete();
DB::statement('SET FOREIGN_KEY_CHECKS=1;');
$tahunAjaran = '2024/2025';
$now = Carbon::now();
$santriList = [
// ============================================================
// S003 - S034: Edit bagian 'kelas' sesuai data asli!
// ============================================================
[
'santri' => ['id_santri' => 'S003', 'nis' => '510035160089253003', 'nama_lengkap' => 'Altaf Baihaqi Amrullah', 'jenis_kelamin' => 'Laki-laki', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Tiebuk RT 006 RW 003 Wiyu, Pacet, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => 'Baihaqi', 'nomor_hp_ortu' => '+6281234560003', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS001', 'is_primary' => true], // PB ← UBAH SESUAI DATA
['kode' => 'KLS004', 'is_primary' => false], // SD 1 ← UBAH SESUAI DATA
],
],
[
'santri' => ['id_santri' => 'S004', 'nis' => '510035160089253004', 'nama_lengkap' => 'Aminati Yusrin Isnaini', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Brangkal RT 002 RW 001 Brangkal, Sooko, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => 'Yusrin', 'nomor_hp_ortu' => '+6281234560004', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS001', 'is_primary' => true], // PB ← UBAH
['kode' => 'KLS005', 'is_primary' => false], // SD 2 ← UBAH
],
],
[
'santri' => ['id_santri' => 'S005', 'nis' => '510035160089253005', 'nama_lengkap' => 'Ananda Novreandis', 'jenis_kelamin' => 'Laki-laki', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Jampirogo RT 006 RW 001 Jampirogo, Sooko, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => 'Novreandis Sr.', 'nomor_hp_ortu' => '+6281234560005', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS001', 'is_primary' => true], // PB ← UBAH
['kode' => 'KLS004', 'is_primary' => false], // SD 1 ← UBAH
],
],
[
'santri' => ['id_santri' => 'S006', 'nis' => '510035160089253006', 'nama_lengkap' => 'Andika Maulana Ishaq', 'jenis_kelamin' => 'Laki-laki', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Jemparing RT 001 RW 001 Pakel, Bareng, Jombang', 'daerah_asal' => 'Jombang Selatan', 'nama_orang_tua' => 'Ishaq', 'nomor_hp_ortu' => '+6281234560006', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS002', 'is_primary' => true], // Lambatan ← UBAH
['kode' => 'KLS010', 'is_primary' => false], // SMP 7 ← UBAH
],
],
[
'santri' => ['id_santri' => 'S007', 'nis' => '510035160089253007', 'nama_lengkap' => 'Anggraini Nur Dina Fahma', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Plosarejo RT 002 RW 001 Tlogoagung, Kedungadem, Bojonegoro', 'daerah_asal' => 'Bojonegoro Timur', 'nama_orang_tua' => 'Nur Fahma', 'nomor_hp_ortu' => '+6281234560007', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS002', 'is_primary' => true], // Lambatan ← UBAH
['kode' => 'KLS011', 'is_primary' => false], // SMP 8 ← UBAH
],
],
[
'santri' => ['id_santri' => 'S008', 'nis' => '510035160089253008', 'nama_lengkap' => 'Azalia Calysta Salsabila', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Bulu RT 002 RW 001 Gedangan, Kutorejo, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => 'Calysta Sr.', 'nomor_hp_ortu' => '+6281234560008', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS001', 'is_primary' => true], // PB ← UBAH
['kode' => 'KLS006', 'is_primary' => false], // SD 3 ← UBAH
],
],
[
'santri' => ['id_santri' => 'S009', 'nis' => '510035160089253009', 'nama_lengkap' => 'Bustomi Firman Amrulloh', 'jenis_kelamin' => 'Laki-laki', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Kuwik RT 001 RW 001 Bareng, Bareng, Jombang', 'daerah_asal' => 'Jombang Selatan', 'nama_orang_tua' => 'Firman', 'nomor_hp_ortu' => '+6281234560009', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS002', 'is_primary' => true], // Lambatan ← UBAH
['kode' => 'KLS007', 'is_primary' => false], // SD 4 ← UBAH
],
],
[
'santri' => ['id_santri' => 'S010', 'nis' => '510035160089253010', 'nama_lengkap' => 'Cresya Nirva Arvenda', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Kedungmaling II RT 014 RW 006 Kedungmaling, Sooko, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => 'Nirva Sr.', 'nomor_hp_ortu' => '+6281234560010', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS003', 'is_primary' => true], // Cepatan ← UBAH
['kode' => 'KLS013', 'is_primary' => false], // SMA 10 ← UBAH
],
],
[
'santri' => ['id_santri' => 'S011', 'nis' => '510035160089253011', 'nama_lengkap' => 'Daud Fasal', 'jenis_kelamin' => 'Laki-laki', 'status' => 'Aktif', 'alamat_santri' => 'Plumpang RT 017 RW 004 Penambangan, Balongbendo, Sidoarjo', 'daerah_asal' => 'Sidoarjo Utara', 'nama_orang_tua' => 'Fasal Sr.', 'nomor_hp_ortu' => '+6281234560011', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS001', 'is_primary' => true], // PB ← UBAH
['kode' => 'KLS010', 'is_primary' => false], // SMP 7 ← UBAH
],
],
[
'santri' => ['id_santri' => 'S012', 'nis' => '510035160089253012', 'nama_lengkap' => 'Dwi Melviana Putri', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Panggih RT 003 RW 003 Panggih, Trowulan, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => 'Melviana Sr.', 'nomor_hp_ortu' => '+6281234560012', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS002', 'is_primary' => true], // Lambatan ← UBAH
['kode' => 'KLS014', 'is_primary' => false], // SMA 11 ← UBAH
],
],
[
'santri' => ['id_santri' => 'S013', 'nis' => '510035160089253013', 'nama_lengkap' => 'Fina Yusrina Jannah', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Kepuhanyar RT 001 RW 001 Kepuhanyar, Mojoanyar, Mojokerto', 'daerah_asal' => 'Mojokerto Kota', 'nama_orang_tua' => 'Yusrina Sr.', 'nomor_hp_ortu' => '+6281234560013', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS003', 'is_primary' => true], // Cepatan ← UBAH
['kode' => 'KLS015', 'is_primary' => false], // SMA 12 ← UBAH
],
],
[
'santri' => ['id_santri' => 'S014', 'nis' => '510035160089253014', 'nama_lengkap' => 'Gadis Sholikhah', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Brangkal RT 002 RW 001 Brangkal, Sooko, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => 'Sholikhah Sr.', 'nomor_hp_ortu' => '+6281234560014', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS001', 'is_primary' => true], // PB ← UBAH
['kode' => 'KLS008', 'is_primary' => false], // SD 5 ← UBAH
],
],
[
'santri' => ['id_santri' => 'S015', 'nis' => '510035160089253015', 'nama_lengkap' => 'Gilang Aswin Nahar', 'jenis_kelamin' => 'Laki-laki', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Panggih RT 001 RW 003 Panggih, Trowulan, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => 'Aswin', 'nomor_hp_ortu' => '+6281234560015', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS002', 'is_primary' => true], // Lambatan ← UBAH
['kode' => 'KLS009', 'is_primary' => false], // SD 6 ← UBAH
],
],
[
'santri' => ['id_santri' => 'S016', 'nis' => '510035160089253016', 'nama_lengkap' => 'Gustiyar Abdullah Manshurin', 'jenis_kelamin' => 'Laki-laki', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Setro RT 007 RW 001 Jatirejo, Kasreman, Ngawi', 'daerah_asal' => 'Ngawi Kota', 'nama_orang_tua' => 'Abdullah', 'nomor_hp_ortu' => '+6281234560016', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS001', 'is_primary' => true], // PB ← UBAH
['kode' => 'KLS012', 'is_primary' => false], // SMP 9 ← UBAH
],
],
[
'santri' => ['id_santri' => 'S017', 'nis' => '510035160089253017', 'nama_lengkap' => 'Ilham Maulana Abdillah', 'jenis_kelamin' => 'Laki-laki', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Tundungan RT 004 RW 002 Sidomojo, Krian, Sidoarjo', 'daerah_asal' => 'Sidoarjo Utara', 'nama_orang_tua' => 'Maulana', 'nomor_hp_ortu' => '+6281234560017', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS003', 'is_primary' => true], // Cepatan ← UBAH
['kode' => 'KLS013', 'is_primary' => false], // SMA 10 ← UBAH
],
],
[
'santri' => ['id_santri' => 'S018', 'nis' => '510035160089253018', 'nama_lengkap' => 'Kafa Septian Ramdan Efendi', 'jenis_kelamin' => 'Laki-laki', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Brangkal RT 002 RW001 Brangkal, Sooko, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => 'Septian', 'nomor_hp_ortu' => '+6281234560018', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS002', 'is_primary' => true], // Lambatan ← UBAH
['kode' => 'KLS011', 'is_primary' => false], // SMP 8 ← UBAH
],
],
[
'santri' => ['id_santri' => 'S019', 'nis' => '510035160089253019', 'nama_lengkap' => "Khalisa Syifa'ul Aini", 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Tiebuk RT 007 RW 003 Wiyu, Pacet, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => "Syifa'ul Sr.", 'nomor_hp_ortu' => '+6281234560019', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS001', 'is_primary' => true], // PB ← UBAH
['kode' => 'KLS005', 'is_primary' => false], // SD 2 ← UBAH
],
],
[
'santri' => ['id_santri' => 'S020', 'nis' => '510035160089253020', 'nama_lengkap' => 'Kharisa Nur Qalbi', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Pasinan Lemah Putih RT 011 RW 003, Wringinanom, Gresik', 'daerah_asal' => 'Gresik Selatan', 'nama_orang_tua' => 'Nur Qalbi Sr.', 'nomor_hp_ortu' => '+6281234560020', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS003', 'is_primary' => true], // Cepatan ← UBAH
['kode' => 'KLS014', 'is_primary' => false], // SMA 11 ← UBAH
],
],
[
'santri' => ['id_santri' => 'S021', 'nis' => '510035160089253021', 'nama_lengkap' => 'Lana Novpriyanto', 'jenis_kelamin' => 'Laki-laki', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Parengan RT 019 RW 004 Kraton, Krian, Sidoarjo', 'daerah_asal' => 'Sidoarjo Utara', 'nama_orang_tua' => 'Novpriyanto Sr.', 'nomor_hp_ortu' => '+6281234560021', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS001', 'is_primary' => true], // PB ← UBAH
['kode' => 'KLS010', 'is_primary' => false], // SMP 7 ← UBAH
],
],
[
'santri' => ['id_santri' => 'S022', 'nis' => '510035160089253022', 'nama_lengkap' => 'M. Reyhan Firdaus', 'jenis_kelamin' => 'Laki-laki', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Jampirogo RT 005 RW 001 Jampirogo, Sooko, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => 'Reyhan Sr.', 'nomor_hp_ortu' => '+6281234560022', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS002', 'is_primary' => true], // Lambatan ← UBAH
['kode' => 'KLS006', 'is_primary' => false], // SD 3 ← UBAH
],
],
[
'santri' => ['id_santri' => 'S023', 'nis' => '510035160089253023', 'nama_lengkap' => 'Masrurotin Fatma Ayu Wulandari', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Pakis Kulon RT 003 RW 003 Pakis, Trowulan, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => 'Fatma Sr.', 'nomor_hp_ortu' => '+6281234560023', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS003', 'is_primary' => true], // Cepatan ← UBAH
['kode' => 'KLS015', 'is_primary' => false], // SMA 12 ← UBAH
],
],
[
'santri' => ['id_santri' => 'S024', 'nis' => '510035160089253024', 'nama_lengkap' => 'Mochammad Adam Madinata', 'jenis_kelamin' => 'Laki-laki', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Kepuhanyar RT 001 RW001 Kepuhanyar, Mojoanyar, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => 'Adam Sr.', 'nomor_hp_ortu' => '+6281234560024', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS001', 'is_primary' => true], // PB ← UBAH
['kode' => 'KLS007', 'is_primary' => false], // SD 4 ← UBAH
],
],
[
'santri' => ['id_santri' => 'S025', 'nis' => '510035160089253025', 'nama_lengkap' => "Muchammad Fachrizal Ta'awamu Insan", 'jenis_kelamin' => 'Laki-laki', 'status' => 'Aktif', 'alamat_santri' => 'Sidokepung RT 015 RW 003 Buduran, Sidoarjo', 'daerah_asal' => 'Sidoarjo Tengah', 'nama_orang_tua' => 'Fachrizal', 'nomor_hp_ortu' => '+6281234560025', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS002', 'is_primary' => true], // Lambatan ← UBAH
['kode' => 'KLS011', 'is_primary' => false], // SMP 8 ← UBAH
],
],
[
'santri' => ['id_santri' => 'S026', 'nis' => '510035160089253026', 'nama_lengkap' => 'Muhammad Revano Fadillah Ramadhan', 'jenis_kelamin' => 'Laki-laki', 'status' => 'Aktif', 'alamat_santri' => 'Kemlagi Timur RT 001 RW 003 Kemlagi, Kemlagi, Mojokerto', 'daerah_asal' => 'Mojokerto Kota', 'nama_orang_tua' => 'Fadillah', 'nomor_hp_ortu' => '+6281234560026', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS003', 'is_primary' => true], // Cepatan ← UBAH
['kode' => 'KLS012', 'is_primary' => false], // SMP 9 ← UBAH
],
],
[
'santri' => ['id_santri' => 'S027', 'nis' => '510035160089253027', 'nama_lengkap' => 'Muhammad Ibrahim Try Aji', 'jenis_kelamin' => 'Laki-laki', 'status' => 'Aktif', 'alamat_santri' => 'Jln. Patimura RT 002 RW 002 Keboan, Ngusikan, Jombang', 'daerah_asal' => 'Mojokerto Kota', 'nama_orang_tua' => 'Ibrahim', 'nomor_hp_ortu' => '+6281234560027', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS001', 'is_primary' => true], // PB ← UBAH
['kode' => 'KLS013', 'is_primary' => false], // SMA 10 ← UBAH
],
],
[
'santri' => ['id_santri' => 'S028', 'nis' => '510035160089253028', 'nama_lengkap' => 'Mutiara Dira Ardiana', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Tiebuk RT 008 RW 001 Wiyu, Pacet, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => 'Dira Sr.', 'nomor_hp_ortu' => '+6281234560028', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS002', 'is_primary' => true], // Lambatan ← UBAH
['kode' => 'KLS008', 'is_primary' => false], // SD 5 ← UBAH
],
],
[
'santri' => ['id_santri' => 'S029', 'nis' => '510035160089253029', 'nama_lengkap' => 'Nerissa Arviana Maharani', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Ngangkrik Kidul RT 001 RW 003 Gebangangkrik, Ngimbang, Lamongan', 'daerah_asal' => 'Kambangan', 'nama_orang_tua' => 'Arviana Sr.', 'nomor_hp_ortu' => '+6281234560029', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS003', 'is_primary' => true], // Cepatan ← UBAH
['kode' => 'KLS009', 'is_primary' => false], // SD 6 ← UBAH
],
],
[
'santri' => ['id_santri' => 'S030', 'nis' => '510035160089253030', 'nama_lengkap' => 'Prisca Zuzin Firdaus', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Ngrayung RT 004 RW 001 Brayung, Puri, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => 'Zuzin Sr.', 'nomor_hp_ortu' => '+6281234560030', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS001', 'is_primary' => true], // PB ← UBAH
['kode' => 'KLS004', 'is_primary' => false], // SD 1 ← UBAH
],
],
[
'santri' => ['id_santri' => 'S031', 'nis' => '510035160089253031', 'nama_lengkap' => 'Shoffiya Fitriani Az Zahra', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Dadi RT 027 RW 014 Dadi, Plaosan, Magetan', 'daerah_asal' => 'Magetan', 'nama_orang_tua' => 'Fitriani Sr.', 'nomor_hp_ortu' => '+6281234560031', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS002', 'is_primary' => true], // Lambatan ← UBAH
['kode' => 'KLS005', 'is_primary' => false], // SD 2 ← UBAH
],
],
[
'santri' => ['id_santri' => 'S032', 'nis' => '510035160089253032', 'nama_lengkap' => 'Syifa Putri Ramahdani', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Sumbertempur Perum Puri Kencana RT 003 RW003 Sumbergirang, Puri, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => 'Putri Sr.', 'nomor_hp_ortu' => '+6281234560032', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS003', 'is_primary' => true], // Cepatan ← UBAH
['kode' => 'KLS006', 'is_primary' => false], // SD 3 ← UBAH
],
],
[
'santri' => ['id_santri' => 'S033', 'nis' => '510035160089253033', 'nama_lengkap' => 'Tiara Rahmadhani Faradilah', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Bulurejo RT 001 RW 002 Kepuhkajang, Perak, Jombang', 'daerah_asal' => 'Mojokerto Kota', 'nama_orang_tua' => 'Rahmadhani Sr.', 'nomor_hp_ortu' => '+6281234560033', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS001', 'is_primary' => true], // PB ← UBAH
['kode' => 'KLS014', 'is_primary' => false], // SMA 11 ← UBAH
],
],
[
'santri' => ['id_santri' => 'S034', 'nis' => '510035160089253034', 'nama_lengkap' => 'Virlye Andyra Zahra', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Randuwates RT 005 RW 003 Mojowatesrejo, Kemlagi, Mojokerto', 'daerah_asal' => 'Mojokerto Kota', 'nama_orang_tua' => 'Andyra Sr.', 'nomor_hp_ortu' => '+6281234560034', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now],
'kelas' => [
['kode' => 'KLS002', 'is_primary' => true], // Lambatan ← UBAH
['kode' => 'KLS015', 'is_primary' => false], // SMA 12 ← UBAH
],
],
];
// ============================================================
// PROSES INSERT — jangan ubah bagian ini
// ============================================================
// Ambil mapping kode_kelas -> id dari DB
$kelasMap = DB::table('kelas')->pluck('id', 'kode_kelas')->toArray();
$totalSantri = 0;
$totalKelas = 0;
$errors = [];
foreach ($santriList as $item) {
DB::table('santris')->insert($item['santri']);
$totalSantri++;
$idSantri = $item['santri']['id_santri'];
foreach ($item['kelas'] as $kelasItem) {
$kode = $kelasItem['kode'];
if (!isset($kelasMap[$kode])) {
$errors[] = "⚠️ Kelas [{$kode}] tidak ada di DB! Santri {$idSantri} dilewati untuk kelas ini.";
continue;
}
DB::table('santri_kelas')->insert([
'id_santri' => $idSantri,
'id_kelas' => $kelasMap[$kode],
'tahun_ajaran' => $tahunAjaran,
'is_primary' => $kelasItem['is_primary'],
'created_at' => $now,
'updated_at' => $now,
]);
$totalKelas++;
}
}
$this->command->info("✅ SantriSeeder selesai!");
$this->command->info("{$totalSantri} santri ditambahkan (S003S034)");
$this->command->info("{$totalKelas} record santri_kelas ditambahkan");
$this->command->info(" → Tahun ajaran: {$tahunAjaran}");
$this->command->info(" → S001 & S002 tidak tersentuh ✓");
if (!empty($errors)) {
$this->command->warn("\nAda masalah:");
foreach ($errors as $err) {
$this->command->warn(" " . $err);
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,311 @@
{{-- resources/views/admin/auth/forgot_password.blade.php --}}
@extends('auth.auth_layout')
@section('title', 'Lupa Password')
@section('auth-content')
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Sans:opsz,wght@9..40,300;400;500;600;700&display=swap" rel="stylesheet">
<style>
body.auth-page {
background: #F8FDFB !important;
font-family: 'DM Sans', sans-serif !important;
padding: 0 !important;
align-items: stretch !important;
min-height: 100vh !important;
}
.auth-container {
width: 100vw !important; max-width: 100vw !important;
min-height: 100vh !important; background: transparent !important;
padding: 0 !important; border-radius: 0 !important; box-shadow: none !important;
}
.fp-wrap {
position: relative; width: 100%; min-height: 100vh;
display: flex; align-items: center; justify-content: center;
overflow: hidden; font-family: 'DM Sans', sans-serif;
}
.fp-bg {
position: absolute; inset: 0; z-index: 0;
background:
radial-gradient(ellipse 80% 60% at 70% 50%, rgba(111,186,157,.12) 0%, transparent 60%),
radial-gradient(ellipse 50% 80% at 10% 80%, rgba(111,186,157,.08) 0%, transparent 55%),
#F8FDFB;
}
.fp-bg::before {
content: ''; position: absolute; inset: 0;
background-image:
linear-gradient(rgba(111,186,157,.055) 1px, transparent 1px),
linear-gradient(90deg, rgba(111,186,157,.055) 1px, transparent 1px);
background-size: 48px 48px;
}
.fp-ring { position: absolute; border-radius: 50%; border: 1.5px solid rgba(111,186,157,.18); pointer-events: none; }
.fp-ring.r1 { width:420px; height:420px; top:-110px; right:-110px; }
.fp-ring.r2 { width:270px; height:270px; bottom:50px; left:60px; border-color:rgba(111,186,157,.10); }
.fp-dot { position:absolute; border-radius:50%; background:#6FBA9D; pointer-events:none; }
.fp-dot.d1 { width:8px; height:8px; top:22%; right:18%; opacity:.14; }
.fp-dot.d2 { width:11px; height:11px; top:55%; left:15%; opacity:.08; }
.fp-line { position:absolute; height:1px; background:linear-gradient(90deg,transparent,rgba(111,186,157,.14),transparent); pointer-events:none; }
.fp-line.l1 { width:280px; top:28%; left:-60px; transform:rotate(-15deg); }
.fp-layout {
position: relative; z-index: 2;
display: flex; align-items: center;
width: 100%; max-width: 1100px;
padding: 40px 60px; gap: 80px;
animation: fpIn .6s ease both;
}
@keyframes fpIn {
from { opacity:0; transform:translateY(20px); }
to { opacity:1; transform:translateY(0); }
}
.fp-brand { flex: 0 0 340px; order: 1; }
.fp-form-panel { flex: 1; max-width: 430px; order: 2; }
/* Brand */
.fp-logo { width:72px; height:72px; margin-bottom:20px; border-radius:16px; box-shadow:0 4px 20px rgba(111,186,157,.2); object-fit:contain; background:#fff; }
.fp-eyebrow {
display:inline-flex; align-items:center; gap:8px;
font-size:.68rem; font-weight:700; letter-spacing:2px;
text-transform:uppercase; color:#6FBA9D; margin-bottom:18px;
}
.fp-eyebrow::before { content:''; display:inline-block; width:22px; height:2px; background:#6FBA9D; border-radius:2px; }
.fp-title {
font-family:'DM Serif Display',serif;
font-size:3.2rem; line-height:1.05; color:#0F2118; margin-bottom:6px;
}
.fp-title em { font-style:italic; color:#5EA98C; }
.fp-sub { font-size:.9rem; font-weight:500; color:#8AADA0; margin-bottom:32px; line-height:1.6; }
.fp-divider { width:44px; height:3px; background:linear-gradient(90deg,#6FBA9D,#A8D8C6); border-radius:3px; margin-bottom:24px; }
.fp-desc { font-size:.81rem; color:#8AADA0; line-height:1.8; max-width:290px; margin-bottom:32px; }
/* Steps */
.fp-steps { display:flex; flex-direction:column; gap:14px; }
.fp-step { display:flex; align-items:flex-start; gap:12px; }
.fp-step-num {
width:28px; height:28px; border-radius:50%;
background:linear-gradient(135deg,#6FBA9D,#5EA98C);
color:#fff; font-size:.72rem; font-weight:800;
display:flex; align-items:center; justify-content:center; flex-shrink:0;
}
.fp-step-num.active { box-shadow:0 0 0 4px rgba(111,186,157,.2); }
.fp-step-text { font-size:.78rem; color:#2A4235; font-weight:500; line-height:1.5; padding-top:3px; }
.fp-step-text small { display:block; color:#8AADA0; font-weight:400; font-size:.72rem; }
/* Card */
.fp-card {
background: #fff; border-radius: 24px;
padding: 42px 38px;
box-shadow:
0 0 0 1px rgba(111,186,157,.1),
0 4px 6px rgba(15,33,24,.03),
0 20px 44px rgba(15,33,24,.08);
position: relative; overflow: hidden;
}
.fp-card::before {
content: ''; position:absolute; top:0; left:0; right:0; height:3px;
background: linear-gradient(90deg, #e57373, #ef9a9a, #e57373);
}
.fp-card::after {
content: ''; position:absolute; bottom:-50px; right:-50px;
width:140px; height:140px; border-radius:50%;
background: radial-gradient(circle, rgba(229,115,115,.06) 0%, transparent 70%);
}
.fp-card-icon {
width:54px; height:54px; border-radius:14px;
background:linear-gradient(135deg,#FFEBEE,#FFCDD2);
display:flex; align-items:center; justify-content:center;
font-size:1.3rem; color:#e53935; margin-bottom:16px;
}
.fp-card-lbl {
font-size:.67rem; font-weight:700; letter-spacing:2px;
text-transform:uppercase; color:#e57373; margin-bottom:7px;
}
.fp-card-title {
font-family:'DM Serif Display',serif;
font-size:1.85rem; color:#0F2118; line-height:1.1; margin-bottom:5px;
}
.fp-card-desc { font-size:.79rem; color:#8AADA0; line-height:1.6; margin-bottom:26px; }
/* Alert */
.fp-alert-danger {
border-radius:10px; padding:10px 13px; font-size:.79rem;
margin-bottom:18px; background:#FFF3F3; color:#c62828; border-left:3px solid #e53935;
display:flex; align-items:center; gap:7px;
}
.fp-alert-success {
border-radius:10px; padding:10px 13px; font-size:.79rem;
margin-bottom:18px; background:#F0FFF4; color:#2E7D32; border-left:3px solid #43A047;
display:flex; align-items:center; gap:7px;
}
/* Field */
.fp-field { margin-bottom:18px; }
.fp-lbl { display:block; font-size:.7rem; font-weight:700; letter-spacing:.8px; text-transform:uppercase; color:#2A4235; margin-bottom:7px; }
.fp-shell { position:relative; display:flex; align-items:center; }
.fp-shell .fi { position:absolute; left:15px; color:#A8D8C6; font-size:.8rem; pointer-events:none; transition:color .2s; }
.fp-shell input {
width:100%; padding:12px 15px 12px 40px;
background:#EBF7F2; border:1.5px solid transparent;
border-radius:11px; font-family:inherit; font-size:.87rem; color:#0F2118; outline:none;
transition:all .2s;
}
.fp-shell input::placeholder { color:#8AADA0; font-size:.83rem; }
.fp-shell input:focus { background:#fff; border-color:#e57373; box-shadow:0 0 0 4px rgba(229,115,115,.12); }
.fp-shell .fi.active { color:#e57373; }
/* Button */
.fp-btn {
width:100%; padding:13px;
background:linear-gradient(135deg, #e57373, #ef5350);
color:#fff; border:none; border-radius:12px;
font-family:inherit; font-size:.89rem; font-weight:700;
cursor:pointer; letter-spacing:.3px;
display:flex; align-items:center; justify-content:center; gap:8px;
box-shadow:0 4px 18px rgba(229,115,115,.35);
transition:all .25s;
}
.fp-btn:hover { transform:translateY(-2px); box-shadow:0 8px 26px rgba(229,115,115,.45); }
.fp-btn:active { transform:none; }
.fp-back {
display:flex; align-items:center; justify-content:center; gap:6px;
margin-top:20px; font-size:.78rem; color:#5EA98C; font-weight:600; text-decoration:none;
transition:color .2s;
}
.fp-back:hover { color:#3D8A6E; text-decoration:underline; }
/* Responsive */
@media (max-width: 900px) {
.fp-layout { gap:48px; padding:32px 36px; }
.fp-brand { flex:0 0 260px; }
.fp-title { font-size:2.7rem; }
}
@media (max-width: 720px) {
body.auth-page { align-items:flex-start !important; overflow-y:auto !important; }
.fp-wrap { align-items:flex-start; min-height:auto; padding:24px 0 40px; }
.fp-layout { flex-direction:column; padding:0 20px; gap:28px; }
.fp-form-panel { order:2; max-width:100%; }
.fp-brand { order:1; flex:none; text-align:center; }
.fp-title { font-size:2.2rem; }
.fp-steps, .fp-desc, .fp-divider { display:none; }
.fp-sub { margin-bottom:0; }
.fp-card { padding:28px 20px; }
.fp-logo { width:56px; height:56px; margin:0 auto 14px; display:block; }
}
@media (max-width: 420px) {
.fp-title { font-size:1.85rem; }
.fp-card { padding:24px 16px; border-radius:16px; }
.fp-card-title { font-size:1.5rem; }
}
@media (min-width: 1280px) {
.fp-layout { max-width:1160px; padding:40px 80px; }
.fp-brand { flex:0 0 360px; }
.fp-title { font-size:3.6rem; }
}
</style>
<div class="fp-wrap">
<div class="fp-bg"></div>
<div class="fp-ring r1"></div>
<div class="fp-ring r2"></div>
<div class="fp-dot d1"></div>
<div class="fp-dot d2"></div>
<div class="fp-line l1"></div>
<div class="fp-layout">
<!-- ═══ Brand (kiri) ═══ -->
<div class="fp-brand">
<img src="{{ asset('images/logo.png') }}" alt="Logo PKPPS" class="fp-logo">
<div class="fp-eyebrow">Reset Akses</div>
<h1 class="fp-title">Lupa<br><em>Password?</em></h1>
<p class="fp-sub">Jangan khawatir, kami bantu pulihkan.</p>
<div class="fp-divider"></div>
<p class="fp-desc">Ikuti langkah berikut untuk mengatur ulang password akun Super Admin Anda.</p>
<div class="fp-steps">
<div class="fp-step">
<div class="fp-step-num active">1</div>
<div class="fp-step-text">Masukkan email terdaftar <small>Kami kirim kode OTP 6 digit</small></div>
</div>
<div class="fp-step">
<div class="fp-step-num">2</div>
<div class="fp-step-text">Verifikasi kode OTP <small>Cek email masuk / spam</small></div>
</div>
<div class="fp-step">
<div class="fp-step-num">3</div>
<div class="fp-step-text">Buat password baru <small>Minimal 8 karakter</small></div>
</div>
</div>
</div>
<!-- ═══ Form (kanan) ═══ -->
<div class="fp-form-panel">
<div class="fp-card">
<div class="fp-card-icon">
<i class="fas fa-envelope-open-text"></i>
</div>
<div class="fp-card-lbl">Langkah 1 dari 3</div>
<div class="fp-card-title">Masukkan Email</div>
<div class="fp-card-desc">Masukkan email Super Admin yang terdaftar di sistem. Kami akan mengirim kode OTP ke email tersebut.</div>
@if ($errors->any())
<div class="fp-alert-danger">
<i class="fas fa-exclamation-circle"></i>
{{ $errors->first() }}
</div>
@endif
@if(session('success'))
<div class="fp-alert-success" id="fpSuccessAlert">
<i class="fas fa-check-circle"></i>
{{ session('success') }}
</div>
@endif
<form method="POST" action="{{ route('admin.forgot.send_otp') }}">
@csrf
<div class="fp-field">
<label class="fp-lbl">Email Super Admin</label>
<div class="fp-shell">
<i class="fas fa-envelope fi" id="ico-email"></i>
<input type="email" id="email" name="email"
value="{{ old('email') }}"
placeholder="contoh@email.com"
required autofocus
onfocus="document.getElementById('ico-email').classList.add('active')"
onblur="document.getElementById('ico-email').classList.remove('active')">
</div>
</div>
<button type="submit" class="fp-btn">
<i class="fas fa-paper-plane"></i>
Kirim Kode OTP
</button>
</form>
<a href="{{ route('admin.login') }}" class="fp-back">
<i class="fas fa-arrow-left"></i> Kembali ke Login
</a>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const sa = document.getElementById('fpSuccessAlert');
if (sa) {
setTimeout(() => {
sa.style.transition = 'opacity .5s ease';
sa.style.opacity = '0';
setTimeout(() => sa.remove(), 500);
}, 5000);
}
});
</script>
@endsection

View File

@ -4,158 +4,386 @@
@section('title', 'Login Admin')
@section('auth-content')
<div class="auth-header">
<h2><i class="fas fa-lock"></i> Admin Login</h2>
<p>Sistem Informasi Monitoring Santri</p>
</div>
{{-- Alert Error --}}
@if ($errors->any())
<div class="alert alert-danger">
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Sans:opsz,wght@9..40,300;400;500;600;700&display=swap" rel="stylesheet">
<style>
body.auth-page {
background: #F8FDFB !important;
font-family: 'DM Sans', sans-serif !important;
padding: 0 !important;
align-items: stretch !important;
min-height: 100vh !important;
}
.auth-container {
width: 100vw !important; max-width: 100vw !important;
min-height: 100vh !important; background: transparent !important;
padding: 0 !important; border-radius: 0 !important; box-shadow: none !important;
}
/* ── Wrapper & Background ── */
.lg-wrap {
position: relative; width: 100%; min-height: 100vh;
display: flex; align-items: center; justify-content: center;
overflow: hidden; font-family: 'DM Sans', sans-serif;
}
.lg-bg {
position: absolute; inset: 0; z-index: 0;
background:
radial-gradient(ellipse 80% 60% at 70% 50%, rgba(111,186,157,.12) 0%, transparent 60%),
radial-gradient(ellipse 50% 80% at 10% 80%, rgba(111,186,157,.08) 0%, transparent 55%),
#F8FDFB;
}
.lg-bg::before {
content: ''; position: absolute; inset: 0;
background-image:
linear-gradient(rgba(111,186,157,.055) 1px, transparent 1px),
linear-gradient(90deg, rgba(111,186,157,.055) 1px, transparent 1px);
background-size: 48px 48px;
}
/* Decorations */
.lg-ring { position: absolute; border-radius: 50%; border: 1.5px solid rgba(111,186,157,.18); pointer-events: none; }
.lg-ring.r1 { width:420px; height:420px; top:-110px; right:-110px; }
.lg-ring.r2 { width:270px; height:270px; bottom:50px; right:60px; border-color:rgba(111,186,157,.10); }
.lg-ring.r3 { width:200px; height:200px; bottom:-50px; left:60px; border-color:rgba(111,186,157,.14); }
.lg-dot { position:absolute; border-radius:50%; background:#6FBA9D; pointer-events:none; }
.lg-dot.d1 { width:8px; height:8px; top:22%; right:18%; opacity:.14; }
.lg-dot.d2 { width:5px; height:5px; bottom:40%; right:30%; opacity:.09; }
.lg-dot.d3 { width:11px; height:11px; top:55%; left:15%; opacity:.08; }
.lg-line { position:absolute; height:1px; background:linear-gradient(90deg,transparent,rgba(111,186,157,.14),transparent); pointer-events:none; }
.lg-line.l1 { width:280px; top:28%; left:-60px; transform:rotate(-15deg); }
.lg-line.l2 { width:220px; bottom:30%; right:-40px; transform:rotate(18deg); }
/* Layout */
.lg-layout {
position: relative; z-index: 2;
display: flex; align-items: center;
width: 100%; max-width: 1100px;
padding: 40px 60px; gap: 80px;
animation: lgIn .6s ease both;
}
@keyframes lgIn {
from { opacity:0; transform:translateY(20px); }
to { opacity:1; transform:translateY(0); }
}
/* Brand left, Form right */
.lg-brand { flex: 0 0 340px; order: 1; }
.lg-form-panel { flex: 1; max-width: 430px; order: 2; }
/* ── Brand section ── */
.lg-logo { width:72px; height:72px; margin-bottom:20px; border-radius:16px; box-shadow:0 4px 20px rgba(111,186,157,.2); object-fit:contain; background:#fff; }
.lg-eyebrow {
display:inline-flex; align-items:center; gap:8px;
font-size:.68rem; font-weight:700; letter-spacing:2px;
text-transform:uppercase; color:#6FBA9D; margin-bottom:18px;
}
.lg-eyebrow::before {
content:''; display:inline-block; width:22px; height:2px;
background:#6FBA9D; border-radius:2px;
}
.lg-title {
font-family:'DM Serif Display',serif;
font-size:3.2rem; line-height:1.05; color:#0F2118; margin-bottom:6px;
}
.lg-title em { font-style:italic; color:#5EA98C; }
.lg-sub { font-size:.9rem; font-weight:500; color:#8AADA0; margin-bottom:32px; line-height:1.6; }
.lg-divider { width:44px; height:3px; background:linear-gradient(90deg,#6FBA9D,#A8D8C6); border-radius:3px; margin-bottom:24px; }
.lg-desc { font-size:.81rem; color:#8AADA0; line-height:1.8; max-width:290px; margin-bottom:32px; }
.lg-features { display:flex; flex-direction:column; gap:11px; }
.lg-feat { display:flex; align-items:center; gap:11px; font-size:.79rem; color:#2A4235; font-weight:500; }
.lg-feat-ico {
width:30px; height:30px; border-radius:8px; background:#EBF7F2;
display:flex; align-items:center; justify-content:center;
color:#3D8A6E; font-size:.73rem; flex-shrink:0;
}
/* ── Card ── */
.lg-card {
background: #fff; border-radius: 24px;
padding: 42px 38px;
box-shadow:
0 0 0 1px rgba(111,186,157,.1),
0 4px 6px rgba(15,33,24,.03),
0 20px 44px rgba(15,33,24,.08);
position: relative; overflow: hidden;
}
.lg-card::before {
content: ''; position:absolute; top:0; left:0; right:0; height:3px;
background: linear-gradient(90deg, #6FBA9D, #A8D8C6, #6FBA9D);
}
.lg-card::after {
content: ''; position:absolute; bottom:-50px; right:-50px;
width:140px; height:140px; border-radius:50%;
background: radial-gradient(circle, rgba(111,186,157,.06) 0%, transparent 70%);
}
.lg-card-lbl {
font-size:.67rem; font-weight:700; letter-spacing:2px;
text-transform:uppercase; color:#6FBA9D; margin-bottom:7px;
}
.lg-card-title {
font-family:'DM Serif Display',serif;
font-size:1.85rem; color:#0F2118; line-height:1.1; margin-bottom:5px;
}
.lg-card-desc { font-size:.79rem; color:#8AADA0; line-height:1.6; margin-bottom:26px; }
/* Alert */
.lg-alert-danger {
border-radius:10px; padding:10px 13px; font-size:.79rem;
margin-bottom:18px; background:#FFF3F3; color:#c62828; border-left:3px solid #e53935;
display:flex; align-items:center; gap:7px;
}
.lg-alert-success {
border-radius:10px; padding:10px 13px; font-size:.79rem;
margin-bottom:18px; background:#F0FFF4; color:#2E7D32; border-left:3px solid #43A047;
display:flex; align-items:center; gap:7px;
}
/* Fields */
.lg-field { margin-bottom:15px; }
.lg-lbl {
display:block; font-size:.7rem; font-weight:700;
letter-spacing:.8px; text-transform:uppercase; color:#2A4235; margin-bottom:7px;
}
.lg-shell { position:relative; display:flex; align-items:center; }
.lg-shell .fi { position:absolute; left:15px; color:#A8D8C6; font-size:.8rem; pointer-events:none; transition:color .2s; }
.lg-shell input {
width:100%; padding:12px 15px 12px 40px;
background:#EBF7F2; border:1.5px solid transparent;
border-radius:11px; font-family:inherit; font-size:.87rem; color:#0F2118; outline:none;
transition:all .2s;
}
.lg-shell input::placeholder { color:#8AADA0; font-size:.83rem; }
.lg-shell input:focus {
background:#fff; border-color:#6FBA9D;
box-shadow:0 0 0 4px rgba(111,186,157,.12);
}
.lg-shell .fi.active { color:#6FBA9D; }
.lg-show {
position:absolute; right:13px;
background:none; border:none; font-size:.68rem; font-weight:800;
letter-spacing:.8px; color:#5EA98C; cursor:pointer; font-family:inherit;
}
.lg-show:hover { color:#3D8A6E; }
/* Remember + Forgot row */
.lg-options {
display:flex; align-items:center; justify-content:space-between;
margin-bottom:18px; font-size:.78rem;
}
.lg-remember { display:flex; align-items:center; gap:7px; color:#2A4235; font-weight:500; cursor:pointer; }
.lg-remember input[type="checkbox"] {
width:16px; height:16px; accent-color:#6FBA9D; cursor:pointer;
}
.lg-forgot { color:#e57373; font-weight:600; text-decoration:none; transition:color .2s; }
.lg-forgot:hover { color:#c62828; text-decoration:underline; }
/* Buttons */
.lg-btn {
width:100%; padding:13px;
background:linear-gradient(135deg, #6FBA9D, #5EA98C);
color:#fff; border:none; border-radius:12px;
font-family:inherit; font-size:.89rem; font-weight:700;
cursor:pointer; letter-spacing:.3px; margin-top:6px;
display:flex; align-items:center; justify-content:center; gap:8px;
box-shadow:0 4px 18px rgba(94,169,140,.35);
transition:all .25s;
}
.lg-btn:hover { transform:translateY(-2px); box-shadow:0 8px 26px rgba(94,169,140,.45); }
.lg-btn:active { transform:none; }
.lg-foot { text-align:center; font-size:.77rem; color:#8AADA0; margin-top:20px; }
.lg-foot a { color:#5EA98C; font-weight:700; text-decoration:none; }
.lg-foot a:hover { text-decoration:underline; }
/* Santri separator */
.lg-sep {
display:flex; align-items:center; gap:12px; margin-top:22px;
font-size:.7rem; color:#B8D4C8; letter-spacing:1px; text-transform:uppercase; font-weight:600;
}
.lg-sep::before, .lg-sep::after {
content:''; flex:1; height:1px; background:linear-gradient(90deg,transparent,#D6EDE5,transparent);
}
.lg-santri-link {
display:flex; align-items:center; justify-content:center; gap:8px;
margin-top:12px; padding:10px;
background:#EBF7F2; border:1.5px solid transparent; border-radius:10px;
font-size:.8rem; font-weight:600; color:#3D8A6E; text-decoration:none;
transition:all .2s;
}
.lg-santri-link:hover {
border-color:#6FBA9D; background:#fff; box-shadow:0 0 0 3px rgba(111,186,157,.1);
}
/* Responsive */
@media (max-width: 900px) {
.lg-layout { gap:48px; padding:32px 36px; }
.lg-brand { flex:0 0 260px; }
.lg-title { font-size:2.7rem; }
}
@media (max-width: 720px) {
body.auth-page { align-items:flex-start !important; overflow-y:auto !important; }
.lg-wrap { align-items:flex-start; min-height:auto; padding:24px 0 40px; }
.lg-layout { flex-direction:column; padding:0 20px; gap:28px; }
.lg-form-panel { order:2; max-width:100%; }
.lg-brand { order:1; flex:none; text-align:center; }
.lg-title { font-size:2.2rem; }
.lg-features, .lg-desc, .lg-divider { display:none; }
.lg-sub { margin-bottom:0; }
.lg-card { padding:28px 20px; }
.lg-ring.r1 { width:260px; height:260px; top:-70px; right:-70px; }
.lg-ring.r2 { display:none; }
.lg-logo { width:56px; height:56px; margin:0 auto 14px; display:block; }
}
@media (max-width: 420px) {
.lg-title { font-size:1.85rem; }
.lg-card { padding:24px 16px; border-radius:16px; }
.lg-card-title { font-size:1.5rem; }
.lg-options { flex-direction:column; align-items:flex-start; gap:10px; }
}
@media (min-width: 1280px) {
.lg-layout { max-width:1160px; padding:40px 80px; }
.lg-brand { flex:0 0 360px; }
.lg-title { font-size:3.6rem; }
}
</style>
<div class="lg-wrap">
<div class="lg-bg"></div>
<div class="lg-ring r1"></div>
<div class="lg-ring r2"></div>
<div class="lg-ring r3"></div>
<div class="lg-dot d1"></div>
<div class="lg-dot d2"></div>
<div class="lg-dot d3"></div>
<div class="lg-line l1"></div>
<div class="lg-line l2"></div>
<div class="lg-layout">
<!-- ═══ Brand (kiri) ═══ -->
<div class="lg-brand">
<img src="{{ asset('images/logo.png') }}" alt="Logo PKPPS" class="lg-logo">
<div class="lg-eyebrow">Selamat Datang</div>
<h1 class="lg-title">Masuk ke<br>Panel<br><em>Admin.</em></h1>
<p class="lg-sub">PKPPS Riyadlul Jannah</p>
<div class="lg-divider"></div>
<p class="lg-desc">Kelola data santri, absensi, keuangan, dan seluruh aktivitas pesantren dalam satu sistem terpadu.</p>
<div class="lg-features">
<div class="lg-feat">
<div class="lg-feat-ico"><i class="fas fa-chart-line"></i></div>
<span>Dashboard monitoring real-time</span>
</div>
<div class="lg-feat">
<div class="lg-feat-ico"><i class="fas fa-shield-alt"></i></div>
<span>Akses aman berbasis role</span>
</div>
<div class="lg-feat">
<div class="lg-feat-ico"><i class="fas fa-mobile-alt"></i></div>
<span>Terintegrasi aplikasi mobile wali</span>
</div>
</div>
</div>
<!-- ═══ Form (kanan) ═══ -->
<div class="lg-form-panel">
<div class="lg-card">
<div class="lg-card-lbl">Login Admin</div>
<div class="lg-card-title">Masuk Akun</div>
<div class="lg-card-desc">Masukkan username dan password untuk mengakses panel admin.</div>
@if ($errors->any())
<div class="lg-alert-danger">
<i class="fas fa-exclamation-circle"></i>
{{ $errors->first() }}
</div>
@endif
@endif
{{-- Alert Success (dari logout) --}}
@if(session('success'))
<div class="alert alert-success">
@if(session('success'))
<div class="lg-alert-success" id="lgSuccessAlert">
<i class="fas fa-check-circle"></i>
{{ session('success') }}
</div>
@endif
@endif
<form method="POST"
action="{{ route('admin.login') }}"
id="adminLoginForm"
class="data-form"
autocomplete="on">
<form method="POST" action="{{ route('admin.login') }}" id="adminLoginForm">
@csrf
{{-- Username Field --}}
<div class="form-group">
<label for="username">
<i class="fas fa-user form-icon"></i>
Username
</label>
<input type="text"
id="username"
name="username"
<div class="lg-field">
<label class="lg-lbl">Username</label>
<div class="lg-shell">
<i class="fas fa-user fi" id="ico-u"></i>
<input type="text" id="username" name="username"
value="{{ old('username') }}"
class="form-control @error('username') is-invalid @enderror"
autocomplete="username"
placeholder="Masukkan username admin"
required
autofocus>
@error('username')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
autocomplete="username" required autofocus
onfocus="document.getElementById('ico-u').classList.add('active')"
onblur="document.getElementById('ico-u').classList.remove('active')">
</div>
</div>
{{-- Password Field --}}
<div class="form-group">
<label for="password">
<i class="fas fa-lock form-icon"></i>
Password
</label>
<div style="position: relative;">
<input type="password"
id="password"
name="password"
class="form-control @error('password') is-invalid @enderror"
autocomplete="current-password"
<div class="lg-field">
<label class="lg-lbl">Password</label>
<div class="lg-shell">
<i class="fas fa-lock fi" id="ico-p"></i>
<input type="password" id="password" name="password"
placeholder="Masukkan password"
style="padding-right: 40px;"
required>
<button type="button"
id="togglePassword"
style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); background: none; border: none; cursor: pointer; color: #666; padding: 0; width: 30px; height: 30px; display: flex; align-items: center; justify-content: center;">
<i class="fas fa-eye" id="eyeIcon"></i>
</button>
autocomplete="current-password" required
onfocus="document.getElementById('ico-p').classList.add('active')"
onblur="document.getElementById('ico-p').classList.remove('active')">
<button type="button" class="lg-show" id="lgTglBtn">SHOW</button>
</div>
@error('password')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
{{-- Remember Me Checkbox --}}
<div class="form-group" style="display:flex; align-items: center;">
<input type="checkbox"
name="remember"
id="remember"
style="width: auto; margin-right: 10px;">
<label for="remember" style="font-weight: normal; margin-bottom: 0;">Ingat Saya</label>
</div>
{{-- Submit Button --}}
<div class="form-group">
<button type="submit" class="btn btn-primary btn-full">
<i class="fas fa-sign-in-alt"></i> Login
</button>
</div>
{{-- Link ke Register --}}
<p style="text-align: center; font-size: 0.9rem; margin-top: 15px;">
Admin baru? <a href="{{ route('admin.register') }}" class="link-primary">Daftar Sekarang</a>
</p>
{{-- Link ke Login Santri --}}
<div style="text-align: center; margin-top: 20px; padding-top: 20px; border-top: 2px solid var(--primary-light);">
<p style="color: var(--text-light); margin-bottom: 10px;">
<i class="fas fa-info-circle"></i> Login sebagai santri/wali?
</p>
<a href="{{ route('santri.login') }}" class="link-primary" style="font-size: 0.95rem;">
<i class="fas fa-user-graduate"></i> Login Santri/Wali
<div class="lg-options">
<label class="lg-remember">
<input type="checkbox" name="remember" id="remember"> Ingat Saya
</label>
<a href="{{ route('admin.forgot.email_form') }}" class="lg-forgot">
<i class="fas fa-key"></i> Lupa Password?
</a>
</div>
</form>
{{-- JavaScript --}}
<button type="submit" class="lg-btn">
<i class="fas fa-sign-in-alt"></i>
Login
</button>
<div class="lg-foot">
Admin baru? <a href="{{ route('admin.register') }}">Daftar Sekarang</a>
</div>
<div class="lg-sep">atau</div>
<a href="{{ route('santri.login') }}" class="lg-santri-link">
<i class="fas fa-user-graduate"></i> Login sebagai Santri / Wali
</a>
</form>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// ========================================
// 1. Toggle Password Visibility
// ========================================
const togglePassword = document.getElementById('togglePassword');
const password = document.getElementById('password');
const eyeIcon = document.getElementById('eyeIcon');
if (togglePassword && password && eyeIcon) {
togglePassword.addEventListener('click', function() {
// Toggle tipe input
const type = password.getAttribute('type') === 'password' ? 'text' : 'password';
password.setAttribute('type', type);
// Toggle icon
if (type === 'password') {
eyeIcon.classList.remove('fa-eye-slash');
eyeIcon.classList.add('fa-eye');
} else {
eyeIcon.classList.remove('fa-eye');
eyeIcon.classList.add('fa-eye-slash');
}
// Toggle password
const btn = document.getElementById('lgTglBtn');
const pw = document.getElementById('password');
if (btn && pw) {
btn.addEventListener('click', () => {
const isP = pw.type === 'password';
pw.type = isP ? 'text' : 'password';
btn.textContent = isP ? 'HIDE' : 'SHOW';
});
}
// ========================================
// 2. Auto-refresh CSRF Token (FIX 419)
// ========================================
// CSRF check
const form = document.getElementById('adminLoginForm');
if (form) {
form.addEventListener('submit', function(e) {
const csrfInput = document.querySelector('input[name="_token"]');
if (!csrfInput) {
e.preventDefault();
alert('CSRF token tidak ditemukan. Halaman akan dimuat ulang.');
window.location.reload();
return false;
}
const token = csrfInput.value;
// Cek apakah token valid (minimal 40 karakter)
if (!token || token.length < 40) {
const csrf = document.querySelector('input[name="_token"]');
if (!csrf || !csrf.value || csrf.value.length < 40) {
e.preventDefault();
alert('Session expired. Halaman akan dimuat ulang.');
window.location.reload();
@ -164,52 +392,32 @@ class="form-control @error('password') is-invalid @enderror"
});
}
// ========================================
// 3. Clear Error Message on Input
// ========================================
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
const alertBox = document.querySelector('.alert-danger');
if (usernameInput && passwordInput && alertBox) {
usernameInput.addEventListener('input', function() {
alertBox.style.display = 'none';
});
passwordInput.addEventListener('input', function() {
alertBox.style.display = 'none';
// Clear error on input
const alertBox = document.querySelector('.lg-alert-danger');
if (alertBox) {
['username','password'].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('input', () => alertBox.style.display = 'none');
});
}
// ========================================
// 4. Auto-hide Success Alert (setelah 5 detik)
// ========================================
const successAlert = document.querySelector('.alert-success');
if (successAlert) {
setTimeout(function() {
successAlert.style.transition = 'opacity 0.5s ease';
successAlert.style.opacity = '0';
setTimeout(function() {
successAlert.remove();
}, 500);
}, 5000); // 5 detik
// Auto-hide success
const sa = document.getElementById('lgSuccessAlert');
if (sa) {
setTimeout(() => {
sa.style.transition = 'opacity .5s ease';
sa.style.opacity = '0';
setTimeout(() => sa.remove(), 500);
}, 5000);
}
// ========================================
// 5. Focus Management
// ========================================
// Auto-focus ke username saat halaman load
if (usernameInput && !usernameInput.value) {
usernameInput.focus();
}
// Enter di username -> pindah ke password
if (usernameInput && passwordInput) {
usernameInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
passwordInput.focus();
}
// Focus management
const u = document.getElementById('username');
const p = document.getElementById('password');
if (u && !u.value) u.focus();
if (u && p) {
u.addEventListener('keypress', e => {
if (e.key === 'Enter') { e.preventDefault(); p.focus(); }
});
}
});

View File

@ -1,53 +1,361 @@
{{-- resources/views/admin/auth/register.blade.php --}}
@extends('auth.auth_layout')
@section('title', 'Register Admin')
@section('auth-content')
<div class="auth-header">
<div class="logo-circle">
<i class="fas fa-lock-open fa-2x"></i>
</div>
<h2>Pendaftaran Akun Admin</h2>
<p>Mohon gunakan email dan password yang kuat untuk keamanan sistem.</p>
</div>
{{-- Tampilkan error dari validator --}}
@if ($errors->any())
<div class="alert alert-danger">
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Sans:opsz,wght@9..40,300;400;500;600;700&display=swap" rel="stylesheet">
<style>
body.auth-page {
background: #F8FDFB !important;
font-family: 'DM Sans', sans-serif !important;
padding: 0 !important;
align-items: stretch !important;
min-height: 100vh !important;
}
.auth-container {
width: 100vw !important; max-width: 100vw !important;
min-height: 100vh !important; background: transparent !important;
padding: 0 !important; border-radius: 0 !important; box-shadow: none !important;
}
.rv2-wrap {
position: relative; width: 100%; min-height: 100vh;
display: flex; align-items: center; justify-content: center;
overflow: hidden; font-family: 'DM Sans', sans-serif;
}
.rv2-bg {
position: absolute; inset: 0; z-index: 0;
background:
radial-gradient(ellipse 80% 60% at 30% 50%, rgba(111,186,157,.12) 0%, transparent 60%),
radial-gradient(ellipse 50% 80% at 90% 20%, rgba(111,186,157,.08) 0%, transparent 55%),
#F8FDFB;
}
.rv2-bg::before {
content: ''; position: absolute; inset: 0;
background-image:
linear-gradient(rgba(111,186,157,.055) 1px, transparent 1px),
linear-gradient(90deg, rgba(111,186,157,.055) 1px, transparent 1px);
background-size: 48px 48px;
}
.rv2-ring {
position: absolute; border-radius: 50%;
border: 1.5px solid rgba(111,186,157,.18); pointer-events: none;
}
.rv2-ring.r1 { width:420px; height:420px; top:-110px; left:-110px; }
.rv2-ring.r2 { width:270px; height:270px; top:50px; left:60px; border-color:rgba(111,186,157,.10); }
.rv2-ring.r3 { width:200px; height:200px; bottom:-50px; right:60px; border-color:rgba(111,186,157,.14); }
.rv2-dot { position:absolute; border-radius:50%; background:#6FBA9D; pointer-events:none; }
.rv2-dot.d1 { width:8px; height:8px; top:22%; left:18%; opacity:.14; }
.rv2-dot.d2 { width:5px; height:5px; bottom:40%; left:30%; opacity:.09; }
.rv2-dot.d3 { width:11px; height:11px; top:55%; right:15%; opacity:.08; }
.rv2-line { position:absolute; height:1px; background:linear-gradient(90deg,transparent,rgba(111,186,157,.14),transparent); pointer-events:none; }
.rv2-line.l1 { width:280px; top:28%; right:-60px; transform:rotate(15deg); }
.rv2-line.l2 { width:220px; bottom:30%; left:-40px; transform:rotate(-18deg); }
.rv2-layout {
position: relative; z-index: 2;
display: flex; align-items: center;
width: 100%; max-width: 1100px;
padding: 40px 60px; gap: 80px;
animation: rv2In .6s ease both;
}
@keyframes rv2In {
from { opacity:0; transform:translateY(20px); }
to { opacity:1; transform:translateY(0); }
}
/* Form kiri dulu di register */
.rv2-form-panel { flex: 1; max-width: 430px; order: 1; }
.rv2-brand { flex: 0 0 320px; order: 2; }
/* CARD */
.rv2-card {
background: #fff; border-radius: 24px;
padding: 42px 38px;
box-shadow:
0 0 0 1px rgba(111,186,157,.1),
0 4px 6px rgba(15,33,24,.03),
0 20px 44px rgba(15,33,24,.08);
position: relative; overflow: hidden;
}
.rv2-card::before {
content: ''; position:absolute; top:0; left:0; right:0; height:3px;
background: linear-gradient(90deg, #6FBA9D, #A8D8C6, #6FBA9D);
}
.rv2-card::after {
content: ''; position:absolute; bottom:-50px; left:-50px;
width:140px; height:140px; border-radius:50%;
background: radial-gradient(circle, rgba(111,186,157,.06) 0%, transparent 70%);
}
.rv2-card-lbl {
font-size:.67rem; font-weight:700; letter-spacing:2px;
text-transform:uppercase; color:#6FBA9D; margin-bottom:7px;
}
.rv2-card-title {
font-family:'DM Serif Display',serif;
font-size:1.85rem; color:#0F2118; line-height:1.1; margin-bottom:5px;
}
.rv2-card-desc { font-size:.79rem; color:#8AADA0; line-height:1.6; margin-bottom:26px; }
/* Alert */
.rv2-alert {
border-radius:10px; padding:10px 13px; font-size:.79rem;
margin-bottom:18px; background:#FFF3F3; color:#c62828; border-left:3px solid #e53935;
}
.rv2-alert p { display:flex; align-items:center; gap:7px; margin:2px 0; }
/* Fields */
.rv2-field { margin-bottom:15px; }
.rv2-lbl {
display:block; font-size:.7rem; font-weight:700;
letter-spacing:.8px; text-transform:uppercase; color:#2A4235; margin-bottom:7px;
}
.rv2-shell { position:relative; display:flex; align-items:center; }
.rv2-shell .fi { position:absolute; left:15px; color:#A8D8C6; font-size:.8rem; pointer-events:none; transition:color .2s; }
.rv2-shell input {
width:100%; padding:12px 15px 12px 40px;
background:#EBF7F2; border:1.5px solid transparent;
border-radius:11px; font-family:inherit; font-size:.87rem; color:#0F2118; outline:none;
transition:all .2s;
}
.rv2-shell input::placeholder { color:#8AADA0; font-size:.83rem; }
.rv2-shell input:focus {
background:#fff; border-color:#6FBA9D;
box-shadow:0 0 0 4px rgba(111,186,157,.12);
}
.rv2-show {
position:absolute; right:13px;
background:none; border:none; font-size:.68rem; font-weight:800;
letter-spacing:.8px; color:#5EA98C; cursor:pointer; font-family:inherit;
}
.rv2-show:hover { color:#3D8A6E; }
.rv2-ferr { font-size:.72rem; color:#e53935; margin-top:4px; padding-left:3px; }
/* Strength */
.rv2-strength { display:flex; gap:4px; margin-top:7px; }
.rv2-bar { height:3px; flex:1; border-radius:3px; background:#D6EDE5; transition:background .3s; }
.rv2-bar.w { background:#e53935; }
.rv2-bar.m { background:#FB8C00; }
.rv2-bar.s { background:#6FBA9D; }
/* Buttons */
.rv2-btn {
width:100%; padding:13px;
background:linear-gradient(135deg, #6FBA9D, #5EA98C);
color:#fff; border:none; border-radius:12px;
font-family:inherit; font-size:.89rem; font-weight:700;
cursor:pointer; letter-spacing:.3px; margin-top:6px;
display:flex; align-items:center; justify-content:center; gap:8px;
box-shadow:0 4px 18px rgba(94,169,140,.35);
transition:all .25s;
}
.rv2-btn:hover { transform:translateY(-2px); box-shadow:0 8px 26px rgba(94,169,140,.45); }
.rv2-btn:active { transform:none; }
.rv2-foot { text-align:center; font-size:.77rem; color:#8AADA0; margin-top:20px; }
.rv2-foot a { color:#5EA98C; font-weight:700; text-decoration:none; }
.rv2-foot a:hover { text-decoration:underline; }
/* Brand */
.rv2-logo { width:72px; height:72px; margin-bottom:20px; border-radius:16px; box-shadow:0 4px 20px rgba(111,186,157,.2); object-fit:contain; background:#fff; }
.rv2-eyebrow {
display:inline-flex; align-items:center; gap:8px;
font-size:.68rem; font-weight:700; letter-spacing:2px;
text-transform:uppercase; color:#6FBA9D; margin-bottom:18px;
}
.rv2-eyebrow::before {
content:''; display:inline-block; width:22px; height:2px;
background:#6FBA9D; border-radius:2px;
}
.rv2-title {
font-family:'DM Serif Display',serif;
font-size:3.2rem; line-height:1.05; color:#0F2118; margin-bottom:6px;
}
.rv2-title em { font-style:italic; color:#5EA98C; }
.rv2-sub { font-size:.9rem; font-weight:500; color:#8AADA0; margin-bottom:32px; line-height:1.6; }
.rv2-divider { width:44px; height:3px; background:linear-gradient(90deg,#6FBA9D,#A8D8C6); border-radius:3px; margin-bottom:24px; }
.rv2-desc { font-size:.81rem; color:#8AADA0; line-height:1.8; max-width:270px; margin-bottom:32px; }
.rv2-features { display:flex; flex-direction:column; gap:11px; }
.rv2-feat { display:flex; align-items:center; gap:11px; font-size:.79rem; color:#2A4235; font-weight:500; }
.rv2-feat-ico {
width:30px; height:30px; border-radius:8px; background:#EBF7F2;
display:flex; align-items:center; justify-content:center;
color:#3D8A6E; font-size:.73rem; flex-shrink:0;
}
/* Responsive */
@media (max-width: 900px) {
.rv2-layout { gap:48px; padding:32px 36px; }
.rv2-brand { flex:0 0 260px; }
.rv2-title { font-size:2.7rem; }
}
@media (max-width: 720px) {
body.auth-page { align-items:flex-start !important; overflow-y:auto !important; }
.rv2-wrap { align-items:flex-start; min-height:auto; padding:24px 0 40px; }
.rv2-layout { flex-direction:column; padding:0 20px; gap:28px; }
.rv2-form-panel { order:2; max-width:100%; }
.rv2-brand { order:1; flex:none; }
.rv2-title { font-size:2.2rem; }
.rv2-logo { width:56px; height:56px; margin:0 auto 14px; display:block; }
.rv2-features, .rv2-desc, .rv2-divider { display:none; }
.rv2-sub { margin-bottom:0; }
.rv2-card { padding:28px 20px; }
.rv2-ring.r1 { width:260px; height:260px; top:-70px; left:-70px; }
.rv2-ring.r2 { display:none; }
}
@media (max-width: 420px) {
.rv2-title { font-size:1.85rem; }
.rv2-card { padding:24px 16px; border-radius:16px; }
.rv2-card-title { font-size:1.5rem; }
}
@media (min-width: 1280px) {
.rv2-layout { max-width:1160px; padding:40px 80px; }
.rv2-brand { flex:0 0 360px; }
.rv2-title { font-size:3.6rem; }
}
</style>
<div class="rv2-wrap">
<div class="rv2-bg"></div>
<div class="rv2-ring r1"></div>
<div class="rv2-ring r2"></div>
<div class="rv2-ring r3"></div>
<div class="rv2-dot d1"></div>
<div class="rv2-dot d2"></div>
<div class="rv2-dot d3"></div>
<div class="rv2-line l1"></div>
<div class="rv2-line l2"></div>
<div class="rv2-layout">
<!-- Form (kiri) -->
<div class="rv2-form-panel">
<div class="rv2-card">
<div class="rv2-card-lbl">Pendaftaran Admin</div>
<div class="rv2-card-title">Buat Akun Baru</div>
<div class="rv2-card-desc">Isi data berikut untuk mendaftarkan akun admin Anda.</div>
@if ($errors->any())
<div class="rv2-alert">
@foreach ($errors->all() as $error)
<p>{{ $error }}</p>
<p><i class="fas fa-circle-exclamation"></i> {{ $error }}</p>
@endforeach
</div>
@endif
@endif
<form method="POST" action="{{ route('admin.register') }}" class="data-form">
<form method="POST" action="{{ route('admin.register') }}">
@csrf
<div class="form-group">
<label for="email"><i class="fas fa-envelope form-icon"></i> Email Admin</label>
<input type="email" id="email" name="email" value="{{ old('email') }}" class="form-control @error('email') is-invalid @enderror" required autofocus>
@error('email')<div class="invalid-feedback">{{ $message }}</div>@enderror
<div class="rv2-field">
<label class="rv2-lbl">Email Admin</label>
<div class="rv2-shell">
<i class="fas fa-envelope fi" id="ico-e"></i>
<input type="email" id="email" name="email"
value="{{ old('email') }}"
placeholder="nama@institusi.com"
autocomplete="email" required autofocus
onfocus="document.getElementById('ico-e').style.color='#6FBA9D'"
onblur="document.getElementById('ico-e').style.color=''">
</div>
@error('email')<div class="rv2-ferr">{{ $message }}</div>@enderror
</div>
<div class="form-group">
<label for="password"><i class="fas fa-key form-icon"></i> Password</label>
<input type="password" id="password" name="password" class="form-control @error('password') is-invalid @enderror" required>
@error('password')<div class="invalid-feedback">{{ $message }}</div>@enderror
<div class="rv2-field">
<label class="rv2-lbl">Password</label>
<div class="rv2-shell">
<i class="fas fa-lock fi" id="ico-pw"></i>
<input type="password" id="password" name="password"
placeholder="Buat password yang kuat"
oninput="rv2Str(this.value)" required
onfocus="document.getElementById('ico-pw').style.color='#6FBA9D'"
onblur="document.getElementById('ico-pw').style.color=''">
<button type="button" class="rv2-show" id="rv2TglBtn">SHOW</button>
</div>
<div class="rv2-strength">
<div class="rv2-bar" id="rv2b1"></div>
<div class="rv2-bar" id="rv2b2"></div>
<div class="rv2-bar" id="rv2b3"></div>
<div class="rv2-bar" id="rv2b4"></div>
</div>
@error('password')<div class="rv2-ferr">{{ $message }}</div>@enderror
</div>
<div class="form-group">
<label for="password_confirmation"><i class="fas fa-lock form-icon"></i> Konfirmasi Password</label>
<input type="password" id="password_confirmation" name="password_confirmation" class="form-control" required>
<div class="rv2-field" style="margin-bottom:22px;">
<label class="rv2-lbl">Konfirmasi Password</label>
<div class="rv2-shell">
<i class="fas fa-lock-open fi" id="ico-c"></i>
<input type="password" id="password_confirmation"
name="password_confirmation"
placeholder="Ulangi password Anda" required
onfocus="document.getElementById('ico-c').style.color='#6FBA9D'"
onblur="document.getElementById('ico-c').style.color=''">
</div>
</div>
<div class="form-group action-group">
<button type="submit" class="btn btn-success btn-full hover-shadow">
<i class="fas fa-paper-plane"></i> Daftarkan Admin
<button type="submit" class="rv2-btn">
<i class="fas fa-user-plus"></i>
Daftarkan Akun Admin
</button>
<div class="rv2-foot">
Sudah punya akun? <a href="{{ route('admin.login') }}">Login di sini</a>
</div>
</form>
</div>
</div>
<p style="text-align: center; font-size: 0.9rem; margin-top: 20px;">
Sudah punya akun? <a href="{{ route('admin.login') }}" class="link-primary">Login di sini</a>
</p>
</form>
<!-- Brand (kanan) -->
<div class="rv2-brand">
<img src="{{ asset('images/logo.png') }}" alt="Logo PKPPS" class="rv2-logo">
<div class="rv2-eyebrow">Bergabung Sekarang</div>
<h1 class="rv2-title">Bergabung<br>Bersama<br><em>Kami.</em></h1>
<p class="rv2-sub">PKPPS Riyadlul Jannah</p>
<div class="rv2-divider"></div>
<p class="rv2-desc">Daftarkan akun admin baru dengan aman. Gunakan email dan password kuat untuk menjaga keamanan sistem pesantren.</p>
<div class="rv2-features">
<div class="rv2-feat">
<div class="rv2-feat-ico"><i class="fas fa-envelope"></i></div>
<span>Gunakan email institusi yang valid</span>
</div>
<div class="rv2-feat">
<div class="rv2-feat-ico"><i class="fas fa-key"></i></div>
<span>Password minimal 8 karakter campuran</span>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const btn = document.getElementById('rv2TglBtn');
const pw = document.getElementById('password');
if (btn && pw) {
btn.addEventListener('click', () => {
const isP = pw.type === 'password';
pw.type = isP ? 'text' : 'password';
btn.textContent = isP ? 'HIDE' : 'SHOW';
});
}
});
function rv2Str(v) {
const bars = ['rv2b1','rv2b2','rv2b3','rv2b4'].map(id => document.getElementById(id));
bars.forEach(b => b.className = 'rv2-bar');
let s = 0;
if (v.length >= 6) s++;
if (v.length >= 10) s++;
if (/[A-Z]/.test(v) && /[0-9]/.test(v)) s++;
if (/[^A-Za-z0-9]/.test(v)) s++;
const cls = s <= 1 ? 'w' : s <= 2 ? 'm' : 's';
for (let i = 0; i < s; i++) bars[i].classList.add(cls);
}
</script>
@endsection

View File

@ -0,0 +1,521 @@
{{-- resources/views/admin/auth/reset_password.blade.php --}}
@extends('auth.auth_layout')
@section('title', 'Reset Password')
@section('auth-content')
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Sans:opsz,wght@9..40,300;400;500;600;700&display=swap" rel="stylesheet">
<style>
body.auth-page {
background: #F8FDFB !important;
font-family: 'DM Sans', sans-serif !important;
padding: 0 !important;
align-items: stretch !important;
min-height: 100vh !important;
}
.auth-container {
width: 100vw !important; max-width: 100vw !important;
min-height: 100vh !important; background: transparent !important;
padding: 0 !important; border-radius: 0 !important; box-shadow: none !important;
}
.rp-wrap {
position: relative; width: 100%; min-height: 100vh;
display: flex; align-items: center; justify-content: center;
overflow: hidden; font-family: 'DM Sans', sans-serif;
}
.rp-bg {
position: absolute; inset: 0; z-index: 0;
background:
radial-gradient(ellipse 80% 60% at 70% 50%, rgba(111,186,157,.12) 0%, transparent 60%),
radial-gradient(ellipse 50% 80% at 10% 80%, rgba(111,186,157,.08) 0%, transparent 55%),
#F8FDFB;
}
.rp-bg::before {
content: ''; position: absolute; inset: 0;
background-image:
linear-gradient(rgba(111,186,157,.055) 1px, transparent 1px),
linear-gradient(90deg, rgba(111,186,157,.055) 1px, transparent 1px);
background-size: 48px 48px;
}
.rp-ring { position: absolute; border-radius: 50%; border: 1.5px solid rgba(111,186,157,.18); pointer-events: none; }
.rp-ring.r1 { width:420px; height:420px; top:-110px; right:-110px; }
.rp-ring.r2 { width:200px; height:200px; bottom:-50px; left:60px; border-color:rgba(111,186,157,.14); }
.rp-dot { position:absolute; border-radius:50%; background:#6FBA9D; pointer-events:none; }
.rp-dot.d1 { width:8px; height:8px; top:22%; right:18%; opacity:.14; }
.rp-dot.d2 { width:11px; height:11px; top:55%; left:15%; opacity:.08; }
.rp-layout {
position: relative; z-index: 2;
display: flex; align-items: center;
width: 100%; max-width: 1100px;
padding: 40px 60px; gap: 80px;
animation: rpIn .6s ease both;
}
@keyframes rpIn {
from { opacity:0; transform:translateY(20px); }
to { opacity:1; transform:translateY(0); }
}
.rp-brand { flex: 0 0 340px; order: 1; }
.rp-form-panel { flex: 1; max-width: 460px; order: 2; }
/* Brand */
.rp-logo { width:72px; height:72px; margin-bottom:20px; border-radius:16px; box-shadow:0 4px 20px rgba(111,186,157,.2); object-fit:contain; background:#fff; }
.rp-eyebrow {
display:inline-flex; align-items:center; gap:8px;
font-size:.68rem; font-weight:700; letter-spacing:2px;
text-transform:uppercase; color:#6FBA9D; margin-bottom:18px;
}
.rp-eyebrow::before { content:''; display:inline-block; width:22px; height:2px; background:#6FBA9D; border-radius:2px; }
.rp-title {
font-family:'DM Serif Display',serif;
font-size:3.2rem; line-height:1.05; color:#0F2118; margin-bottom:6px;
}
.rp-title em { font-style:italic; color:#5EA98C; }
.rp-sub { font-size:.9rem; font-weight:500; color:#8AADA0; margin-bottom:32px; line-height:1.6; }
.rp-divider { width:44px; height:3px; background:linear-gradient(90deg,#6FBA9D,#A8D8C6); border-radius:3px; margin-bottom:24px; }
.rp-desc { font-size:.81rem; color:#8AADA0; line-height:1.8; max-width:290px; margin-bottom:32px; }
/* Steps */
.rp-steps { display:flex; flex-direction:column; gap:14px; margin-bottom:28px; }
.rp-step { display:flex; align-items:flex-start; gap:12px; }
.rp-step-num {
width:28px; height:28px; border-radius:50%;
background:#D6EDE5; color:#5EA98C;
font-size:.72rem; font-weight:800;
display:flex; align-items:center; justify-content:center; flex-shrink:0;
}
.rp-step-num.done { background:linear-gradient(135deg,#6FBA9D,#5EA98C); color:#fff; }
.rp-step-num.active { background:linear-gradient(135deg,#6FBA9D,#5EA98C); color:#fff; box-shadow:0 0 0 4px rgba(111,186,157,.2); }
.rp-step-text { font-size:.78rem; color:#2A4235; font-weight:500; line-height:1.5; padding-top:3px; }
.rp-step-text small { display:block; color:#8AADA0; font-weight:400; font-size:.72rem; }
/* Password rules */
.rp-rules { margin-bottom:24px; }
.rp-rules-title { font-size:.72rem; font-weight:700; letter-spacing:.5px; color:#2A4235; margin-bottom:10px; text-transform:uppercase; }
.rp-rule {
display:flex; align-items:center; gap:8px;
font-size:.76rem; color:#8AADA0; font-weight:500;
padding:4px 0; transition:color .2s;
}
.rp-rule i { font-size:.65rem; width:16px; text-align:center; }
.rp-rule.pass { color:#2E7D32; }
.rp-rule.pass i { color:#43A047; }
.rp-rule.fail { color:#c62828; }
.rp-rule.fail i { color:#e53935; }
/* Card */
.rp-card {
background: #fff; border-radius: 24px;
padding: 42px 38px;
box-shadow:
0 0 0 1px rgba(111,186,157,.1),
0 4px 6px rgba(15,33,24,.03),
0 20px 44px rgba(15,33,24,.08);
position: relative; overflow: hidden;
}
.rp-card::before {
content: ''; position:absolute; top:0; left:0; right:0; height:3px;
background: linear-gradient(90deg, #6FBA9D, #A8D8C6, #6FBA9D);
}
.rp-card::after {
content: ''; position:absolute; bottom:-50px; right:-50px;
width:140px; height:140px; border-radius:50%;
background: radial-gradient(circle, rgba(111,186,157,.06) 0%, transparent 70%);
}
.rp-card-icon {
width:54px; height:54px; border-radius:14px;
background:linear-gradient(135deg,#E8F5E9,#C8E6C9);
display:flex; align-items:center; justify-content:center;
font-size:1.3rem; color:#2E7D32; margin-bottom:16px;
}
.rp-card-lbl {
font-size:.67rem; font-weight:700; letter-spacing:2px;
text-transform:uppercase; color:#6FBA9D; margin-bottom:7px;
}
.rp-card-title {
font-family:'DM Serif Display',serif;
font-size:1.85rem; color:#0F2118; line-height:1.1; margin-bottom:5px;
}
.rp-card-desc { font-size:.79rem; color:#8AADA0; line-height:1.6; margin-bottom:26px; }
/* Alert */
.rp-alert-danger {
border-radius:10px; padding:10px 13px; font-size:.79rem;
margin-bottom:18px; background:#FFF3F3; color:#c62828; border-left:3px solid #e53935;
display:flex; align-items:center; gap:7px;
}
.rp-alert-success {
border-radius:10px; padding:10px 13px; font-size:.79rem;
margin-bottom:18px; background:#F0FFF4; color:#2E7D32; border-left:3px solid #43A047;
display:flex; align-items:center; gap:7px;
}
/* Fields */
.rp-field { margin-bottom:16px; }
.rp-lbl { display:block; font-size:.7rem; font-weight:700; letter-spacing:.8px; text-transform:uppercase; color:#2A4235; margin-bottom:7px; }
.rp-shell { position:relative; display:flex; align-items:center; }
.rp-shell .fi { position:absolute; left:15px; color:#A8D8C6; font-size:.8rem; pointer-events:none; transition:color .2s; }
.rp-shell input {
width:100%; padding:12px 50px 12px 40px;
background:#EBF7F2; border:1.5px solid transparent;
border-radius:11px; font-family:inherit; font-size:.87rem; color:#0F2118; outline:none;
transition:all .2s;
}
.rp-shell input::placeholder { color:#8AADA0; font-size:.83rem; }
.rp-shell input:focus { background:#fff; border-color:#6FBA9D; box-shadow:0 0 0 4px rgba(111,186,157,.12); }
.rp-shell .fi.active { color:#6FBA9D; }
.rp-show {
position:absolute; right:13px;
background:none; border:none; font-size:.68rem; font-weight:800;
letter-spacing:.8px; color:#5EA98C; cursor:pointer; font-family:inherit;
}
.rp-show:hover { color:#3D8A6E; }
/* Strength bar */
.rp-strength {
display:flex; gap:4px; margin-top:8px;
}
.rp-bar { height:4px; flex:1; border-radius:3px; background:#D6EDE5; transition:background .3s; }
.rp-str-text { font-size:.72rem; margin-top:4px; font-weight:600; transition:color .3s; }
/* Match */
.rp-match { font-size:.72rem; margin-top:6px; font-weight:600; }
/* Button */
.rp-btn {
width:100%; padding:13px;
background:linear-gradient(135deg, #6FBA9D, #5EA98C);
color:#fff; border:none; border-radius:12px;
font-family:inherit; font-size:.89rem; font-weight:700;
cursor:pointer; letter-spacing:.3px; margin-top:4px;
display:flex; align-items:center; justify-content:center; gap:8px;
box-shadow:0 4px 18px rgba(94,169,140,.35);
transition:all .25s;
}
.rp-btn:hover { transform:translateY(-2px); box-shadow:0 8px 26px rgba(94,169,140,.45); }
.rp-btn:active { transform:none; }
.rp-btn:disabled {
background:#D6EDE5; color:#8AADA0; cursor:not-allowed;
box-shadow:none; transform:none;
}
.rp-back {
display:flex; align-items:center; justify-content:center; gap:6px;
margin-top:20px; font-size:.78rem; color:#5EA98C; font-weight:600; text-decoration:none;
}
.rp-back:hover { color:#3D8A6E; text-decoration:underline; }
/* Responsive */
@media (max-width: 900px) {
.rp-layout { gap:48px; padding:32px 36px; }
.rp-brand { flex:0 0 260px; }
.rp-title { font-size:2.7rem; }
}
@media (max-width: 720px) {
body.auth-page { align-items:flex-start !important; overflow-y:auto !important; }
.rp-wrap { align-items:flex-start; min-height:auto; padding:24px 0 40px; }
.rp-layout { flex-direction:column; padding:0 20px; gap:28px; }
.rp-form-panel { order:2; max-width:100%; }
.rp-brand { order:1; flex:none; text-align:center; }
.rp-title { font-size:2.2rem; }
.rp-steps, .rp-desc, .rp-divider, .rp-rules { display:none; }
.rp-sub { margin-bottom:0; }
.rp-card { padding:28px 20px; }
.rp-logo { width:56px; height:56px; margin:0 auto 14px; display:block; }
}
@media (max-width: 420px) {
.rp-title { font-size:1.85rem; }
.rp-card { padding:24px 16px; border-radius:16px; }
.rp-card-title { font-size:1.5rem; }
}
@media (min-width: 1280px) {
.rp-layout { max-width:1160px; padding:40px 80px; }
.rp-brand { flex:0 0 360px; }
.rp-title { font-size:3.6rem; }
}
</style>
<div class="rp-wrap">
<div class="rp-bg"></div>
<div class="rp-ring r1"></div>
<div class="rp-ring r2"></div>
<div class="rp-dot d1"></div>
<div class="rp-dot d2"></div>
<div class="rp-layout">
<!-- ═══ Brand (kiri) ═══ -->
<div class="rp-brand">
<img src="{{ asset('images/logo.png') }}" alt="Logo PKPPS" class="rp-logo">
<div class="rp-eyebrow">Langkah Terakhir</div>
<h1 class="rp-title">Buat<br>Password<br><em>Baru.</em></h1>
<p class="rp-sub">PKPPS Riyadlul Jannah</p>
<div class="rp-divider"></div>
<div class="rp-steps">
<div class="rp-step">
<div class="rp-step-num done"><i class="fas fa-check" style="font-size:.6rem"></i></div>
<div class="rp-step-text" style="color:#8AADA0;">Email terkirim</div>
</div>
<div class="rp-step">
<div class="rp-step-num done"><i class="fas fa-check" style="font-size:.6rem"></i></div>
<div class="rp-step-text" style="color:#8AADA0;">OTP terverifikasi</div>
</div>
<div class="rp-step">
<div class="rp-step-num active">3</div>
<div class="rp-step-text">Buat password baru <small>Ikuti ketentuan di bawah</small></div>
</div>
</div>
{{-- Ketentuan Password --}}
<div class="rp-rules">
<div class="rp-rules-title"><i class="fas fa-info-circle"></i> Ketentuan Password</div>
<div class="rp-rule" id="rule-length">
<i class="fas fa-circle"></i> Minimal 8 karakter
</div>
<div class="rp-rule" id="rule-upper">
<i class="fas fa-circle"></i> Mengandung huruf besar (A-Z)
</div>
<div class="rp-rule" id="rule-lower">
<i class="fas fa-circle"></i> Mengandung huruf kecil (a-z)
</div>
<div class="rp-rule" id="rule-number">
<i class="fas fa-circle"></i> Mengandung angka (0-9)
</div>
<div class="rp-rule" id="rule-special">
<i class="fas fa-circle"></i> Mengandung simbol (!@#$%...)
</div>
</div>
</div>
<!-- ═══ Form (kanan) ═══ -->
<div class="rp-form-panel">
<div class="rp-card">
<div class="rp-card-icon">
<i class="fas fa-lock-open"></i>
</div>
<div class="rp-card-lbl">Langkah 3 dari 3</div>
<div class="rp-card-title">Password Baru</div>
<div class="rp-card-desc">Buat password baru yang kuat. Password harus memenuhi semua ketentuan keamanan berikut.</div>
{{-- Ketentuan Password (visible di mobile saja) --}}
<div class="rp-rules-mobile" style="display:none; margin-bottom:20px;">
<div class="rp-rules-title" style="font-size:.68rem;"><i class="fas fa-info-circle"></i> Ketentuan Password</div>
<div class="rp-rule" id="rule-length-m"><i class="fas fa-circle"></i> Minimal 8 karakter</div>
<div class="rp-rule" id="rule-upper-m"><i class="fas fa-circle"></i> Huruf besar (A-Z)</div>
<div class="rp-rule" id="rule-lower-m"><i class="fas fa-circle"></i> Huruf kecil (a-z)</div>
<div class="rp-rule" id="rule-number-m"><i class="fas fa-circle"></i> Angka (0-9)</div>
<div class="rp-rule" id="rule-special-m"><i class="fas fa-circle"></i> Simbol (!@#$%...)</div>
</div>
@if ($errors->any())
<div class="rp-alert-danger">
<i class="fas fa-exclamation-circle"></i>
{{ $errors->first() }}
</div>
@endif
@if(session('success'))
<div class="rp-alert-success" id="rpSuccessAlert">
<i class="fas fa-check-circle"></i>
{{ session('success') }}
</div>
@endif
<form method="POST" action="{{ route('admin.forgot.reset_password') }}" id="resetForm">
@csrf
<input type="hidden" name="email" value="{{ $email }}">
<div class="rp-field">
<label class="rp-lbl">Password Baru</label>
<div class="rp-shell">
<i class="fas fa-lock fi" id="ico-pw"></i>
<input type="password" id="password" name="password"
placeholder="Buat password yang kuat"
required autofocus
onfocus="document.getElementById('ico-pw').classList.add('active')"
onblur="document.getElementById('ico-pw').classList.remove('active')">
<button type="button" class="rp-show" id="tglPw">SHOW</button>
</div>
<div class="rp-strength">
<div class="rp-bar" id="bar1"></div>
<div class="rp-bar" id="bar2"></div>
<div class="rp-bar" id="bar3"></div>
<div class="rp-bar" id="bar4"></div>
</div>
<div class="rp-str-text" id="strText"></div>
</div>
<div class="rp-field">
<label class="rp-lbl">Konfirmasi Password</label>
<div class="rp-shell">
<i class="fas fa-lock-open fi" id="ico-pc"></i>
<input type="password" id="password_confirmation" name="password_confirmation"
placeholder="Ulangi password baru"
required
onfocus="document.getElementById('ico-pc').classList.add('active')"
onblur="document.getElementById('ico-pc').classList.remove('active')">
<button type="button" class="rp-show" id="tglPc">SHOW</button>
</div>
<div class="rp-match" id="matchText"></div>
</div>
<button type="submit" class="rp-btn" id="submitBtn" disabled>
<i class="fas fa-save"></i>
Ubah Password
</button>
</form>
<a href="{{ route('admin.login') }}" class="rp-back">
<i class="fas fa-arrow-left"></i> Kembali ke Login
</a>
</div>
</div>
</div>
</div>
<style>
@media (max-width: 720px) {
.rp-rules-mobile { display:block !important; }
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const pw = document.getElementById('password');
const pc = document.getElementById('password_confirmation');
const bars = ['bar1','bar2','bar3','bar4'].map(id => document.getElementById(id));
const strText = document.getElementById('strText');
const matchText = document.getElementById('matchText');
const submitBtn = document.getElementById('submitBtn');
// Toggle show/hide
function setupToggle(btnId, inputId) {
const btn = document.getElementById(btnId);
const inp = document.getElementById(inputId);
if (btn && inp) {
btn.addEventListener('click', () => {
const isP = inp.type === 'password';
inp.type = isP ? 'text' : 'password';
btn.textContent = isP ? 'HIDE' : 'SHOW';
});
}
}
setupToggle('tglPw', 'password');
setupToggle('tglPc', 'password_confirmation');
// Rule checker
function updateRule(id, pass) {
const el = document.getElementById(id);
if (!el) return;
const icon = el.querySelector('i');
if (pass) {
el.classList.add('pass');
el.classList.remove('fail');
icon.className = 'fas fa-check-circle';
} else {
el.classList.remove('pass');
el.classList.add('fail');
icon.className = 'fas fa-circle';
}
}
function checkRules(val) {
const rules = {
'length': val.length >= 8,
'upper': /[A-Z]/.test(val),
'lower': /[a-z]/.test(val),
'number': /[0-9]/.test(val),
'special': /[^A-Za-z0-9]/.test(val),
};
// Update both desktop and mobile rules
Object.entries(rules).forEach(([key, pass]) => {
updateRule('rule-' + key, pass);
updateRule('rule-' + key + '-m', pass);
});
return Object.values(rules).filter(Boolean).length;
}
// Strength
pw.addEventListener('input', function() {
const val = this.value;
const passed = checkRules(val);
// Reset bars
bars.forEach(b => b.style.background = '#D6EDE5');
if (val.length === 0) {
strText.textContent = '';
validateForm();
return;
}
let color, label;
if (passed <= 2) { color = '#e53935'; label = 'Lemah'; }
else if (passed <= 3) { color = '#FB8C00'; label = 'Sedang'; }
else if (passed <= 4) { color = '#FFB74D'; label = 'Cukup Baik'; }
else { color = '#6FBA9D'; label = 'Kuat'; }
for (let i = 0; i < Math.min(passed, 4); i++) bars[i].style.background = color;
strText.textContent = label;
strText.style.color = color;
checkMatch();
validateForm();
});
// Match
function checkMatch() {
if (!pc.value) { matchText.textContent = ''; return; }
if (pw.value === pc.value) {
matchText.textContent = '✓ Password cocok';
matchText.style.color = '#6FBA9D';
} else {
matchText.textContent = '✗ Password tidak cocok';
matchText.style.color = '#e53935';
}
}
pc.addEventListener('input', function() { checkMatch(); validateForm(); });
// Validate form
function validateForm() {
const val = pw.value;
const allPass = val.length >= 8
&& /[A-Z]/.test(val)
&& /[a-z]/.test(val)
&& /[0-9]/.test(val)
&& /[^A-Za-z0-9]/.test(val)
&& pw.value === pc.value
&& pc.value.length > 0;
submitBtn.disabled = !allPass;
}
// Auto-hide success
const sa = document.getElementById('rpSuccessAlert');
if (sa) {
setTimeout(() => {
sa.style.transition = 'opacity .5s ease';
sa.style.opacity = '0';
setTimeout(() => sa.remove(), 500);
}, 5000);
}
// Init rules to neutral
if (!pw.value) {
['length','upper','lower','number','special'].forEach(key => {
['rule-'+key, 'rule-'+key+'-m'].forEach(id => {
const el = document.getElementById(id);
if (el) { el.classList.remove('pass','fail'); }
});
});
}
});
</script>
@endsection

View File

@ -0,0 +1,419 @@
{{-- resources/views/admin/auth/verify_otp.blade.php --}}
@extends('auth.auth_layout')
@section('title', 'Verifikasi OTP')
@section('auth-content')
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Sans:opsz,wght@9..40,300;400;500;600;700&display=swap" rel="stylesheet">
<style>
body.auth-page {
background: #F8FDFB !important;
font-family: 'DM Sans', sans-serif !important;
padding: 0 !important;
align-items: stretch !important;
min-height: 100vh !important;
}
.auth-container {
width: 100vw !important; max-width: 100vw !important;
min-height: 100vh !important; background: transparent !important;
padding: 0 !important; border-radius: 0 !important; box-shadow: none !important;
}
.vf-wrap {
position: relative; width: 100%; min-height: 100vh;
display: flex; align-items: center; justify-content: center;
overflow: hidden; font-family: 'DM Sans', sans-serif;
}
.vf-bg {
position: absolute; inset: 0; z-index: 0;
background:
radial-gradient(ellipse 80% 60% at 70% 50%, rgba(111,186,157,.12) 0%, transparent 60%),
radial-gradient(ellipse 50% 80% at 10% 80%, rgba(111,186,157,.08) 0%, transparent 55%),
#F8FDFB;
}
.vf-bg::before {
content: ''; position: absolute; inset: 0;
background-image:
linear-gradient(rgba(111,186,157,.055) 1px, transparent 1px),
linear-gradient(90deg, rgba(111,186,157,.055) 1px, transparent 1px);
background-size: 48px 48px;
}
.vf-ring { position: absolute; border-radius: 50%; border: 1.5px solid rgba(111,186,157,.18); pointer-events: none; }
.vf-ring.r1 { width:420px; height:420px; top:-110px; right:-110px; }
.vf-ring.r2 { width:200px; height:200px; bottom:-50px; left:60px; border-color:rgba(111,186,157,.14); }
.vf-dot { position:absolute; border-radius:50%; background:#6FBA9D; pointer-events:none; }
.vf-dot.d1 { width:8px; height:8px; top:22%; right:18%; opacity:.14; }
.vf-dot.d2 { width:11px; height:11px; top:55%; left:15%; opacity:.08; }
.vf-layout {
position: relative; z-index: 2;
display: flex; align-items: center;
width: 100%; max-width: 1100px;
padding: 40px 60px; gap: 80px;
animation: vfIn .6s ease both;
}
@keyframes vfIn {
from { opacity:0; transform:translateY(20px); }
to { opacity:1; transform:translateY(0); }
}
.vf-brand { flex: 0 0 340px; order: 1; }
.vf-form-panel { flex: 1; max-width: 460px; order: 2; }
/* Brand */
.vf-logo { width:72px; height:72px; margin-bottom:20px; border-radius:16px; box-shadow:0 4px 20px rgba(111,186,157,.2); object-fit:contain; background:#fff; }
.vf-eyebrow {
display:inline-flex; align-items:center; gap:8px;
font-size:.68rem; font-weight:700; letter-spacing:2px;
text-transform:uppercase; color:#6FBA9D; margin-bottom:18px;
}
.vf-eyebrow::before { content:''; display:inline-block; width:22px; height:2px; background:#6FBA9D; border-radius:2px; }
.vf-title {
font-family:'DM Serif Display',serif;
font-size:3.2rem; line-height:1.05; color:#0F2118; margin-bottom:6px;
}
.vf-title em { font-style:italic; color:#5EA98C; }
.vf-sub { font-size:.9rem; font-weight:500; color:#8AADA0; margin-bottom:32px; line-height:1.6; }
.vf-divider { width:44px; height:3px; background:linear-gradient(90deg,#6FBA9D,#A8D8C6); border-radius:3px; margin-bottom:24px; }
.vf-desc { font-size:.81rem; color:#8AADA0; line-height:1.8; max-width:290px; margin-bottom:32px; }
/* Steps */
.vf-steps { display:flex; flex-direction:column; gap:14px; }
.vf-step { display:flex; align-items:flex-start; gap:12px; }
.vf-step-num {
width:28px; height:28px; border-radius:50%;
background:#D6EDE5; color:#5EA98C;
font-size:.72rem; font-weight:800;
display:flex; align-items:center; justify-content:center; flex-shrink:0;
}
.vf-step-num.done { background:linear-gradient(135deg,#6FBA9D,#5EA98C); color:#fff; }
.vf-step-num.active { background:linear-gradient(135deg,#6FBA9D,#5EA98C); color:#fff; box-shadow:0 0 0 4px rgba(111,186,157,.2); }
.vf-step-text { font-size:.78rem; color:#2A4235; font-weight:500; line-height:1.5; padding-top:3px; }
.vf-step-text small { display:block; color:#8AADA0; font-weight:400; font-size:.72rem; }
/* Card */
.vf-card {
background: #fff; border-radius: 24px;
padding: 42px 38px;
box-shadow:
0 0 0 1px rgba(111,186,157,.1),
0 4px 6px rgba(15,33,24,.03),
0 20px 44px rgba(15,33,24,.08);
position: relative; overflow: hidden;
}
.vf-card::before {
content: ''; position:absolute; top:0; left:0; right:0; height:3px;
background: linear-gradient(90deg, #FFB74D, #FFA726, #FFB74D);
}
.vf-card::after {
content: ''; position:absolute; bottom:-50px; right:-50px;
width:140px; height:140px; border-radius:50%;
background: radial-gradient(circle, rgba(255,183,77,.06) 0%, transparent 70%);
}
.vf-card-icon {
width:54px; height:54px; border-radius:14px;
background:linear-gradient(135deg,#FFF3E0,#FFE0B2);
display:flex; align-items:center; justify-content:center;
font-size:1.3rem; color:#F57C00; margin-bottom:16px;
}
.vf-card-lbl {
font-size:.67rem; font-weight:700; letter-spacing:2px;
text-transform:uppercase; color:#FFB74D; margin-bottom:7px;
}
.vf-card-title {
font-family:'DM Serif Display',serif;
font-size:1.85rem; color:#0F2118; line-height:1.1; margin-bottom:5px;
}
.vf-card-desc { font-size:.79rem; color:#8AADA0; line-height:1.6; margin-bottom:10px; }
.vf-email-badge {
display:inline-flex; align-items:center; gap:6px;
padding:6px 14px; border-radius:8px;
background:#EBF7F2; font-size:.78rem; font-weight:600; color:#3D8A6E;
margin-bottom:22px;
}
/* Alert */
.vf-alert-danger {
border-radius:10px; padding:10px 13px; font-size:.79rem;
margin-bottom:18px; background:#FFF3F3; color:#c62828; border-left:3px solid #e53935;
display:flex; align-items:center; gap:7px;
}
.vf-alert-success {
border-radius:10px; padding:10px 13px; font-size:.79rem;
margin-bottom:18px; background:#F0FFF4; color:#2E7D32; border-left:3px solid #43A047;
display:flex; align-items:center; gap:7px;
}
/* OTP Boxes */
.vf-otp-row {
display:flex; gap:10px; justify-content:center; margin:22px 0;
}
.vf-otp-box {
width:50px; height:60px; text-align:center;
font-size:1.6rem; font-weight:800; color:#0F2118;
font-family:'DM Sans',sans-serif;
border:2px solid #D6EDE5; border-radius:12px;
background:#EBF7F2; outline:none;
transition:all .2s;
}
.vf-otp-box:focus {
border-color:#FFB74D; background:#fff;
box-shadow:0 0 0 4px rgba(255,183,77,.15);
}
.vf-otp-box.filled {
border-color:#6FBA9D; background:#f0faf5;
}
.vf-otp-box.error {
border-color:#e53935; background:#FFF3F3;
animation: vfShake .4s ease;
}
@keyframes vfShake {
0%,100% { transform:translateX(0); }
20%,60% { transform:translateX(-4px); }
40%,80% { transform:translateX(4px); }
}
/* Button */
.vf-btn {
width:100%; padding:13px;
background:linear-gradient(135deg, #FFB74D, #FFA726);
color:#fff; border:none; border-radius:12px;
font-family:inherit; font-size:.89rem; font-weight:700;
cursor:pointer; letter-spacing:.3px;
display:flex; align-items:center; justify-content:center; gap:8px;
box-shadow:0 4px 18px rgba(255,183,77,.35);
transition:all .25s;
}
.vf-btn:hover { transform:translateY(-2px); box-shadow:0 8px 26px rgba(255,183,77,.45); }
.vf-btn:active { transform:none; }
/* Resend */
.vf-resend {
text-align:center; margin-top:20px; font-size:.8rem; color:#8AADA0;
}
.vf-resend a {
color:#5EA98C; font-weight:700; text-decoration:none; transition:color .2s;
}
.vf-resend a:hover { text-decoration:underline; color:#3D8A6E; }
.vf-resend a.disabled { color:#ccc; pointer-events:none; }
.vf-countdown { font-weight:700; color:#e53935; }
.vf-back {
display:flex; align-items:center; justify-content:center; gap:6px;
margin-top:16px; font-size:.78rem; color:#5EA98C; font-weight:600; text-decoration:none;
}
.vf-back:hover { color:#3D8A6E; text-decoration:underline; }
/* Responsive */
@media (max-width: 900px) {
.vf-layout { gap:48px; padding:32px 36px; }
.vf-brand { flex:0 0 260px; }
.vf-title { font-size:2.7rem; }
}
@media (max-width: 720px) {
body.auth-page { align-items:flex-start !important; overflow-y:auto !important; }
.vf-wrap { align-items:flex-start; min-height:auto; padding:24px 0 40px; }
.vf-layout { flex-direction:column; padding:0 20px; gap:28px; }
.vf-form-panel { order:2; max-width:100%; }
.vf-brand { order:1; flex:none; text-align:center; }
.vf-title { font-size:2.2rem; }
.vf-steps, .vf-desc, .vf-divider { display:none; }
.vf-sub { margin-bottom:0; }
.vf-card { padding:28px 20px; }
.vf-logo { width:56px; height:56px; margin:0 auto 14px; display:block; }
.vf-otp-box { width:42px; height:50px; font-size:1.3rem; }
}
@media (max-width: 420px) {
.vf-title { font-size:1.85rem; }
.vf-card { padding:24px 16px; border-radius:16px; }
.vf-card-title { font-size:1.5rem; }
.vf-otp-row { gap:6px; }
.vf-otp-box { width:38px; height:46px; font-size:1.1rem; border-radius:9px; }
}
@media (min-width: 1280px) {
.vf-layout { max-width:1160px; padding:40px 80px; }
.vf-brand { flex:0 0 360px; }
.vf-title { font-size:3.6rem; }
}
</style>
<div class="vf-wrap">
<div class="vf-bg"></div>
<div class="vf-ring r1"></div>
<div class="vf-ring r2"></div>
<div class="vf-dot d1"></div>
<div class="vf-dot d2"></div>
<div class="vf-layout">
<!-- ═══ Brand (kiri) ═══ -->
<div class="vf-brand">
<img src="{{ asset('images/logo.png') }}" alt="Logo PKPPS" class="vf-logo">
<div class="vf-eyebrow">Verifikasi</div>
<h1 class="vf-title">Cek<br><em>Email</em><br>Anda.</h1>
<p class="vf-sub">PKPPS Riyadlul Jannah</p>
<div class="vf-divider"></div>
<p class="vf-desc">Masukkan kode 6 digit yang telah kami kirim. Periksa juga folder spam jika belum menerima email.</p>
<div class="vf-steps">
<div class="vf-step">
<div class="vf-step-num done"><i class="fas fa-check" style="font-size:.6rem"></i></div>
<div class="vf-step-text" style="color:#8AADA0;">Email terkirim</div>
</div>
<div class="vf-step">
<div class="vf-step-num active">2</div>
<div class="vf-step-text">Verifikasi kode OTP <small>Masukkan 6 digit kode</small></div>
</div>
<div class="vf-step">
<div class="vf-step-num">3</div>
<div class="vf-step-text">Buat password baru</div>
</div>
</div>
</div>
<!-- ═══ Form (kanan) ═══ -->
<div class="vf-form-panel">
<div class="vf-card">
<div class="vf-card-icon">
<i class="fas fa-shield-alt"></i>
</div>
<div class="vf-card-lbl">Langkah 2 dari 3</div>
<div class="vf-card-title">Masukkan Kode OTP</div>
<div class="vf-card-desc">Kode verifikasi 6 digit telah dikirim ke:</div>
<div class="vf-email-badge">
<i class="fas fa-envelope"></i> {{ $email }}
</div>
@if ($errors->any())
<div class="vf-alert-danger">
<i class="fas fa-exclamation-circle"></i>
{{ $errors->first() }}
</div>
@endif
@if(session('success'))
<div class="vf-alert-success" id="vfSuccessAlert">
<i class="fas fa-check-circle"></i>
{{ session('success') }}
</div>
@endif
<form method="POST" action="{{ route('admin.forgot.verify_otp') }}" id="otpForm">
@csrf
<input type="hidden" name="email" value="{{ $email }}">
<input type="hidden" name="otp" id="otpHidden" value="">
<div class="vf-otp-row" id="otpRow">
<input type="text" maxlength="1" class="vf-otp-box" data-index="0" inputmode="numeric" autofocus>
<input type="text" maxlength="1" class="vf-otp-box" data-index="1" inputmode="numeric">
<input type="text" maxlength="1" class="vf-otp-box" data-index="2" inputmode="numeric">
<input type="text" maxlength="1" class="vf-otp-box" data-index="3" inputmode="numeric">
<input type="text" maxlength="1" class="vf-otp-box" data-index="4" inputmode="numeric">
<input type="text" maxlength="1" class="vf-otp-box" data-index="5" inputmode="numeric">
</div>
<button type="submit" class="vf-btn" id="verifyBtn">
<i class="fas fa-check-circle"></i>
Verifikasi Kode
</button>
</form>
<div class="vf-resend">
<p>Tidak menerima kode?</p>
<form method="POST" action="{{ route('admin.forgot.resend_otp') }}" id="resendForm" style="display:inline;">
@csrf
<input type="hidden" name="email" value="{{ $email }}">
<a href="#" id="resendLink" class="disabled" onclick="document.getElementById('resendForm').submit(); return false;">
Kirim Ulang OTP
</a>
<span id="timerText" class="vf-countdown"> (60s)</span>
</form>
</div>
<a href="{{ route('admin.forgot.email_form') }}" class="vf-back">
<i class="fas fa-arrow-left"></i> Ganti Email
</a>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const boxes = document.querySelectorAll('.vf-otp-box');
const hiddenOtp = document.getElementById('otpHidden');
const form = document.getElementById('otpForm');
function updateOtp() {
let otp = '';
boxes.forEach(b => otp += b.value);
hiddenOtp.value = otp;
}
boxes.forEach((box, idx) => {
box.addEventListener('input', function() {
this.value = this.value.replace(/[^0-9]/g, '');
if (this.value && idx < boxes.length - 1) boxes[idx + 1].focus();
this.classList.toggle('filled', !!this.value);
this.classList.remove('error');
updateOtp();
if (hiddenOtp.value.length === 6) form.submit();
});
box.addEventListener('keydown', function(e) {
if (e.key === 'Backspace' && !this.value && idx > 0) {
boxes[idx - 1].focus();
boxes[idx - 1].value = '';
boxes[idx - 1].classList.remove('filled');
updateOtp();
}
});
box.addEventListener('paste', function(e) {
e.preventDefault();
const pasted = (e.clipboardData || window.clipboardData).getData('text').replace(/[^0-9]/g, '');
if (pasted.length >= 6) {
for (let i = 0; i < 6; i++) {
boxes[i].value = pasted[i] || '';
boxes[i].classList.toggle('filled', !!boxes[i].value);
}
boxes[5].focus();
updateOtp();
if (hiddenOtp.value.length === 6) form.submit();
}
});
});
// If there's an error, shake the boxes
@if ($errors->any())
boxes.forEach(b => b.classList.add('error'));
@endif
// Countdown
let secs = 60;
const timerText = document.getElementById('timerText');
const resendLink = document.getElementById('resendLink');
const cd = setInterval(() => {
secs--;
timerText.textContent = ' (' + secs + 's)';
if (secs <= 0) {
clearInterval(cd);
timerText.textContent = '';
resendLink.classList.remove('disabled');
}
}, 1000);
// Auto-hide success
const sa = document.getElementById('vfSuccessAlert');
if (sa) {
setTimeout(() => {
sa.style.transition = 'opacity .5s ease';
sa.style.opacity = '0';
setTimeout(() => sa.remove(), 500);
}, 5000);
}
});
</script>
@endsection

View File

@ -149,8 +149,8 @@ class="form-control @error('status') is-invalid @enderror"
<i class="fas fa-graduation-cap form-icon"></i>
Pilih Kelas yang Akan Menerima Berita <span style="color: var(--danger-color);">*</span>
</label>
<div style="border: 2px solid var(--primary-light); border-radius: var(--border-radius-sm); padding: 20px; background-color: var(--primary-light);">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 15px;">
<div style="border: 2px solid var(--primary-light); border-radius: var(--border-radius-sm); padding: 14px; background-color: var(--primary-light);">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 11px;">
@foreach($kelasOptions as $kelas)
<div style="background: white; padding: 12px; border-radius: var(--border-radius-sm); box-shadow: var(--shadow-sm);">
<label style="display: flex; align-items: center; margin: 0; cursor: pointer;">
@ -176,7 +176,7 @@ class="kelas-checkbox"
</div>
<!-- Submit Buttons -->
<div style="display: flex; gap: 10px; margin-top: 30px; padding-top: 20px; border-top: 2px solid var(--primary-light);">
<div style="display: flex; gap: 10px; margin-top: 22px; padding-top: 20px; border-top: 2px solid var(--primary-light);">
<button type="submit" class="btn btn-success">
<i class="fas fa-save"></i> Simpan Berita
</button>
@ -264,7 +264,7 @@ function updateKelasCount() {
<style>
.ql-toolbar { background-color: #f8f9fa; border-radius: 4px 4px 0 0; border-bottom: 2px solid #dee2e6; }
.ql-container { font-size: 14px; font-family: Arial, sans-serif; min-height: 250px; }
.ql-container { font-size: 11px; font-family: Arial, sans-serif; min-height: 250px; }
.ql-editor { min-height: 250px; max-height: 500px; overflow-y: auto; }
.ql-editor h1 { font-size: 2em; color: #2c3e50; }
.ql-editor h2 { font-size: 1.5em; color: #34495e; }

View File

@ -24,7 +24,7 @@
@method('PUT')
<!-- ID Berita (Read-only) -->
<div style="background: var(--primary-light); padding: 15px; border-radius: var(--border-radius-sm); margin-bottom: 20px;">
<div style="background: var(--primary-light); padding: 15px; border-radius: var(--border-radius-sm); margin-bottom: 14px;">
<strong style="color: var(--primary-dark);">
<i class="fas fa-id-card"></i> ID Berita: {{ $berita->id_berita }}
</strong>
@ -168,8 +168,8 @@ class="form-control @error('status') is-invalid @enderror"
<i class="fas fa-graduation-cap form-icon"></i>
Pilih Kelas yang Akan Menerima Berita <span style="color: var(--danger-color);">*</span>
</label>
<div style="border: 2px solid var(--primary-light); border-radius: var(--border-radius-sm); padding: 20px; background-color: var(--primary-light);">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 15px;">
<div style="border: 2px solid var(--primary-light); border-radius: var(--border-radius-sm); padding: 14px; background-color: var(--primary-light);">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 11px;">
@foreach($kelasOptions as $kelas)
<div style="background: white; padding: 12px; border-radius: var(--border-radius-sm); box-shadow: var(--shadow-sm);">
<label style="display: flex; align-items: center; margin: 0; cursor: pointer;">
@ -195,7 +195,7 @@ class="kelas-checkbox"
</div>
<!-- Submit Buttons -->
<div style="display: flex; gap: 10px; margin-top: 30px; padding-top: 20px; border-top: 2px solid var(--primary-light);">
<div style="display: flex; gap: 10px; margin-top: 22px; padding-top: 20px; border-top: 2px solid var(--primary-light);">
<button type="submit" class="btn btn-success">
<i class="fas fa-save"></i> Update Berita
</button>
@ -281,7 +281,7 @@ function updateKelasCount() {
<style>
.ql-toolbar { background-color: #f8f9fa; border-radius: 4px 4px 0 0; border-bottom: 2px solid #dee2e6; }
.ql-container { font-size: 14px; font-family: Arial, sans-serif; min-height: 250px; }
.ql-container { font-size: 11px; font-family: Arial, sans-serif; min-height: 250px; }
.ql-editor { min-height: 250px; max-height: 500px; overflow-y: auto; }
.ql-editor h1 { font-size: 2em; color: #2c3e50; }
.ql-editor h2 { font-size: 1.5em; color: #34495e; }

View File

@ -8,8 +8,8 @@
</div>
<!-- Header Actions -->
<div class="content-box" style="margin-bottom: 20px;">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px;">
<div class="content-box" style="margin-bottom: 14px;">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 11px;">
<!-- Search & Filter Form -->
<form method="GET" action="{{ route('admin.berita.index') }}" style="display: flex; gap: 10px; flex-wrap: wrap; flex-grow: 1;">
<input type="text"
@ -144,12 +144,12 @@ class="btn btn-warning btn-sm"
</table>
<!-- Pagination -->
<div style="margin-top: 20px; display: flex; justify-content: center;">
<div style="margin-top: 14px; display: flex; justify-content: center;">
{{ $berita->appends(request()->query())->links() }}
</div>
@else
<div style="text-align: center; padding: 60px 20px;">
<i class="fas fa-newspaper" style="font-size: 4em; color: #ccc; margin-bottom: 20px;"></i>
<div style="text-align: center; padding: 44px 14px;">
<i class="fas fa-newspaper" style="font-size: 4em; color: #ccc; margin-bottom: 14px;"></i>
<h3 style="color: var(--text-light);">Belum Ada Berita</h3>
<p style="color: var(--text-light); margin-bottom: 25px;">
Mulai tambahkan berita pertama untuk santri pesantren.

View File

@ -8,10 +8,10 @@
</div>
<!-- Header Actions -->
<div class="content-box" style="margin-bottom: 20px;">
<div class="content-box" style="margin-bottom: 14px;">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
<div>
<span class="badge {{ $berita->status_badge }}" style="font-size: 1em; padding: 8px 15px;">
<span class="badge {{ $berita->status_badge }}" style="font-size: 1em; padding: 6px 11px;">
@if($berita->status === 'published')
<i class="fas fa-check-circle"></i> Published
@else
@ -34,14 +34,14 @@
<div class="content-box">
<div style="padding: 10px;">
<!-- Header Berita -->
<div style="border-bottom: 3px solid var(--primary-color); padding-bottom: 25px; margin-bottom: 30px;">
<div style="border-bottom: 3px solid var(--primary-color); padding-bottom: 25px; margin-bottom: 22px;">
<div style="margin-bottom: 15px;">
<span style="background: var(--primary-light); color: var(--primary-dark); padding: 6px 12px; border-radius: var(--border-radius-sm); font-weight: 600; font-size: 0.9em;">
ID: {{ $berita->id_berita }}
</span>
</div>
<h1 style="color: var(--primary-dark); margin-bottom: 20px; font-size: 2em; line-height: 1.3;">
<h1 style="color: var(--primary-dark); margin-bottom: 14px; font-size: 2em; line-height: 1.3;">
{{ $berita->judul }}
</h1>
@ -81,7 +81,7 @@
<!-- Konten Berita -->
<div class="detail-section">
<h4><i class="fas fa-align-left"></i> Konten Berita</h4>
<div style="line-height: 1.9; font-size: 1.05em; color: var(--text-color); background: var(--primary-light); padding: 25px; border-radius: var(--border-radius-sm); border-left: 4px solid var(--primary-color);">
<div style="line-height: 1.9; font-size: 1.05em; color: var(--text-color); background: var(--primary-light); padding: 18px; border-radius: var(--border-radius-sm); border-left: 4px solid var(--primary-color);">
{!! $berita->konten !!}
</div>
</div>
@ -93,7 +93,7 @@
<i class="fas fa-graduation-cap"></i>
Target Kelas
</h4>
<div style="background: linear-gradient(135deg, #E3F2FD 0%, #D1E9F9 100%); padding: 20px; border-radius: var(--border-radius-sm); border-left: 4px solid var(--info-color);">
<div style="background: linear-gradient(135deg, #E3F2FD 0%, #D1E9F9 100%); padding: 14px; border-radius: var(--border-radius-sm); border-left: 4px solid var(--info-color);">
<p style="margin: 0; color: var(--text-color); font-size: 1em;">
<i class="fas fa-info-circle"></i>
Berita ini ditujukan untuk:

View File

@ -1,21 +1,21 @@
@extends('layouts.app')
@extends('layouts.app')
@section('title', 'Statistik Berita')
@section('title', 'Statistik Berita')
@section('content')
<div class="page-header">
@section('content')
<div class="page-header">
<h2><i class="fas fa-chart-bar"></i> Statistik Berita</h2>
</div>
</div>
<!-- Back Button -->
<div style="margin-bottom: 20px;">
<!-- Back Button -->
<div style="margin-bottom: 14px;">
<a href="{{ route('admin.berita.index') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Kembali ke Daftar Berita
</a>
</div>
</div>
<!-- Dashboard Cards -->
<div class="row-cards">
<!-- Dashboard Cards -->
<div class="row-cards">
<div class="card card-info">
<h3>Total Berita</h3>
<div class="card-value">{{ $totalBerita }}</div>
@ -45,10 +45,10 @@
<div class="card-value">{{ $beritaKelas }}</div>
<i class="fas fa-graduation-cap card-icon"></i>
</div>
</div>
</div>
<!-- Grafik Distribusi -->
<div class="content-box" style="margin-top: 30px;">
<!-- Grafik Distribusi -->
<div class="content-box" style="margin-top: 22px;">
<h3 style="color: var(--primary-color); margin-bottom: 25px; display: flex; align-items: center;">
<i class="fas fa-chart-pie" style="margin-right: 10px;"></i>
Distribusi Berita
@ -56,8 +56,8 @@
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 30px;">
<!-- Status Distribution -->
<div style="background: linear-gradient(135deg, #F8FBF9 0%, #FFFFFF 100%); padding: 25px; border-radius: var(--border-radius); box-shadow: var(--shadow-sm); border: 2px solid var(--primary-light);">
<h4 style="margin-bottom: 20px; color: var(--primary-dark); display: flex; align-items: center;">
<div style="background: linear-gradient(135deg, #F8FBF9 0%, #FFFFFF 100%); padding: 18px; border-radius: var(--border-radius); box-shadow: var(--shadow-sm); border: 2px solid var(--primary-light);">
<h4 style="margin-bottom: 14px; color: var(--primary-dark); display: flex; align-items: center;">
<i class="fas fa-toggle-on" style="margin-right: 8px;"></i>
Berdasarkan Status
</h4>
@ -69,7 +69,7 @@
@endphp
<!-- Published -->
<div style="margin-bottom: 20px;">
<div style="margin-bottom: 14px;">
<div style="display: flex; justify-content: space-between; margin-bottom: 8px; align-items: center;">
<span style="font-weight: 600; color: var(--text-color);">
<i class="fas fa-check-circle" style="color: var(--success-color);"></i> Published
@ -106,8 +106,8 @@
</div>
<!-- Target Distribution -->
<div style="background: linear-gradient(135deg, #F8FBF9 0%, #FFFFFF 100%); padding: 25px; border-radius: var(--border-radius); box-shadow: var(--shadow-sm); border: 2px solid var(--primary-light);">
<h4 style="margin-bottom: 20px; color: var(--primary-dark); display: flex; align-items: center;">
<div style="background: linear-gradient(135deg, #F8FBF9 0%, #FFFFFF 100%); padding: 18px; border-radius: var(--border-radius); box-shadow: var(--shadow-sm); border: 2px solid var(--primary-light);">
<h4 style="margin-bottom: 14px; color: var(--primary-dark); display: flex; align-items: center;">
<i class="fas fa-bullseye" style="margin-right: 8px;"></i>
Berdasarkan Target
</h4>
@ -118,7 +118,7 @@
@endphp
<!-- Semua Santri -->
<div style="margin-bottom: 20px;">
<div style="margin-bottom: 14px;">
<div style="display: flex; justify-content: space-between; margin-bottom: 8px; align-items: center;">
<span style="font-weight: 600; color: var(--text-color);">
<i class="fas fa-globe" style="color: var(--info-color);"></i> Semua Santri
@ -154,11 +154,11 @@
</div>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="content-box" style="margin-top: 30px;">
<h3 style="color: var(--primary-color); margin-bottom: 20px; display: flex; align-items: center;">
<!-- Quick Actions -->
<div class="content-box" style="margin-top: 22px;">
<h3 style="color: var(--primary-color); margin-bottom: 14px; display: flex; align-items: center;">
<i class="fas fa-bolt" style="margin-right: 10px;"></i>
Aksi Cepat
</h3>
@ -180,19 +180,19 @@
<i class="fas fa-graduation-cap"></i> Berita Kelas Tertentu ({{ $beritaKelas }})
</a>
</div>
</div>
</div>
<!-- Empty State -->
@if($totalBerita == 0)
<div class="content-box" style="margin-top: 30px; text-align: center; padding: 60px 20px;">
<!-- Empty State -->
@if($totalBerita == 0)
<div class="content-box" style="margin-top: 22px; text-align: center; padding: 44px 14px;">
<i class="fas fa-newspaper" style="font-size: 5em; color: #ccc; margin-bottom: 25px;"></i>
<h3 style="color: var(--text-light); margin-bottom: 15px;">Belum Ada Berita</h3>
<p style="color: var(--text-light); margin-bottom: 30px; max-width: 500px; margin-left: auto; margin-right: auto;">
<p style="color: var(--text-light); margin-bottom: 22px; max-width: 500px; margin-left: auto; margin-right: auto;">
Mulai dengan membuat berita pertama untuk pesantren Anda. Berita dapat dipublikasikan untuk semua santri atau target tertentu.
</p>
<a href="{{ route('admin.berita.create') }}" class="btn btn-success btn-lg">
<i class="fas fa-plus"></i> Buat Berita Pertama
</a>
</div>
@endif
@endsection
@endif
@endsection

View File

@ -0,0 +1,277 @@
{{-- resources/views/admin/capaian/akses-santri.blade.php --}}
@extends('layouts.app')
@section('content')
<style>
.access-hero {
border-radius: 14px;
padding: 24px 28px;
margin-bottom: 18px;
display: flex;
align-items: center;
gap: 20px;
position: relative;
overflow: hidden;
}
.access-hero.open { background: linear-gradient(135deg, #e8f5e9, #c8e6c9); border: 2px solid #66bb6a; }
.access-hero.closed { background: linear-gradient(135deg, #fff3e0, #ffe0b2); border: 2px solid #ffa726; }
.access-hero .ah-icon { font-size: 3.5rem; flex-shrink: 0; }
.access-hero h3 { margin: 0 0 5px; font-size: 1.15rem; }
.access-hero p { margin: 0; font-size: 0.85rem; color: #555; }
.status-badge-big {
display: inline-flex; align-items: center; gap: 8px;
padding: 8px 22px; border-radius: 25px; font-weight: 800;
font-size: 1rem; margin-bottom: 10px;
}
.status-badge-big.open { background: #2e7d32; color: #fff; }
.status-badge-big.closed { background: #e65100; color: #fff; }
.info-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 18px;
}
.info-box {
background: #fff;
border-radius: 10px;
padding: 14px 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
border-left: 4px solid;
}
.info-box .ib-label { font-size: 0.72rem; color: #999; text-transform: uppercase; margin-bottom: 4px; }
.info-box .ib-val { font-size: 0.92rem; font-weight: 700; color: #333; }
.form-card {
background: #fff;
border-radius: 12px;
padding: 22px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
margin-bottom: 16px;
border: 1px solid #e8f0ec;
}
.form-card h4 { margin: 0 0 16px; color: var(--primary-dark); font-size: 1rem; }
.toggle-btn {
display: inline-flex; align-items: center; gap: 8px;
padding: 11px 28px; border-radius: 10px; border: none;
font-weight: 700; font-size: 0.9rem; cursor: pointer;
transition: all .2s;
}
.toggle-btn:hover { transform: translateY(-2px); box-shadow: 0 6px 18px rgba(0,0,0,0.15); }
.toggle-btn.open-btn { background: linear-gradient(135deg, #43a047, #2e7d32); color: #fff; }
.toggle-btn.close-btn { background: linear-gradient(135deg, #ef5350, #c62828); color: #fff; }
.countdown-bar {
height: 10px; background: #f0f0f0;
border-radius: 20px; overflow: hidden; margin-top: 8px;
}
.countdown-fill {
height: 100%; border-radius: 20px;
background: linear-gradient(90deg, #66bb6a, #2e7d32);
transition: width .5s;
}
</style>
<div class="page-header" style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;">
<h2><i class="fas fa-unlock-alt"></i> Kelola Akses Input Capaian Santri</h2>
<a href="{{ route('admin.capaian.index') }}" class="btn btn-secondary" style="padding:7px 16px;">
<i class="fas fa-arrow-left"></i> Kembali
</a>
</div>
{{-- Alert --}}
@if(session('success'))
<div class="alert alert-success"><i class="fas fa-check-circle"></i> {{ session('success') }}</div>
@endif
@if(session('error'))
<div class="alert alert-danger"><i class="fas fa-exclamation-circle"></i> {{ session('error') }}</div>
@endif
{{-- ===== STATUS HERO ===== --}}
<div class="access-hero {{ $isOpen ? 'open' : 'closed' }}">
<div class="ah-icon">{{ $isOpen ? '🔓' : '🔒' }}</div>
<div style="flex:1;">
<div class="status-badge-big {{ $isOpen ? 'open' : 'closed' }}">
<i class="fas fa-{{ $isOpen ? 'lock-open' : 'lock' }}"></i>
{{ $isOpen ? 'AKSES DIBUKA' : 'AKSES DITUTUP' }}
</div>
<h3>
@if($isOpen)
Santri sedang bisa menginputkan capaian mereka
@else
Santri belum bisa menginputkan capaian
@endif
</h3>
<p>
@if($isOpen)
Dibuka oleh <strong>{{ $config['opened_by'] ?? '-' }}</strong>
pada {{ $config['opened_at'] ? \Carbon\Carbon::parse($config['opened_at'])->isoFormat('D MMM YYYY, HH:mm') : '-' }}
@if($config['id_semester'])
&bull; Semester: <strong>{{ \App\Models\Semester::where('id_semester', $config['id_semester'])->value('nama_semester') ?? '-' }}</strong>
@else
&bull; Semua semester diizinkan
@endif
@if($sisaWaktu)
&bull; Sisa waktu: <strong>{{ $sisaWaktu }}</strong>
@endif
@else
@if($config['closed_at'])
Ditutup pada {{ \Carbon\Carbon::parse($config['closed_at'])->isoFormat('D MMM YYYY, HH:mm') }}
@else
Belum pernah dibuka
@endif
@endif
</p>
@if(!empty($config['catatan']))
<p style="margin-top:6px;font-style:italic;"><i class="fas fa-sticky-note"></i> "{{ $config['catatan'] }}"</p>
@endif
</div>
</div>
{{-- ===== INFO STATS ===== --}}
<div class="info-row">
<div class="info-box" style="border-color:#66bb6a;">
<div class="ib-label">Status</div>
<div class="ib-val" style="color:{{ $isOpen ? '#2e7d32' : '#c62828' }};">
<i class="fas fa-{{ $isOpen ? 'check-circle' : 'times-circle' }}"></i>
{{ $isOpen ? 'Terbuka' : 'Tertutup' }}
</div>
</div>
<div class="info-box" style="border-color:#81C6E8;">
<div class="ib-label">Dibuka Oleh</div>
<div class="ib-val">{{ $config['opened_by'] ?? '-' }}</div>
</div>
<div class="info-box" style="border-color:#FFD56B;">
<div class="ib-label">Semester</div>
<div class="ib-val">
@if($config['id_semester'])
{{ \App\Models\Semester::where('id_semester', $config['id_semester'])->value('nama_semester') ?? '-' }}
@else
Semua Semester
@endif
</div>
</div>
<div class="info-box" style="border-color:#B39DDB;">
<div class="ib-label">Auto-Close</div>
<div class="ib-val">
@if($config['auto_close_at'])
{{ \Carbon\Carbon::parse($config['auto_close_at'])->isoFormat('D MMM HH:mm') }}
@else
Manual
@endif
</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;">
{{-- ===== FORM BUKA AKSES ===== --}}
<div class="form-card">
<h4 style="color:#2e7d32;"><i class="fas fa-lock-open"></i> Buka Akses Input Capaian</h4>
<form method="POST" action="{{ route('admin.capaian.akses-santri.buka') }}">
@csrf
<div class="form-group" style="margin-bottom:12px;">
<label style="font-size:.83rem;font-weight:600;color:#555;display:block;margin-bottom:4px;">
<i class="fas fa-calendar-alt"></i> Semester yang Dibuka
</label>
<select name="id_semester" class="form-control" style="font-size:.84rem;">
<option value="">-- Semua Semester --</option>
@foreach($semesters as $sem)
<option value="{{ $sem->id_semester }}"
{{ ($semesterAktif && $sem->id_semester == $semesterAktif->id_semester) ? 'selected' : '' }}>
{{ $sem->nama_semester }}{{ $sem->is_active ? ' ★ (Aktif)' : '' }}
</option>
@endforeach
</select>
<small style="color:#999;font-size:.74rem;">Kosongkan = santri bisa input di semua semester</small>
</div>
<div class="form-group" style="margin-bottom:12px;">
<label style="font-size:.83rem;font-weight:600;color:#555;display:block;margin-bottom:4px;">
<i class="fas fa-clock"></i> Durasi Otomatis Tutup (opsional)
</label>
<div style="display:flex;align-items:center;gap:8px;">
<input type="number" name="durasi_jam" min="1" max="720"
class="form-control" style="width:110px;font-size:.84rem;"
placeholder="cth: 24">
<span style="font-size:.84rem;color:#555;">jam</span>
</div>
<small style="color:#999;font-size:.74rem;">Kosongkan = harus ditutup manual oleh admin</small>
</div>
<div class="form-group" style="margin-bottom:16px;">
<label style="font-size:.83rem;font-weight:600;color:#555;display:block;margin-bottom:4px;">
<i class="fas fa-sticky-note"></i> Catatan untuk Santri (opsional)
</label>
<input type="text" name="catatan" class="form-control" style="font-size:.84rem;"
placeholder="cth: Deadline input: Jumat 17.00 WIB" maxlength="255">
</div>
<button type="submit" class="toggle-btn open-btn">
<i class="fas fa-lock-open"></i> Buka Akses Sekarang
</button>
</form>
</div>
{{-- ===== TUTUP AKSES ===== --}}
<div class="form-card">
<h4 style="color:#c62828;"><i class="fas fa-lock"></i> Tutup Akses Input Capaian</h4>
@if($isOpen)
<div style="background:#fbe9e7;border-radius:9px;padding:14px;margin-bottom:16px;">
<p style="margin:0;font-size:.84rem;color:#555;">
<i class="fas fa-info-circle" style="color:#ef5350;"></i>
Saat ini akses <strong>sedang dibuka</strong>. Klik tombol di bawah untuk menutup akses input capaian santri segera.
</p>
@if($sisaWaktu)
<p style="margin:8px 0 0;font-size:.8rem;color:#777;">
<i class="fas fa-hourglass-half"></i> Sisa waktu auto-close: <strong>{{ $sisaWaktu }}</strong>
</p>
@endif
</div>
<form method="POST" action="{{ route('admin.capaian.akses-santri.tutup') }}"
onsubmit="return confirm('Yakin ingin menutup akses input capaian santri?')">
@csrf
<button type="submit" class="toggle-btn close-btn">
<i class="fas fa-lock"></i> Tutup Akses Sekarang
</button>
</form>
@else
<div style="background:#f5f5f5;border-radius:9px;padding:14px;text-align:center;color:#aaa;">
<i class="fas fa-lock" style="font-size:2rem;display:block;margin-bottom:8px;"></i>
Akses saat ini sudah tertutup.<br>
<span style="font-size:.8rem;">Gunakan form di sebelah kiri untuk membuka akses.</span>
</div>
@endif
</div>
</div>
{{-- ===== PANDUAN ===== --}}
<div class="form-card" style="background:#f0f9ff;border:1px solid #b3e0ff;">
<h4 style="color:#0277bd;"><i class="fas fa-info-circle"></i> Alur Penggunaan</h4>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px;font-size:.83rem;color:#555;">
<div style="display:flex;gap:10px;">
<div style="background:#1565c0;color:#fff;width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:800;flex-shrink:0;">1</div>
<div><strong>Admin membuka akses</strong><br>Pilih semester & opsional durasi waktu lalu klik "Buka Akses".</div>
</div>
<div style="display:flex;gap:10px;">
<div style="background:#2e7d32;color:#fff;width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:800;flex-shrink:0;">2</div>
<div><strong>Santri input capaian</strong><br>Santri login ke web-nya dan bisa input capaian sesuai materi kelasnya.</div>
</div>
<div style="display:flex;gap:10px;">
<div style="background:#e65100;color:#fff;width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:800;flex-shrink:0;">3</div>
<div><strong>Data langsung masuk</strong><br>Data capaian santri langsung terlihat di dashboard admin & riwayat santri.</div>
</div>
<div style="display:flex;gap:10px;">
<div style="background:#6a1b9a;color:#fff;width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:800;flex-shrink:0;">4</div>
<div><strong>Admin menutup akses</strong><br>Setelah selesai, tutup akses manual atau biarkan auto-close berjalan.</div>
</div>
</div>
</div>
@endsection

View File

@ -71,7 +71,7 @@
</div>
{{-- Materi Info Display --}}
<div id="materiInfo" style="display: none; margin-bottom: 20px;">
<div id="materiInfo" style="display: none; margin-bottom: 14px;">
<div class="info-box">
<i class="fas fa-book-open"></i>
<strong>Detail Materi:</strong>
@ -90,7 +90,7 @@
<h4><i class="fas fa-keyboard"></i> Input Halaman yang Sudah Selesai</h4>
{{-- Tab Metode Input --}}
<div style="display: flex; gap: 10px; margin-bottom: 20px; border-bottom: 2px solid var(--primary-light); padding-bottom: 10px;">
<div style="display: flex; gap: 10px; margin-bottom: 14px; border-bottom: 2px solid var(--primary-light); padding-bottom: 10px;">
<button type="button" class="btn btn-sm btn-primary" id="btnMetode1" onclick="switchMetode(1)">
<i class="fas fa-keyboard"></i> Metode 1: Input Range Text
</button>
@ -154,7 +154,7 @@ class="form-control @error('halaman_selesai') is-invalid @enderror"
<div id="metode3" class="metode-input" style="display: none;">
<div class="form-group">
<label><i class="fas fa-sliders-h form-icon"></i> Pilih Halaman Sampai</label>
<div style="display: flex; align-items: center; gap: 15px;">
<div style="display: flex; align-items: center; gap: 11px;">
<span>Halaman 1 sampai</span>
<input type="number" id="quickInputValue" class="form-control"
style="width: 150px;" min="1" placeholder="400">
@ -176,12 +176,12 @@ class="form-control @error('halaman_selesai') is-invalid @enderror"
</div>
{{-- Preview Result --}}
<div id="previewResult" style="display: none; margin-top: 20px;">
<div id="previewResult" style="display: none; margin-top: 14px;">
<div class="info-box" style="background: linear-gradient(135deg, #E8F7F2 0%, #D4F1E3 100%);">
<h4 style="margin: 0 0 10px 0; color: var(--primary-dark);">
<i class="fas fa-chart-pie"></i> Preview Capaian
</h4>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 15px; margin-top: 15px;">
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 11px; margin-top: 15px;">
<div style="text-align: center; padding: 15px; background: white; border-radius: 8px;">
<p style="margin: 0; color: var(--text-light); font-size: 0.9rem;">Halaman Selesai</p>
<h3 style="margin: 5px 0; color: var(--primary-color);" id="previewJumlah">0</h3>

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@
</div>
{{-- Materi Info Card --}}
<div class="content-box" style="margin-bottom: 20px;">
<div class="content-box" style="margin-bottom: 14px;">
<div style="display: flex; align-items: center; gap: 20px;">
<div class="icon-wrapper icon-wrapper-lg">
<i class="fas fa-book"></i>
@ -28,13 +28,13 @@
</div>
{{-- Filter Semester --}}
<div class="content-box" style="margin-bottom: 20px;">
<div class="content-box" style="margin-bottom: 14px;">
<form method="GET" action="{{ route('admin.capaian.detail-materi', $materi->id_materi) }}" class="filter-form-inline">
<select name="id_semester" class="form-control" style="width: 250px;">
<option value="">Semua Semester</option>
@foreach($semesters as $semester)
<option value="{{ $semester->id_semester }}" {{ $selectedSemester == $semester->id_semester ? 'selected' : '' }}>
{{ $semester->nama_semester }} @if($semester->is_active) @endif
{{ $semester->nama_semester }} @if($semester->is_active) ★ @endif
</option>
@endforeach
</select>
@ -74,7 +74,7 @@
</div>
{{-- Grafik Distribusi --}}
<div class="content-box" style="margin-bottom: 20px;">
<div class="content-box" style="margin-bottom: 14px;">
<h4 style="margin: 0 0 20px 0; color: var(--primary-dark);">
<i class="fas fa-chart-bar"></i> Distribusi Progress Santri
</h4>

View File

@ -52,7 +52,7 @@
<h4><i class="fas fa-keyboard"></i> Update Halaman yang Sudah Selesai</h4>
{{-- Tab Metode Input --}}
<div style="display: flex; gap: 10px; margin-bottom: 20px; border-bottom: 2px solid var(--primary-light); padding-bottom: 10px;">
<div style="display: flex; gap: 10px; margin-bottom: 14px; border-bottom: 2px solid var(--primary-light); padding-bottom: 10px;">
<button type="button" class="btn btn-sm btn-primary" id="btnMetode1" onclick="switchMetode(1)">
<i class="fas fa-keyboard"></i> Metode 1: Input Range Text
</button>
@ -116,7 +116,7 @@ class="form-control @error('halaman_selesai') is-invalid @enderror"
<div id="metode3" class="metode-input" style="display: none;">
<div class="form-group">
<label><i class="fas fa-sliders-h form-icon"></i> Pilih Halaman Sampai</label>
<div style="display: flex; align-items: center; gap: 15px;">
<div style="display: flex; align-items: center; gap: 11px;">
<span>Halaman {{ $capaian->materi->halaman_mulai }} sampai</span>
<input type="number" id="quickInputValue" class="form-control"
style="width: 150px;"
@ -141,12 +141,12 @@ class="form-control @error('halaman_selesai') is-invalid @enderror"
</div>
{{-- Preview Result --}}
<div id="previewResult" style="margin-top: 20px;">
<div id="previewResult" style="margin-top: 14px;">
<div class="info-box" style="background: linear-gradient(135deg, #E8F7F2 0%, #D4F1E3 100%);">
<h4 style="margin: 0 0 10px 0; color: var(--primary-dark);">
<i class="fas fa-chart-pie"></i> Preview Capaian
</h4>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 15px; margin-top: 15px;">
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 11px; margin-top: 15px;">
<div style="text-align: center; padding: 15px; background: white; border-radius: 8px;">
<p style="margin: 0; color: var(--text-light); font-size: 0.9rem;">Halaman Selesai</p>
<h3 style="margin: 5px 0; color: var(--primary-color);" id="previewJumlah">{{ $capaian->jumlah_halaman_selesai }}</h3>

View File

@ -19,14 +19,19 @@
@endif
{{-- Action Button --}}
<div class="content-box" style="margin-bottom: 20px;">
<a href="{{ route('admin.capaian.create') }}" class="btn btn-success" style="padding: 12px 24px;">
<div class="content-box" style="margin-bottom: 14px;">
<div style="display: flex; gap: 10px; flex-wrap: wrap; align-items: center;">
<a href="{{ route('admin.capaian.create') }}" class="btn btn-success" style="padding: 9px 18px;">
<i class="fas fa-plus"></i> Input Capaian
</a>
<a href="{{ route('admin.capaian.akses-santri') }}" class="btn btn-primary" style="padding: 9px 18px;">
<i class="fas fa-unlock-alt"></i> Kelola Akses Input Santri
</a>
</div>
</div>
{{-- Filter Section --}}
<div class="content-box" style="margin-bottom: 20px;">
<div class="content-box" style="margin-bottom: 14px;">
<form method="GET" action="{{ route('admin.capaian.index') }}" class="filter-form-inline">
<div style="display: flex; gap: 10px; flex-wrap: wrap; align-items: center;">
{{-- Filter Kelas (Dropdown dynamic dari database) --}}

View File

@ -6,7 +6,7 @@
</div>
{{-- Santri Info Card --}}
<div class="content-box" style="margin-bottom: 20px;">
<div class="content-box" style="margin-bottom: 14px;">
<div style="display: flex; align-items: center; gap: 20px;">
<div class="icon-wrapper icon-wrapper-lg">
<i class="fas fa-user-graduate"></i>
@ -58,7 +58,7 @@
</div>
{{-- Filter Section --}}
<div class="content-box" style="margin-bottom: 20px;">
<div class="content-box" style="margin-bottom: 14px;">
<form method="GET" action="{{ route('admin.capaian.riwayat-santri', $santri->id_santri) }}" class="filter-form-inline">
<select name="id_semester" class="form-control" style="width: 250px;">
<option value="">Semua Semester</option>
@ -100,7 +100,7 @@
@foreach(['Al-Qur\'an', 'Hadist', 'Materi Tambahan'] as $kategori)
@if(isset($groupedCapaians[$kategori]) && $groupedCapaians[$kategori]->count() > 0)
<div style="margin-bottom: 30px;">
<div style="margin-bottom: 22px;">
<h4 style="color: var(--primary-dark); margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid var(--primary-light);">
<i class="fas fa-{{ $kategori == 'Al-Qur\'an' ? 'book-quran' : ($kategori == 'Hadist' ? 'scroll' : 'book') }}"></i>
Kategori: {{ $kategori }}
@ -162,7 +162,7 @@ class="btn btn-sm btn-warning" title="Edit">
@endforeach
{{-- Pagination --}}
<div style="margin-top: 20px;">
<div style="margin-top: 14px;">
{{ $capaians->links() }}
</div>
@else

View File

@ -23,22 +23,22 @@
</div>
{{-- Progress Card --}}
<div style="background: linear-gradient(135deg, #E8F7F2 0%, #D4F1E3 100%); padding: 30px; border-radius: 12px; margin: 30px 0;">
<div style="background: linear-gradient(135deg, #E8F7F2 0%, #D4F1E3 100%); padding: 22px; border-radius: 12px; margin: 22px 0;">
<h4 style="margin: 0 0 20px 0; color: var(--primary-dark);">
<i class="fas fa-chart-pie"></i> Progress Capaian
</h4>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 20px;">
<div style="text-align: center; padding: 20px; background: white; border-radius: 8px;">
<div style="text-align: center; padding: 14px; background: white; border-radius: 8px;">
<p style="margin: 0; color: var(--text-light); font-size: 0.9rem;">Halaman Selesai</p>
<h2 style="margin: 10px 0; color: var(--primary-color);">{{ $capaian->jumlah_halaman_selesai }}</h2>
<small class="text-muted">dari {{ $capaian->materi->total_halaman }} halaman</small>
</div>
<div style="text-align: center; padding: 20px; background: white; border-radius: 8px;">
<div style="text-align: center; padding: 14px; background: white; border-radius: 8px;">
<p style="margin: 0; color: var(--text-light); font-size: 0.9rem;">Persentase</p>
<h2 style="margin: 10px 0; color: var(--success-color);">{{ number_format($capaian->persentase, 2) }}%</h2>
<small class="text-muted">progress keseluruhan</small>
</div>
<div style="text-align: center; padding: 20px; background: white; border-radius: 8px;">
<div style="text-align: center; padding: 14px; background: white; border-radius: 8px;">
<p style="margin: 0; color: var(--text-light); font-size: 0.9rem;">Status</p>
<div style="margin: 10px 0;">
{!! $capaian->persentase_badge !!}
@ -56,7 +56,7 @@
</small>
</div>
</div>
<div style="margin-top: 20px;">
<div style="margin-top: 14px;">
<div class="progress-bar" style="height: 20px;">
<div class="progress-fill" style="width: {{ $capaian->persentase }}%; background: linear-gradient(90deg, var(--primary-color), var(--success-color)); display: flex; align-items: center; justify-content: center; color: white; font-weight: bold; font-size: 0.85rem;">
{{ number_format($capaian->persentase, 1) }}%
@ -158,7 +158,7 @@
{{-- Visual Halaman (Grid Preview) --}}
<div class="detail-section">
<h4><i class="fas fa-th"></i> Visual Halaman yang Selesai</h4>
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px;">
<div style="background: #f8f9fa; padding: 14px; border-radius: 8px;">
<div id="visualGrid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(45px, 1fr)); gap: 8px;">
<!-- Will be generated by JavaScript -->
</div>
@ -170,7 +170,7 @@
</div>
{{-- Action Buttons --}}
<div style="margin-top: 30px; display: flex; gap: 10px; justify-content: flex-end;">
<div style="margin-top: 22px; display: flex; gap: 10px; justify-content: flex-end;">
<a href="{{ route('admin.capaian.edit', $capaian) }}" class="btn btn-warning">
<i class="fas fa-edit"></i> Edit Capaian
</a>

View File

@ -1,34 +1,30 @@
{{-- Alert Panel --}}
{{-- resources/views/admin/dashboard/_alert-panel.blade.php --}}
@if($alerts['santriAlpaBeruntun']->isNotEmpty() || $alerts['sppJatuhTempo']->isNotEmpty() || $alerts['kepulanganPending']->isNotEmpty())
<div class="content-section">
<h3><i class="fas fa-exclamation-circle"></i> Peringatan & Tindak Lanjut</h3>
<div class="content-box" style="margin-bottom:16px;">
<h4 style="margin:0 0 12px;font-size:.88rem;font-weight:700;color:var(--text-color);display:flex;align-items:center;gap:8px;">
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:linear-gradient(135deg,#FFD56B,#FFAB91);border-radius:6px;flex-shrink:0;">
<i class="fas fa-bell" style="font-size:.7rem;color:#fff;"></i>
</span>
Peringatan &amp; Tindak Lanjut
</h4>
<div class="dash-alerts">
{{-- Santri Alpa Beruntun --}}
@if($alerts['santriAlpaBeruntun']->isNotEmpty())
<div class="alert alert-danger">
<div class="alert-body">
<strong><i class="fas fa-user-times"></i> Santri Alpa Beruntun (7 Hari Terakhir)</strong>
<ul class="alert-list">
<strong>
<i class="fas fa-user-times"></i> Santri Alpa Beruntun (7 Hari Terakhir)
<span class="badge badge-danger badge-sm" style="margin-left:6px;">{{ $alerts['santriAlpaBeruntun']->count() }} santri</span>
</strong>
<ul class="alert-list" style="margin-top:6px;">
@foreach($alerts['santriAlpaBeruntun'] as $s)
<li>{{ $s->nama }} <span class="badge badge-danger badge-sm">{{ $s->total_alpa }}x alpa</span></li>
@endforeach
</ul>
</div>
</div>
@endif
{{-- SPP Jatuh Tempo --}}
@if($alerts['sppJatuhTempo']->isNotEmpty())
<div class="alert alert-warning">
<div class="alert-body">
<strong><i class="fas fa-file-invoice-dollar"></i> SPP Jatuh Tempo</strong>
<ul class="alert-list">
@foreach($alerts['sppJatuhTempo'] as $s)
<li>
{{ $s->santri->nama_lengkap ?? '-' }}
Bln {{ $s->bulan }}/{{ $s->tahun }}
<small>(jatuh tempo {{ $s->batas_bayar->translatedFormat('d M Y') }})</small>
<li style="display:flex;align-items:center;gap:8px;padding:3px 0;">
<i class="fas fa-circle" style="font-size:.4rem;color:var(--danger-color);flex-shrink:0;"></i>
<span style="flex:1;">{{ $s->nama }}</span>
<span class="badge badge-danger badge-sm">{{ $s->total_alpa }}x alpa</span>
</li>
@endforeach
</ul>
@ -36,17 +32,42 @@
</div>
@endif
{{-- Pengajuan Kepulangan Pending --}}
{{-- SPP Jatuh Tempo --}}
@if(auth()->user()->isSuperAdmin() && $alerts['sppJatuhTempo']->isNotEmpty())
<div class="alert alert-warning">
<div class="alert-body">
<strong>
<i class="fas fa-file-invoice-dollar"></i> SPP Jatuh Tempo
<span class="badge badge-warning badge-sm" style="margin-left:6px;">{{ $alerts['sppJatuhTempo']->count() }} tagihan</span>
</strong>
<ul class="alert-list" style="margin-top:6px;">
@foreach($alerts['sppJatuhTempo'] as $s)
<li style="display:flex;align-items:center;gap:8px;padding:3px 0;flex-wrap:wrap;">
<i class="fas fa-circle" style="font-size:.4rem;color:#E6B85C;flex-shrink:0;"></i>
<span style="flex:1;">{{ $s->santri->nama_lengkap ?? '-' }}</span>
<span class="badge badge-warning badge-sm">Bln {{ $s->bulan }}/{{ $s->tahun }}</span>
<small style="color:var(--text-light);">jatuh tempo {{ $s->batas_bayar->translatedFormat('d M Y') }}</small>
</li>
@endforeach
</ul>
</div>
</div>
@endif
{{-- Pengajuan Kepulangan --}}
@if($alerts['kepulanganPending']->isNotEmpty())
<div class="alert alert-info">
<div class="alert-body">
<strong><i class="fas fa-home"></i> Pengajuan Kepulangan Menunggu Review</strong>
<ul class="alert-list">
<strong>
<i class="fas fa-home"></i> Pengajuan Kepulangan Menunggu Review
<span class="badge badge-primary badge-sm" style="margin-left:6px;">{{ $alerts['kepulanganPending']->count() }} pengajuan</span>
</strong>
<ul class="alert-list" style="margin-top:6px;">
@foreach($alerts['kepulanganPending'] as $k)
<li>
{{ $k->santri->nama_lengkap ?? '-' }}
{{ $k->tanggal_pulang->translatedFormat('d M') }} s.d {{ $k->tanggal_kembali->translatedFormat('d M Y') }}
<small>({{ $k->alasan }})</small>
<li style="display:flex;align-items:center;gap:8px;padding:3px 0;flex-wrap:wrap;">
<i class="fas fa-circle" style="font-size:.4rem;color:var(--info-color);flex-shrink:0;"></i>
<span style="flex:1;">{{ $k->santri->nama_lengkap ?? '-' }}</span>
<span class="badge badge-info badge-sm">{{ $k->tanggal_pulang->translatedFormat('d M') }} {{ $k->tanggal_kembali->translatedFormat('d M Y') }}</span>
</li>
@endforeach
</ul>
@ -56,4 +77,5 @@
</div>
</div>
@endif

View File

@ -1,12 +1,20 @@
{{-- Jadwal Kegiatan Hari Ini --}}
<div class="content-section">
<h3><i class="fas fa-list-alt"></i> Jadwal Kegiatan {{ $hari }}</h3>
<div class="content-box">
{{-- resources/views/admin/dashboard/_jadwal-kegiatan.blade.php --}}
<div class="content-box" style="margin-bottom:16px;">
<h4 style="margin:0 0 12px;font-size:.88rem;font-weight:700;color:var(--text-color);display:flex;align-items:center;gap:8px;">
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:linear-gradient(135deg,var(--primary-color),var(--primary-dark));border-radius:6px;flex-shrink:0;">
<i class="fas fa-calendar-day" style="font-size:.7rem;color:#fff;"></i>
</span>
Jadwal Kegiatan {{ $hari }}
</h4>
@if($kegiatan->isEmpty())
<p class="text-muted">Tidak ada kegiatan terjadwal hari ini.</p>
<div class="empty-state" style="padding:20px;">
<i class="fas fa-calendar-times"></i>
<p>Tidak ada kegiatan terjadwal hari ini.</p>
</div>
@else
<div class="table-responsive">
<table class="data-table">
<div class="table-responsive" style="overflow-x:auto;">
<table class="data-table" style="margin-top:0;">
<thead>
<tr>
<th>Kegiatan</th>
@ -20,32 +28,45 @@
@foreach($kegiatan as $k)
<tr class="{{ $k->belum_input ? 'row-danger' : '' }}">
<td>
<strong>{{ $k->nama_kegiatan }}</strong>
<strong style="font-size:.82rem;">{{ $k->nama_kegiatan }}</strong>
@if($k->belum_input)
<span class="badge badge-danger badge-sm">Belum input absensi!</span>
<span class="badge badge-danger badge-sm" style="display:inline-flex;margin-left:6px;animation:slideInDown .4s;">
<i class="fas fa-exclamation-triangle"></i> Belum input absensi!
</span>
@endif
</td>
<td>{{ $k->kategori->nama_kategori ?? '-' }}</td>
<td>
<span class="badge badge-info">{{ $k->kategori->nama_kategori ?? '-' }}</span>
</td>
<td style="font-size:.78rem;font-weight:600;white-space:nowrap;color:var(--text-color);">
{{ is_string($k->waktu_mulai) ? $k->waktu_mulai : $k->waktu_mulai->format('H:i') }}
<span style="color:var(--text-light);margin:0 2px;"></span>
{{ is_string($k->waktu_selesai) ? $k->waktu_selesai : $k->waktu_selesai->format('H:i') }}
</td>
<td>
@if($k->status_kegiatan === 'berlangsung')
<span class="badge badge-info">Berlangsung</span>
<span class="badge badge-success" style="animation:slideInDown .5s;">
<i class="fas fa-circle" style="font-size:.45rem;"></i> Berlangsung
</span>
@elseif($k->status_kegiatan === 'selesai')
<span class="badge badge-success">Selesai</span>
<span class="badge badge-primary">
<i class="fas fa-check"></i> Selesai
</span>
@else
<span class="badge badge-secondary">Belum Mulai</span>
<span class="badge badge-secondary">
<i class="fas fa-clock"></i> Belum Mulai
</span>
@endif
</td>
<td>
@if($k->total_absensi > 0)
<div class="progress-bar-wrap">
<div class="progress-bar-fill" style="width: {{ $k->persen_kehadiran }}%"></div>
<div class="progress-bar-fill" style="width:{{ $k->persen_kehadiran }}%;"></div>
</div>
<small>{{ $k->persen_kehadiran }}% ({{ $k->total_absensi }} data)</small>
<small style="font-size:.68rem;color:var(--text-light);">
{{ $k->persen_kehadiran }}%
<span style="color:#bbb;">({{ $k->total_absensi }} data)</span>
</small>
@else
<small class="text-muted"></small>
@endif
@ -56,5 +77,4 @@
</table>
</div>
@endif
</div>
</div>

View File

@ -1,36 +1,45 @@
{{-- KPI Cards --}}
<div class="row-cards row-cards-5">
{{-- resources/views/admin/dashboard/_kpi-cards.blade.php --}}
<div class="row-cards row-cards-5" style="margin-bottom:16px;">
<div class="card card-info">
<h3>Santri Aktif</h3>
<p class="card-value">{{ $kpi['totalSantriAktif'] }}</p>
<div class="card-value">{{ $kpi['totalSantriAktif'] }}</div>
<span class="card-sub">terdaftar &amp; aktif</span>
<i class="fas fa-user-graduate card-icon"></i>
</div>
<div class="card {{ $kpi['belumAbsensi'] > 0 ? 'card-warning' : 'card-success' }}">
<h3>Kegiatan Hari Ini</h3>
<p class="card-value">{{ $kpi['totalKegiatan'] }}</p>
<span class="card-sub">{{ $kpi['sudahAbsensi'] }} sudah absen &middot; {{ $kpi['belumAbsensi'] }} belum</span>
<div class="card-value">{{ $kpi['totalKegiatan'] }}</div>
<span class="card-sub">
<span style="color:#27ae60;font-weight:700;">{{ $kpi['sudahAbsensi'] }} absen</span>
&nbsp;·&nbsp;
<span style="{{ $kpi['belumAbsensi'] > 0 ? 'color:#e67e22;font-weight:700;' : '' }}">{{ $kpi['belumAbsensi'] }} belum</span>
</span>
<i class="fas fa-calendar-check card-icon"></i>
</div>
<div class="card {{ $kpi['santriSakit'] > 0 ? 'card-danger' : 'card-success' }}">
<h3>Santri di UKP</h3>
<p class="card-value">{{ $kpi['santriSakit'] }}</p>
<div class="card-value">{{ $kpi['santriSakit'] }}</div>
<span class="card-sub">sedang dirawat</span>
<i class="fas fa-briefcase-medical card-icon"></i>
</div>
<div class="card {{ $kpi['kepulanganMenunggu'] > 0 ? 'card-warning' : 'card-success' }}">
<h3>Menunggu Approval</h3>
<p class="card-value">{{ $kpi['kepulanganMenunggu'] }}</p>
<div class="card-value">{{ $kpi['kepulanganMenunggu'] }}</div>
<span class="card-sub">pengajuan kepulangan</span>
<i class="fas fa-clock card-icon"></i>
</div>
@if(auth()->user()->isSuperAdmin())
<div class="card {{ $kpi['santriTanpaWali'] > 0 ? 'card-secondary' : 'card-success' }}">
<h3>Belum Ada Akun Wali</h3>
<p class="card-value">{{ $kpi['santriTanpaWali'] }}</p>
<div class="card-value">{{ $kpi['santriTanpaWali'] }}</div>
<span class="card-sub">santri tanpa wali mobile</span>
<i class="fas fa-user-plus card-icon"></i>
</div>
@endif
</div>

View File

@ -1,32 +1,254 @@
{{-- Ringkasan SPP Bulan Ini --}}
<div class="content-box dash-chart-box">
<h4><i class="fas fa-wallet"></i> SPP Bulan Ini</h4>
@php
$total = $spp['lunas'] + $spp['belum'];
{{-- resources/views/admin/dashboard/_ringkasan-spp.blade.php --}}
@php
$total = ($spp['lunas'] ?? 0) + ($spp['belum'] ?? 0);
$persenLunas = $total > 0 ? round(($spp['lunas'] / $total) * 100) : 0;
@endphp
$terkumpul = (float) ($spp['terkumpul'] ?? 0);
$totalTagihan = (float) ($spp['totalTagihan'] ?? 0);
$persenNominal = $totalTagihan > 0 ? min(100, round($terkumpul / $totalTagihan * 100)) : 0;
<div class="chart-container chart-container-sm">
<canvas id="sppDonutChart"></canvas>
</div>
$pemasukanLain = (float) ($spp['pemasukanLain'] ?? 0);
$pengeluaran = (float) ($spp['pengeluaran'] ?? 0);
$totalPemasukan = $terkumpul + $pemasukanLain;
$sisaKas = $totalPemasukan - $pengeluaran;
<div class="spp-summary">
<div class="spp-stat">
<span class="spp-label">Lunas</span>
<strong class="text-success">{{ $spp['lunas'] }} santri ({{ $persenLunas }}%)</strong>
</div>
<div class="spp-stat">
<span class="spp-label">Belum Lunas</span>
<strong class="text-danger">{{ $spp['belum'] }} santri</strong>
</div>
<div class="spp-stat">
<span class="spp-label">Terkumpul</span>
<strong>Rp {{ number_format($spp['terkumpul'], 0, ',', '.') }}</strong>
</div>
<div class="spp-stat">
<span class="spp-label">Total Tagihan</span>
<strong>Rp {{ number_format($spp['totalTagihan'], 0, ',', '.') }}</strong>
</div>
</div>
$kasMax = max($totalPemasukan, $pengeluaran, 1);
$pBarMasuk = min(100, round($totalPemasukan / $kasMax * 100));
$pBarKeluar = min(100, round($pengeluaran / $kasMax * 100));
@endphp
{{-- Label section --}}
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;
background:linear-gradient(135deg,var(--primary-color),var(--secondary-color));border-radius:6px;flex-shrink:0;">
<i class="fas fa-wallet" style="font-size:.7rem;color:#fff;"></i>
</span>
<span style="font-size:.88rem;font-weight:700;color:var(--text-color);">Keuangan Bulan Ini</span>
<a href="{{ route('admin.keuangan.laporan', ['bulan'=>date('n'),'tahun'=>date('Y')]) }}"
style="margin-left:auto;font-size:.72rem;color:var(--primary-color);font-weight:600;text-decoration:none;display:flex;align-items:center;gap:4px;">
Lihat Neraca <i class="fas fa-arrow-right" style="font-size:.6rem;"></i>
</a>
</div>
{{-- 2 panel grid --}}
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:16px;">
{{-- ── Panel Kiri: Status SPP ── --}}
<div class="content-box" style="display:flex;flex-direction:column;gap:0;">
<h4 style="margin:0 0 14px;font-size:.8rem;font-weight:700;color:var(--primary-dark);
display:flex;align-items:center;gap:6px;">
<i class="fas fa-money-check-alt" style="color:var(--primary-color);"></i>
Status Pembayaran SPP
</h4>
{{-- Ring chart + legend --}}
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:14px;">
{{-- Canvas --}}
<div style="position:relative;width:110px;height:110px;flex-shrink:0;">
<canvas id="sppRingChart"></canvas>
<div style="position:absolute;inset:0;display:flex;flex-direction:column;
align-items:center;justify-content:center;pointer-events:none;">
<span style="font-size:1.5rem;font-weight:800;color:var(--text-color);line-height:1;">{{ $persenLunas }}%</span>
<span style="font-size:.6rem;color:var(--text-light);font-weight:500;">lunas</span>
</div>
</div>
{{-- Stats --}}
<div style="display:flex;flex-direction:column;gap:8px;flex:1;min-width:100px;">
<div style="display:flex;align-items:center;gap:8px;">
<span style="width:10px;height:10px;border-radius:50%;background:var(--primary-color);flex-shrink:0;display:inline-block;"></span>
<span style="font-size:.72rem;color:var(--text-light);flex:1;">Lunas</span>
<strong style="font-size:.82rem;color:var(--primary-color);">{{ $spp['lunas'] ?? 0 }}</strong>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<span style="width:10px;height:10px;border-radius:50%;background:var(--danger-color);flex-shrink:0;display:inline-block;"></span>
<span style="font-size:.72rem;color:var(--text-light);flex:1;">Belum Lunas</span>
<strong style="font-size:.82rem;color:var(--danger-color);">{{ $spp['belum'] ?? 0 }}</strong>
</div>
<div style="height:1px;background:var(--primary-light);margin:2px 0;"></div>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="font-size:.68rem;color:var(--text-light);">Terkumpul</span>
<span style="font-size:.72rem;font-weight:700;color:var(--text-color);">Rp {{ number_format($terkumpul/1000000,1) }}jt</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="font-size:.68rem;color:var(--text-light);">Target</span>
<span style="font-size:.72rem;font-weight:600;color:var(--text-light);">Rp {{ number_format($totalTagihan/1000000,1) }}jt</span>
</div>
</div>
</div>
{{-- Progress bar nominal --}}
<div style="margin-bottom:14px;">
<div style="display:flex;justify-content:space-between;font-size:.68rem;color:var(--text-light);margin-bottom:4px;">
<span>Nominal terkumpul</span>
<span style="font-weight:700;color:var(--text-color);">{{ $persenNominal }}%</span>
</div>
<div class="progress-bar-wrap">
<div class="progress-bar-fill" style="width:{{ $persenNominal }}%;
background:linear-gradient(90deg,var(--primary-color),var(--primary-dark));"></div>
</div>
</div>
{{-- Quick links --}}
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:auto;">
<a href="{{ route('admin.pembayaran-spp.index', ['tab'=>'belum-bayar','bulan'=>date('n'),'tahun'=>date('Y')]) }}"
class="btn btn-danger btn-sm" style="flex:1;justify-content:center;">
<i class="fas fa-exclamation-circle"></i> Belum ({{ $spp['belum'] ?? 0 }})
</a>
<a href="{{ route('admin.pembayaran-spp.generate') }}"
class="btn btn-warning btn-sm" style="flex:1;justify-content:center;">
<i class="fas fa-cogs"></i> Generate
</a>
<a href="{{ route('admin.pembayaran-spp.index', ['tab'=>'sudah-bayar','bulan'=>date('n'),'tahun'=>date('Y')]) }}"
class="btn btn-success btn-sm" style="flex:1;justify-content:center;">
<i class="fas fa-check-circle"></i> Lunas ({{ $spp['lunas'] ?? 0 }})
</a>
</div>
</div>
{{-- ── Panel Kanan: Neraca Kas ── --}}
<div class="content-box" style="display:flex;flex-direction:column;gap:0;">
<h4 style="margin:0 0 14px;font-size:.8rem;font-weight:700;color:var(--primary-dark);
display:flex;align-items:center;gap:6px;">
<i class="fas fa-landmark" style="color:var(--info-color);"></i>
Neraca Kas Pondok
</h4>
{{-- Horizontal bars --}}
<div style="display:flex;flex-direction:column;gap:14px;margin-bottom:14px;">
{{-- Bar Pemasukan --}}
<div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:5px;">
<span style="font-size:.72rem;color:var(--text-light);font-weight:600;display:flex;align-items:center;gap:6px;">
<span style="width:10px;height:10px;border-radius:50%;background:var(--success-color);display:inline-block;flex-shrink:0;"></span>
SPP + Pemasukan
</span>
<strong style="font-size:.78rem;color:var(--success-color);">
Rp {{ number_format($totalPemasukan,0,',','.') }}
</strong>
</div>
<div class="progress-bar-wrap" style="height:10px;">
<div class="progress-bar-fill" style="width:{{ $pBarMasuk }}%;height:100%;
background:linear-gradient(90deg,var(--primary-color),#38ef7d);"></div>
</div>
</div>
{{-- Bar Pengeluaran --}}
<div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:5px;">
<span style="font-size:.72rem;color:var(--text-light);font-weight:600;display:flex;align-items:center;gap:6px;">
<span style="width:10px;height:10px;border-radius:50%;background:var(--danger-color);display:inline-block;flex-shrink:0;"></span>
Pengeluaran
</span>
<strong style="font-size:.78rem;color:var(--danger-color);">
Rp {{ number_format($pengeluaran,0,',','.') }}
</strong>
</div>
<div class="progress-bar-wrap" style="height:10px;">
<div class="progress-bar-fill" style="width:{{ $pBarKeluar }}%;height:100%;
background:linear-gradient(90deg,var(--danger-color),#FF6B7A);"></div>
</div>
</div>
@if($pemasukanLain > 0)
{{-- Bar SPP saja (breakdown) --}}
<div style="opacity:.8;">
@php $pBarSpp = min(100, round($terkumpul / $kasMax * 100)); @endphp
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:5px;">
<span style="font-size:.68rem;color:var(--text-light);display:flex;align-items:center;gap:6px;">
<span style="width:8px;height:8px;border-radius:50%;background:var(--info-color);display:inline-block;flex-shrink:0;"></span>
dari SPP saja
</span>
<span style="font-size:.7rem;color:var(--info-color);font-weight:600;">
Rp {{ number_format($terkumpul,0,',','.') }}
</span>
</div>
<div class="progress-bar-wrap" style="height:7px;">
<div class="progress-bar-fill" style="width:{{ $pBarSpp }}%;height:100%;
background:linear-gradient(90deg,var(--info-color),#5FAFE0);"></div>
</div>
</div>
@endif
</div>
{{-- Sisa Kas box --}}
<div style="padding:11px 14px;border-radius:var(--border-radius-sm);margin-bottom:14px;
background:{{ $sisaKas >= 0 ? 'linear-gradient(135deg,#E8F7F2,#D4F1E3)' : 'linear-gradient(135deg,#FFE8EA,#FFD5D8)' }};
border-left:4px solid {{ $sisaKas >= 0 ? 'var(--success-color)' : 'var(--danger-color)' }};
display:flex;justify-content:space-between;align-items:center;">
<span style="font-size:.75rem;font-weight:700;color:var(--text-color);display:flex;align-items:center;gap:6px;">
<i class="fas fa-{{ $sisaKas >= 0 ? 'piggy-bank' : 'exclamation-triangle' }}"
style="color:{{ $sisaKas >= 0 ? 'var(--success-color)' : 'var(--danger-color)' }};"></i>
Sisa Kas Bulan Ini
</span>
<strong style="font-size:1rem;font-weight:800;color:{{ $sisaKas >= 0 ? 'var(--success-color)' : 'var(--danger-color)' }};">
{{ $sisaKas >= 0 ? '+' : '' }}Rp {{ number_format($sisaKas,0,',','.') }}
</strong>
</div>
{{-- Quick links --}}
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:auto;">
<a href="{{ route('admin.keuangan.index') }}"
class="btn btn-info btn-sm" style="flex:1;justify-content:center;">
<i class="fas fa-book-open"></i> Buku Kas
</a>
<a href="{{ route('admin.keuangan.laporan', ['bulan'=>date('n'),'tahun'=>date('Y')]) }}"
class="btn btn-warning btn-sm" style="flex:1;justify-content:center;">
<i class="fas fa-chart-bar"></i> Laporan
</a>
</div>
</div>
</div>
{{-- Ring Chart Script --}}
<script>
(function () {
var SPP_LUNAS = {{ (int)($spp['lunas'] ?? 0) }};
var SPP_BELUM = {{ (int)($spp['belum'] ?? 0) }};
function initRing() {
if (typeof Chart === 'undefined') { setTimeout(initRing, 50); return; }
var el = document.getElementById('sppRingChart');
if (!el) return;
if (el._ci) { el._ci.destroy(); }
var allZero = (SPP_LUNAS === 0 && SPP_BELUM === 0);
el._ci = new Chart(el, {
type: 'doughnut',
data: {
labels : allZero ? ['Belum ada data'] : ['Lunas', 'Belum Lunas'],
datasets: [{
data : allZero ? [1] : [SPP_LUNAS, SPP_BELUM],
backgroundColor: allZero ? ['#E8F7F2'] : ['#6FBA9D', '#FF8B94'],
borderWidth : allZero ? 0 : 3,
borderColor : '#fff',
hoverOffset : allZero ? 0 : 6
}]
},
options: {
responsive: true, maintainAspectRatio: false,
cutout: '70%',
plugins: {
legend: { display: false },
tooltip: {
enabled: !allZero,
callbacks: {
label: function (ctx) {
var total = ctx.dataset.data.reduce(function(a,b){return a+b;},0);
var pct = total > 0 ? Math.round(ctx.parsed/total*100) : 0;
return ' '+ctx.label+': '+ctx.formattedValue+' ('+pct+'%)';
}
}
}
}
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initRing);
} else { initRing(); }
})();
</script>

View File

@ -1,7 +1,13 @@
{{-- Tren Kehadiran 4 Minggu Terakhir --}}
<div class="content-box dash-chart-box">
<h4><i class="fas fa-chart-line"></i> Tren Kehadiran (4 Minggu)</h4>
<div class="chart-container">
{{-- resources/views/admin/dashboard/_tren-kehadiran.blade.php --}}
<div class="content-box" style="margin-bottom:16px;">
<h4 style="margin:0 0 12px;font-size:.88rem;font-weight:700;color:var(--text-color);display:flex;align-items:center;gap:8px;">
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;
background:linear-gradient(135deg,var(--info-color),#5FAFE0);border-radius:6px;flex-shrink:0;">
<i class="fas fa-chart-line" style="font-size:.7rem;color:#fff;"></i>
</span>
Tren Kehadiran 4 Minggu Terakhir
</h4>
<div class="chart-container" style="height:220px;">
<canvas id="trenKehadiranChart"></canvas>
</div>
</div>

View File

@ -1,98 +1,159 @@
{{-- views/admin/dashboardAdmin.blade.php --}}
{{-- resources/views/admin/dashboardAdmin.blade.php --}}
@extends('layouts.app', ['isAdmin' => true])
@section('title', 'Dashboard Admin')
@section('content')
{{-- ───────── PAGE HEADER ───────── --}}
<div class="page-header">
<h2>Dashboard Admin</h2>
<p>{{ $hariIni }}, {{ $today->translatedFormat('d F Y') }}</p>
<div>
<h2>
@if(auth()->user()->role === 'super_admin')
<i class="fas fa-crown"></i> Super Admin
@elseif(auth()->user()->role === 'akademik')
<i class="fas fa-book-open"></i> Akademik
@else
<i class="fas fa-shield-alt"></i> Pamong
@endif
</h2>
<p style="margin:3px 0 0;font-size:.75rem;color:var(--text-light);">
{{ $hariIni }}, {{ $today->translatedFormat('d F Y') }}
&nbsp;·&nbsp;
<span style="display:inline-flex;align-items:center;gap:5px;">
<span style="width:7px;height:7px;border-radius:50%;background:#27ae60;
box-shadow:0 0 0 3px rgba(39,174,96,.2);display:inline-block;
animation:dashLivePulse 2s infinite;"></span>
Live
</span>
</p>
</div>
</div>
{{-- 1. KPI Cards --}}
<style>
@keyframes dashLivePulse {
0%,100%{box-shadow:0 0 0 3px rgba(39,174,96,.2)}
50% {box-shadow:0 0 0 6px rgba(39,174,96,.05)}
}
/* 2-col grid untuk SPP + Kas */
.dash-fin-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-bottom: 16px;
}
@media (max-width: 768px) {
.dash-fin-grid { grid-template-columns: 1fr; }
}
@media (max-width: 1024px) {
.row-cards-5 { grid-template-columns: repeat(3,1fr); }
}
@media (max-width: 640px) {
.row-cards-5 { grid-template-columns: repeat(2,1fr); }
}
</style>
{{-- ───────── 1. KPI CARDS ───────── --}}
@include('admin.dashboard._kpi-cards', ['kpi' => $kpiCards])
{{-- 2. Jadwal Kegiatan Hari Ini --}}
@include('admin.dashboard._jadwal-kegiatan', ['kegiatan' => $kegiatanHariIni, 'hari' => $hariIni])
{{-- 3. Alert Panel --}}
{{-- ───────── 2. ALERTS ───────── --}}
@include('admin.dashboard._alert-panel', ['alerts' => $alerts])
{{-- Row: Grafik + SPP --}}
<div class="dash-grid-2">
{{-- 4. Grafik Tren Kehadiran --}}
@include('admin.dashboard._tren-kehadiran', ['trenKehadiran' => $trenKehadiran])
{{-- ───────── 3. JADWAL KEGIATAN ───────── --}}
@include('admin.dashboard._jadwal-kegiatan', ['kegiatan' => $kegiatanHariIni, 'hari' => $hariIni])
{{-- 5. Ringkasan SPP Bulan Ini --}}
{{-- ───────── 4. KEUANGAN (SPP + KAS) ───────── --}}
@if(auth()->user()->isSuperAdmin())
@include('admin.dashboard._ringkasan-spp', ['spp' => $sppBulanIni])
</div>
@endif
{{-- ───────── 5. TREN KEHADIRAN FULL WIDTH ───────── --}}
@include('admin.dashboard._tren-kehadiran', ['trenKehadiran' => $trenKehadiran])
{{-- 6. Feed Aktivitas Terbaru --}}
@include('admin.dashboard._feed-aktivitas', ['feed' => $feedAktivitas])
@endsection
@section('scripts')
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
// ── Tren Kehadiran (Line Chart) ──
const trenCtx = document.getElementById('trenKehadiranChart');
if (trenCtx) {
const trenData = @json($trenKehadiran);
const colors = ['#6FBA9D', '#FF8B94', '#81C6E8', '#FFD56B', '#B39DDB', '#FFAB91'];
const datasets = Object.keys(trenData.series).map((label, i) => ({
label: label,
data: trenData.series[label],
borderColor: colors[i % colors.length],
backgroundColor: colors[i % colors.length] + '20',
tension: 0.3,
fill: true,
pointRadius: 4,
pointHoverRadius: 6,
}));
// ── Tren Kehadiran Line Chart ─────────────────────────────────────────
var trenCtx = document.getElementById('trenKehadiranChart');
if (!trenCtx) return;
var trenData = @json($trenKehadiran);
// Pakai palet warna yang sesuai theme (eucalyptus green + accents)
var palette = ['#6FBA9D','#FF8B94','#81C6E8','#FFD56B','#B39DDB','#FFAB91'];
var datasets = [];
Object.keys(trenData.series).forEach(function (key, i) {
var c = palette[i % palette.length];
datasets.push({
label : key,
data : trenData.series[key],
borderColor : c,
backgroundColor : c + '20',
borderWidth : 2.5,
tension : 0.4,
fill : true,
pointRadius : 5,
pointHoverRadius : 7,
pointBackgroundColor : c,
pointBorderColor : '#fff',
pointBorderWidth : 2
});
});
new Chart(trenCtx, {
type: 'line',
data: { labels: trenData.labels, datasets },
data: { labels: trenData.labels, datasets: datasets },
options: {
responsive: true,
responsive : true,
maintainAspectRatio: false,
interaction : { mode: 'index', intersect: false },
plugins: {
legend: { position: 'bottom', labels: { padding: 16, usePointStyle: true } },
tooltip: { callbacks: { label: ctx => ctx.dataset.label + ': ' + ctx.parsed.y + '%' } }
legend: {
position: 'bottom',
labels : {
padding : 18,
usePointStyle: true,
font : { size: 11 }
}
},
tooltip: {
backgroundColor: '#2C3E50',
titleFont : { size: 12, weight: '700' },
bodyFont : { size: 11 },
padding : 10,
cornerRadius : 8,
callbacks : {
label: function (ctx) {
return ' ' + ctx.dataset.label + ': ' + ctx.parsed.y + '%';
}
}
}
},
scales: {
y: { beginAtZero: true, max: 100, ticks: { callback: v => v + '%' } }
}
}
});
}
// ── Ringkasan SPP (Donut Chart) ──
const sppCtx = document.getElementById('sppDonutChart');
if (sppCtx) {
const sppData = @json($sppBulanIni);
new Chart(sppCtx, {
type: 'doughnut',
data: {
labels: ['Lunas', 'Belum Lunas'],
datasets: [{
data: [sppData.lunas, sppData.belum],
backgroundColor: ['#6FBA9D', '#FF8B94'],
borderWidth: 2,
borderColor: '#fff',
}]
x: {
grid : { display: false },
ticks: { font: { size: 11 }, color: '#7F8C8D' }
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '65%',
plugins: {
legend: { position: 'bottom', labels: { padding: 16, usePointStyle: true } },
y: {
beginAtZero: true,
max : 100,
grid : { color: '#E8F7F2' },
ticks : {
callback: function (v) { return v + '%'; },
font : { size: 10 },
color : '#7F8C8D'
}
}
}
}
});
}
// SPP ring chart diinisialisasi dari dalam _ringkasan-spp.blade.php
});
</script>
@endsection

View File

@ -3,6 +3,9 @@
@section('title', 'Tambah Pelanggaran')
@section('content')
{{-- Select2 CSS --}}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css">
<div class="page-header">
<h2><i class="fas fa-plus-circle"></i> Tambah Pelanggaran</h2>
</div>
@ -87,7 +90,7 @@ class="form-control @error('poin') is-invalid @enderror"
id="kafaroh"
class="form-control @error('kafaroh') is-invalid @enderror"
rows="6"
placeholder="Contoh: Membaca Al-Qur'an 1 juz, Sholat tahajud 2 rakaat, dll...">{{ old('kafaroh') }}</textarea>
placeholder="Contoh: Bangunan, Sholat tasbih, dll...">{{ old('kafaroh') }}</textarea>
@error('kafaroh')
<span class="invalid-feedback">{{ $message }}</span>
@enderror
@ -111,7 +114,7 @@ class="form-control @error('kafaroh') is-invalid @enderror"
</div>
</div>
<div class="btn-group" style="margin-top: 30px;">
<div class="btn-group" style="margin-top: 22px;">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Simpan
</button>
@ -121,4 +124,16 @@ class="form-control @error('kafaroh') is-invalid @enderror"
</div>
</form>
</div>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script>
$(document).ready(function() {
$('#id_klasifikasi').select2({
placeholder: '-- Pilih Klasifikasi --',
allowClear: true,
width: '100%'
});
});
</script>
@endsection

View File

@ -107,7 +107,7 @@ class="form-control @error('kafaroh') is-invalid @enderror"
</div>
</div>
<div class="btn-group" style="margin-top: 30px;">
<div class="btn-group" style="margin-top: 22px;">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Update
</button>

View File

@ -4,7 +4,7 @@
@section('content')
<div class="page-header">
<h2><i class="fas fa-list-ul"></i> Master Pelanggaran</h2>
<h2><i class="fas fa-list-ul"></i> Kategori Pelanggaran</h2>
</div>
@if(session('success'))
@ -20,9 +20,9 @@
@endif
<!-- Filter -->
<div class="content-box" style="margin-bottom: 20px;">
<div class="content-box" style="margin-bottom: 14px;">
<form method="GET" action="{{ route('admin.kategori-pelanggaran.index') }}">
<div style="display: grid; grid-template-columns: 2fr 1fr auto; gap: 15px; align-items: end;">
<div style="display: grid; grid-template-columns: 2fr 1fr auto; gap: 11px; align-items: end;">
<div class="form-group" style="margin-bottom: 0;">
<label for="id_klasifikasi">
<i class="fas fa-filter form-icon"></i>
@ -65,7 +65,7 @@
<!-- Tabel Data -->
<div class="content-box">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px;">
<h3 style="margin: 0; color: var(--primary-color);">
<i class="fas fa-table"></i> Daftar Pelanggaran
</h3>
@ -73,9 +73,6 @@
<a href="{{ route('admin.klasifikasi-pelanggaran.index') }}" class="btn btn-warning">
<i class="fas fa-tags"></i> Klasifikasi Pelanggaran
</a>
<a href="{{ route('admin.pembinaan-sanksi.index') }}" class="btn btn-success">
<i class="fas fa-book-open"></i> Pembinaan & Sanksi
</a>
<a href="{{ route('admin.kategori-pelanggaran.create') }}" class="btn btn-primary">
<i class="fas fa-plus-circle"></i> Tambah Pelanggaran
</a>

View File

@ -8,10 +8,10 @@
</div>
<div class="content-box">
<div style="margin-bottom: 30px;">
<div style="margin-bottom: 22px;">
<h3 style="color: var(--primary-color); margin-bottom: 15px;">Informasi Pelanggaran</h3>
<table style="width: 100%; margin-bottom: 20px;">
<table style="width: 100%; margin-bottom: 14px;">
<tr>
<td style="width: 200px; padding: 10px 0; font-weight: 600;">ID Pelanggaran</td>
<td style="padding: 10px 0;">
@ -114,7 +114,7 @@
</table>
@endif
<div class="btn-group" style="margin-top: 30px;">
<div class="btn-group" style="margin-top: 22px;">
<a href="{{ route('admin.kategori-pelanggaran.edit', $kategori) }}" class="btn btn-warning">
<i class="fas fa-edit"></i> Edit
</a>

View File

@ -0,0 +1,97 @@
{{-- views/admin/kegiatan/absensi/edit.blade.php --}}
@extends('layouts.app')
@section('content')
<div class="page-header">
<h2><i class="fas fa-edit"></i> Edit Absensi</h2>
</div>
<div class="content-box" style="max-width: 600px;">
<div style="margin-bottom: 20px;">
<h3 style="margin: 0 0 8px 0; color: var(--primary-color);">{{ $absensi->kegiatan->nama_kegiatan }}</h3>
<p style="margin: 0; color: var(--text-light); font-size: 0.9rem;">
<i class="fas fa-tag"></i> {{ $absensi->kegiatan->kategori->nama_kategori ?? '-' }} |
<i class="fas fa-calendar"></i> {{ \Carbon\Carbon::parse($absensi->tanggal)->format('d M Y') }} |
<i class="fas fa-clock"></i> {{ $absensi->waktu_absen ? date('H:i', strtotime($absensi->waktu_absen)) : '-' }}
</p>
</div>
<div style="background: var(--primary-light); padding: 14px; border-radius: 8px; margin-bottom: 20px;">
<table style="width: 100%;">
<tr>
<td style="width: 120px; padding: 4px 0; color: var(--text-light);"><i class="fas fa-id-badge"></i> ID Santri</td>
<td style="padding: 4px 0;"><strong>{{ $absensi->santri->id_santri }}</strong></td>
</tr>
<tr>
<td style="padding: 4px 0; color: var(--text-light);"><i class="fas fa-user"></i> Nama</td>
<td style="padding: 4px 0;"><strong>{{ $absensi->santri->nama_lengkap }}</strong></td>
</tr>
<tr>
<td style="padding: 4px 0; color: var(--text-light);"><i class="fas fa-chalkboard"></i> Kelas</td>
<td style="padding: 4px 0;">{{ $absensi->santri->kelas_name ?? '-' }}</td>
</tr>
<tr>
<td style="padding: 4px 0; color: var(--text-light);"><i class="fas fa-info-circle"></i> Status Saat Ini</td>
<td style="padding: 4px 0;">
{!! $absensi->status_badge !!}
</td>
</tr>
</table>
</div>
<form action="{{ route('admin.absensi-kegiatan.update', $absensi->id) }}" method="POST">
@csrf
@method('PUT')
<div class="form-group" style="margin-bottom: 20px;">
<label style="font-weight: 600; margin-bottom: 10px; display: block;">
<i class="fas fa-exchange-alt"></i> Ubah Status Absensi:
</label>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
@php
$statusOptions = [
'Hadir' => ['badge' => 'badge-success', 'icon' => 'fa-check'],
'Terlambat' => ['badge' => '', 'icon' => 'fa-clock', 'style' => 'background: #FF9800; color: white;'],
'Izin' => ['badge' => 'badge-warning', 'icon' => 'fa-envelope'],
'Sakit' => ['badge' => 'badge-info', 'icon' => 'fa-medkit'],
'Alpa' => ['badge' => 'badge-danger', 'icon' => 'fa-times'],
'Pulang' => ['badge' => '', 'icon' => 'fa-home', 'style' => 'background: #FFF3E0; color: #E65100;'],
];
@endphp
@foreach($statusOptions as $status => $opt)
<label style="cursor: pointer; margin: 0;">
<input type="radio" name="status" value="{{ $status }}" {{ $absensi->status == $status ? 'checked' : '' }} style="display: none;" class="status-radio">
<span class="badge {{ $opt['badge'] ?? '' }} status-option" style="{{ $opt['style'] ?? '' }} padding: 8px 16px; font-size: 0.9rem; border: 2px solid transparent; border-radius: 6px; transition: all 0.2s;">
<i class="fas {{ $opt['icon'] }}"></i> {{ $status }}
</span>
</label>
@endforeach
</div>
@error('status')
<div class="text-danger" style="margin-top: 6px; font-size: 0.85rem;">{{ $message }}</div>
@enderror
</div>
<div class="btn-group" style="display: flex; gap: 10px;">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Simpan Perubahan
</button>
<a href="{{ route('admin.absensi-kegiatan.rekap', $absensi->kegiatan_id) }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Kembali ke Rekap
</a>
</div>
</form>
</div>
<style>
.status-radio:checked + .status-option {
border-color: var(--primary-color) !important;
box-shadow: 0 0 0 3px rgba(var(--primary-rgb, 76, 110, 245), 0.25);
transform: scale(1.05);
}
.status-option:hover {
transform: scale(1.05);
opacity: 0.9;
}
</style>
@endsection

View File

@ -1,199 +0,0 @@
@extends('layouts.app')
@section('content')
<style>
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.page-header h2 { margin: 0; font-size: 1.5rem; display: flex; align-items: center; gap: 10px; }
.btn-back { padding: 8px 16px; background: #6B7280; color: #fff; border: none; border-radius: 8px; font-size: 0.85rem; text-decoration: none; display: inline-flex; align-items: center; gap: 6px; transition: background 0.2s; }
.btn-back:hover { background: #4B5563; color: #fff; }
.filter-box { background: #fff; padding: 20px; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); margin-bottom: 20px; }
.filter-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; }
.form-group { margin: 0; }
.form-group label { display: block; font-size: 0.85rem; margin-bottom: 5px; color: #64748b; font-weight: 500; }
.form-control { width: 100%; padding: 8px 12px; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 0.85rem; }
.btn-filter { background: var(--primary-color); color: #fff; border: none; padding: 9px 16px; border-radius: 8px; cursor: pointer; font-size: 0.85rem; display: inline-flex; align-items: center; gap: 6px; transition: all 0.2s; }
.btn-filter:hover { background: #059669; }
.btn-reset { background: #6B7280; color: #fff; border: none; padding: 9px 12px; border-radius: 8px; font-size: 0.85rem; display: inline-flex; align-items: center; gap: 6px; text-decoration: none; }
.btn-reset:hover { background: #4B5563; color: #fff; }
.kelas-badge-container { position: relative; display: inline-block; }
.kelas-badge-more { cursor: pointer; position: relative; }
.kelas-tooltip { display: none; position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%); margin-bottom: 8px; background: #2c3e50; color: white; padding: 10px 12px; border-radius: 8px; font-size: 0.85rem; white-space: nowrap; z-index: 1000; box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
.kelas-tooltip::after { content: ''; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); border: 6px solid transparent; border-top-color: #2c3e50; }
.kelas-badge-more:hover .kelas-tooltip, .kelas-badge-more.active .kelas-tooltip { display: block; animation: fadeInUp 0.2s ease; }
@keyframes fadeInUp { from { opacity: 0; transform: translateX(-50%) translateY(5px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } }
.kelas-tooltip .badge { margin: 2px 3px; font-size: 0.8rem; }
</style>
<div class="page-header">
<h2><i class="fas fa-clipboard-check"></i> Absensi Kegiatan</h2>
<a href="{{ route('admin.kegiatan.index') }}" class="btn-back">
<i class="fas fa-arrow-left"></i> Kembali
</a>
</div>
@if(session('success'))
<div class="alert alert-success">
<i class="fas fa-check-circle"></i> {{ session('success') }}
</div>
@endif
@if(session('error'))
<div class="alert alert-danger">
<i class="fas fa-exclamation-circle"></i> {{ session('error') }}
</div>
@endif
<!-- Filter -->
<div class="filter-box">
<form method="GET">
<div class="filter-grid">
<div class="form-group">
<label for="search">Cari Kegiatan</label>
<input type="text" name="search" id="search" class="form-control" placeholder="Nama atau ID kegiatan..." value="{{ request('search') }}">
</div>
<div class="form-group">
<label for="hari">Hari</label>
<select name="hari" id="hari" class="form-control">
<option value="">-- Semua Hari --</option>
@foreach($hariList as $h)
<option value="{{ $h }}" {{ request('hari') == $h ? 'selected' : '' }}>{{ $h }}</option>
@endforeach
</select>
</div>
<div class="form-group">
<label for="kategori_id">Kategori</label>
<select name="kategori_id" id="kategori_id" class="form-control">
<option value="">-- Semua Kategori --</option>
@foreach($kategoris as $k)
<option value="{{ $k->kategori_id }}" {{ request('kategori_id') == $k->kategori_id ? 'selected' : '' }}>
{{ $k->nama_kategori }}
</option>
@endforeach
</select>
</div>
<div class="form-group">
<label for="id_kelas">Kelas</label>
<select name="id_kelas" id="id_kelas" class="form-control">
<option value="">-- Semua Kelas --</option>
@foreach($kelasList->groupBy('kelompok.nama_kelompok') as $kelompokNama => $kelasList_group)
<optgroup label="{{ $kelompokNama }}">
@foreach($kelasList_group as $kelas)
<option value="{{ $kelas->id }}" {{ request('id_kelas') == $kelas->id ? 'selected' : '' }}>
{{ $kelas->nama_kelas }}
</option>
@endforeach
</optgroup>
@endforeach
</select>
</div>
<div style="display: flex; align-items: flex-end; gap: 10px;">
<button type="submit" class="btn-filter" style="flex: 1;">
<i class="fas fa-filter"></i> Filter
</button>
@if(request()->hasAny(['search', 'hari', 'kategori_id', 'id_kelas']))
<a href="{{ route('admin.absensi-kegiatan.index') }}" class="btn-reset">
<i class="fas fa-times"></i>
</a>
@endif
</div>
</div>
</form>
</div>
<div class="content-box">
@if($kegiatans->count() > 0)
<table class="data-table">
<thead>
<tr>
<th style="width: 50px;">No</th>
<th style="width: 100px;">Hari</th>
<th style="width: 120px;">Waktu</th>
<th>Nama Kegiatan</th>
<th style="width: 150px;">Kategori</th>
<th style="width: 150px;">Kelas</th>
<th style="width: 250px; text-align: center;">Aksi</th>
</tr>
</thead>
<tbody>
@foreach($kegiatans as $index => $kegiatan)
<tr>
<td>{{ $kegiatans->firstItem() + $index }}</td>
<td><span class="badge badge-primary">{{ $kegiatan->hari }}</span></td>
<td>{{ date('H:i', strtotime($kegiatan->waktu_mulai)) }} - {{ date('H:i', strtotime($kegiatan->waktu_selesai)) }}</td>
<td><strong>{{ $kegiatan->nama_kegiatan }}</strong></td>
<td>{{ $kegiatan->kategori->nama_kategori }}</td>
<td>
@if($kegiatan->kelasKegiatan->isEmpty())
<span class="badge badge-info">Umum</span>
@else
@php
$firstKelas = $kegiatan->kelasKegiatan->first();
$remainingCount = $kegiatan->kelasKegiatan->count() - 1;
@endphp
<span class="badge badge-secondary">{{ $firstKelas->nama_kelas }}</span>
@if($remainingCount > 0)
<span class="kelas-badge-container">
<span class="badge badge-primary kelas-badge-more" onclick="this.classList.toggle('active')">
+{{ $remainingCount }} lainnya
<div class="kelas-tooltip">
<strong style="display: block; margin-bottom: 5px; border-bottom: 1px solid rgba(255,255,255,0.3); padding-bottom: 5px;">Semua Kelas:</strong>
@foreach($kegiatan->kelasKegiatan as $kelas)
<span class="badge badge-light">{{ $kelas->nama_kelas }}</span>
@endforeach
</div>
</span>
</span>
@endif
@endif
</td>
<td class="text-center">
<a href="{{ route('admin.absensi-kegiatan.input', $kegiatan->kegiatan_id) }}" class="btn btn-sm btn-success" title="Input Absensi">
<i class="fas fa-clipboard-check"></i> Input
</a>
<a href="{{ route('admin.absensi-kegiatan.rekap', $kegiatan->kegiatan_id) }}" class="btn btn-sm btn-primary" title="Rekap Absensi">
<i class="fas fa-chart-bar"></i> Rekap
</a>
</td>
</tr>
@endforeach
</tbody>
</table>
<div style="margin-top: 20px;">
{{ $kegiatans->links() }}
</div>
@else
<div class="empty-state">
<i class="fas fa-calendar-times"></i>
<h3>Belum Ada Kegiatan</h3>
<p>Silakan tambahkan kegiatan terlebih dahulu.</p>
<a href="{{ route('admin.kegiatan.create') }}" class="btn btn-success">
<i class="fas fa-plus"></i> Tambah Kegiatan
</a>
</div>
@endif
</div>
<script>
// Close tooltip when clicking outside
document.addEventListener('click', function(event) {
if (!event.target.closest('.kelas-badge-more')) {
document.querySelectorAll('.kelas-badge-more.active').forEach(badge => {
badge.classList.remove('active');
});
}
});
// Prevent table row click when clicking badge
document.querySelectorAll('.kelas-badge-more').forEach(badge => {
badge.addEventListener('click', function(event) {
event.stopPropagation();
});
});
</script>
@endsection

View File

@ -1,3 +1,4 @@
{{-- views/admin/kegiatan/absensi/input.blade.php --}}
@extends('layouts.app')
@section('content')
@ -5,8 +6,8 @@
<h2><i class="fas fa-clipboard-check"></i> Input Absensi: {{ $kegiatan->nama_kegiatan }}</h2>
</div>
<div class="content-box" style="margin-bottom: 20px;">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px;">
<div class="content-box" style="margin-bottom: 14px;">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 11px;">
<div>
<h3 style="margin: 0; color: var(--primary-color);">{{ $kegiatan->nama_kegiatan }}</h3>
<p style="margin: 5px 0 0 0; color: var(--text-light);">
@ -19,7 +20,7 @@
<button type="button" id="btnModeManual" class="btn btn-primary" onclick="setMode('manual')">
<i class="fas fa-hand-pointer"></i> Mode Manual
</button>
<button type="button" id="btnModeRfid" class="btn btn-success" onclick="setMode('rfid')">
<button type="button" id="btnModeRfid" class="btn btn-secondary" onclick="setMode('rfid')">
<i class="fas fa-id-card"></i> Mode RFID
</button>
</div>
@ -27,7 +28,7 @@
</div>
{{-- Info Kelas Kegiatan --}}
<div class="info-box" style="margin-bottom: 20px; border-left: 4px solid var(--primary-color);">
<div class="info-box" style="margin-bottom: 14px; border-left: 4px solid var(--primary-color);">
<i class="fas fa-info-circle"></i>
@if($kegiatanInfo['is_umum'])
<strong>Kegiatan Umum</strong> - Diikuti oleh semua santri aktif ({{ $santris->count() }} santri)
@ -38,99 +39,183 @@
@endif
</div>
{{-- Sudah ada data absensi? --}}
@php
$sudahAdaData = count($absensiData) > 0;
@endphp
@if($sudahAdaData)
<div class="alert alert-info" style="margin-bottom: 14px;">
<i class="fas fa-edit"></i>
<strong>Mode Edit</strong> - Data absensi untuk tanggal ini sudah ada ({{ count($absensiData) }} santri).
Anda dapat mengubah status absensi lalu klik Simpan.
</div>
@endif
<!-- MODE MANUAL -->
<div id="modeManual" class="content-box">
<form action="{{ route('admin.absensi-kegiatan.simpan') }}" method="POST">
@csrf
<input type="hidden" name="kegiatan_id" value="{{ $kegiatan->kegiatan_id }}">
<div class="form-group">
<label for="tanggal">
<i class="fas fa-calendar form-icon"></i>
Tanggal Absensi
<div class="filter-form-inline" style="margin-bottom: 14px; gap: 12px;">
<div class="filter-form-inline" style="gap: 8px;">
<label style="font-weight: 600; white-space: nowrap; margin: 0;">
<i class="fas fa-calendar"></i> Tanggal:
</label>
<input type="date" name="tanggal" id="tanggal" class="form-control" value="{{ $tanggal }}" required>
<input type="date" name="tanggal" id="tanggal" class="form-control"
value="{{ $tanggal }}" required style="max-width: 170px;">
</div>
<div class="info-box">
<p><i class="fas fa-info-circle"></i> Pilih status absensi untuk setiap santri. Jika tidak dipilih, akan dianggap <strong>Alpa</strong>.</p>
</div>
{{-- Filter Kelas (Manual Mode) --}}
@if(!$kegiatanInfo['is_umum'] && $kegiatanInfo['jumlah_kelas'] > 1)
<div class="form-group" style="max-width: 300px;">
<label for="filterKelas">
<i class="fas fa-filter form-icon"></i>
Filter Kelas
<div class="filter-form-inline" style="gap: 8px;">
<label style="font-weight: 600; white-space: nowrap; margin: 0;">
<i class="fas fa-school"></i> Pilih Kelas:
</label>
<select id="filterKelas" class="form-control">
<option value="">Semua Kelas</option>
@foreach($kegiatan->kelasKegiatan as $kelas)
<option value="{{ $kelas->nama_kelas }}">{{ $kelas->nama_kelas }}</option>
<select id="kelasFilter" class="form-control" onchange="filterKelas(this.value)" style="max-width: 220px;">
<option value="semua">-- Tampilkan Semua Kelas --</option>
@foreach($santriGrouped as $kelasNama => $santriKelas)
<option value="{{ $kelasNama }}">{{ $kelasNama }} ({{ $santriKelas->count() }} santri)</option>
@endforeach
</select>
</div>
@endif
<table class="data-table">
<div style="margin-left: auto; display: flex; gap: 8px; flex-wrap: wrap;">
<button type="button" class="btn btn-sm btn-info" onclick="setAllStatus('Hadir')">
<i class="fas fa-check-double"></i> Semua Hadir
</button>
<button type="button" class="btn btn-sm" style="background: #FF9800; color: white;" onclick="setAllStatus('Terlambat')">
<i class="fas fa-clock"></i> Semua Terlambat
</button>
<button type="button" class="btn btn-sm btn-danger" onclick="setAllStatus('Alpa')">
<i class="fas fa-times"></i> Semua Alpa
</button>
<button type="button" class="btn btn-sm btn-secondary" onclick="clearAllStatus()">
<i class="fas fa-eraser"></i> Kosongkan
</button>
</div>
</div>
<div class="info-box" style="margin-bottom: 14px;">
<p style="margin: 0;"><i class="fas fa-info-circle"></i> Pilih kelas terlebih dahulu untuk menampilkan daftar santri. Santri tanpa pilihan status akan <strong>dilewati</strong>. Santri yang <strong>sedang pulang</strong> otomatis ditandai.</p>
</div>
{{-- Group santri by kelas --}}
@foreach($santriGrouped as $kelasNama => $santriKelas)
@php
$hadirCount = 0;
$totalKelas = $santriKelas->count();
foreach ($santriKelas as $s) {
$st = $absensiData[$s->id_santri] ?? null;
if ($st === 'Hadir') $hadirCount++;
}
$sudahInputKelas = false;
foreach ($santriKelas as $s) {
if (isset($absensiData[$s->id_santri])) { $sudahInputKelas = true; break; }
}
@endphp
<div class="kelas-group" data-kelas="{{ $kelasNama }}" style="margin-bottom: 20px; display: none;">
<div style="background: var(--primary-light); padding: 10px 14px; border-radius: 8px 8px 0 0; display: flex; justify-content: space-between; align-items: center;">
<h4 style="margin: 0; color: var(--primary-color); font-size: 0.95rem;">
<i class="fas fa-users"></i> {{ $kelasNama }}
</h4>
<div style="display: flex; gap: 8px; align-items: center;">
@if($sudahInputKelas)
<span class="badge badge-info"><i class="fas fa-edit"></i> {{ $hadirCount }}/{{ $totalKelas }} hadir</span>
@endif
<span class="badge badge-primary">{{ $totalKelas }} santri</span>
</div>
</div>
<table class="data-table" style="margin-top: 0; border-top-left-radius: 0; border-top-right-radius: 0;">
<thead>
<tr>
<th style="width: 50px;">No</th>
<th style="width: 100px;">ID Santri</th>
<th>Nama Santri</th>
<th style="width: 100px;">Kelas</th>
<th style="width: 300px; text-align: center;">Status</th>
<th style="width: 420px; text-align: center;">Status</th>
</tr>
</thead>
<tbody>
@foreach($santris as $index => $santri)
<tr>
<td>{{ $index + 1 }}</td>
<td><strong>{{ $santri->id_santri }}</strong></td>
<td>{{ $santri->nama_lengkap }}</td>
<td>
@foreach($santriKelas as $santri)
@php
$kelasName = $santri->kelas_name ?? $santri->kelas ?? '-';
$isPulang = in_array($santri->id_santri, $santriSedangPulang ?? []);
$currentStatus = $absensiData[$santri->id_santri] ?? ($isPulang ? 'Pulang' : '');
@endphp
<span class="badge badge-secondary">{{ $kelasName }}</span>
<tr @if($isPulang) style="background: #FFF8E1; opacity: 0.85;" @endif>
<td>{{ $loop->iteration }}</td>
<td><strong>{{ $santri->id_santri }}</strong></td>
<td>
{{ $santri->nama_lengkap }}
@if($isPulang)
<span class="badge" style="background: #FFF3E0; color: #E65100; font-size: 0.75rem; margin-left: 6px;">
<i class="fas fa-home"></i> Sedang Pulang
</span>
@endif
@if(isset($absensiData[$santri->id_santri]))
<span class="badge badge-secondary" style="font-size: 0.7rem; margin-left: 4px;">
<i class="fas fa-edit"></i> Sudah input
</span>
@endif
</td>
<td class="text-center">
@php
$currentStatus = $absensiData[$santri->id_santri] ?? 'Alpa';
@endphp
<div style="display: flex; gap: 8px; justify-content: center;">
@if($isPulang)
<input type="hidden" name="absensi[{{ $santri->id_santri }}]" value="Pulang" class="absensi-input">
<span class="badge" style="background: #FFF3E0; color: #E65100; padding: 6px 14px; font-size: 0.85rem;">
<i class="fas fa-home"></i> Pulang
</span>
@else
<div style="display: flex; gap: 5px; justify-content: center; flex-wrap: wrap;">
<label style="margin: 0; cursor: pointer;">
<input type="radio" name="absensi[{{ $santri->id_santri }}]" value="Hadir"
{{ $currentStatus == 'Hadir' ? 'checked' : '' }} required>
{{ $currentStatus == 'Hadir' ? 'checked' : '' }} class="absensi-radio absensi-input">
<span class="badge badge-success">Hadir</span>
</label>
<label style="margin: 0; cursor: pointer;">
<input type="radio" name="absensi[{{ $santri->id_santri }}]" value="Terlambat"
{{ $currentStatus == 'Terlambat' ? 'checked' : '' }} class="absensi-radio absensi-input">
<span class="badge" style="background: #FF9800; color: white;">Terlambat</span>
</label>
<label style="margin: 0; cursor: pointer;">
<input type="radio" name="absensi[{{ $santri->id_santri }}]" value="Izin"
{{ $currentStatus == 'Izin' ? 'checked' : '' }}>
{{ $currentStatus == 'Izin' ? 'checked' : '' }} class="absensi-radio absensi-input">
<span class="badge badge-warning">Izin</span>
</label>
<label style="margin: 0; cursor: pointer;">
<input type="radio" name="absensi[{{ $santri->id_santri }}]" value="Sakit"
{{ $currentStatus == 'Sakit' ? 'checked' : '' }}>
{{ $currentStatus == 'Sakit' ? 'checked' : '' }} class="absensi-radio absensi-input">
<span class="badge badge-info">Sakit</span>
</label>
<label style="margin: 0; cursor: pointer;">
<input type="radio" name="absensi[{{ $santri->id_santri }}]" value="Alpa"
{{ $currentStatus == 'Alpa' ? 'checked' : '' }}>
{{ $currentStatus == 'Alpa' ? 'checked' : '' }} class="absensi-radio absensi-input">
<span class="badge badge-danger">Alpa</span>
</label>
@if($currentStatus)
<label style="margin: 0; cursor: pointer;" title="Hapus pilihan">
<button type="button" class="btn btn-sm" style="padding: 2px 8px; font-size: 0.75rem; background: #f1f1f1;" onclick="clearRadio('{{ $santri->id_santri }}')">
<i class="fas fa-undo"></i>
</button>
</label>
@endif
</div>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endforeach
<div class="btn-group" style="margin-top: 20px;">
<div id="noKelasSelected" class="empty-state" style="padding: 40px 20px;">
<i class="fas fa-hand-pointer"></i>
<h3>Pilih Kelas Terlebih Dahulu</h3>
<p>Silakan pilih kelas pada dropdown di atas untuk menampilkan daftar santri yang akan diabsen.</p>
</div>
<div class="btn-group" style="margin-top: 14px;">
<button type="submit" class="btn btn-success">
<i class="fas fa-save"></i> Simpan Absensi
<i class="fas fa-save"></i> {{ $sudahAdaData ? 'Update Absensi' : 'Simpan Absensi' }}
</button>
<a href="{{ route('admin.absensi-kegiatan.index') }}" class="btn btn-secondary">
<a href="{{ route('admin.kegiatan.index') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Kembali
</a>
</div>
@ -151,7 +236,7 @@
<p><i class="fas fa-id-card"></i> Tempelkan kartu RFID santri ke reader. Absensi akan otomatis tersimpan sebagai <strong>Hadir</strong>.</p>
</div>
<div style="background: linear-gradient(135deg, var(--primary-light) 0%, #D4F1E3 100%); padding: 30px; border-radius: var(--border-radius); text-align: center; margin-bottom: 20px;">
<div style="background: linear-gradient(135deg, var(--primary-light) 0%, #D4F1E3 100%); padding: 22px; border-radius: var(--border-radius); text-align: center; margin-bottom: 14px;">
<div id="rfidStatus" style="font-size: 1.5rem; font-weight: 600; color: var(--primary-color); margin-bottom: 15px;">
<i class="fas fa-wifi"></i> Siap Scan RFID
</div>
@ -167,47 +252,116 @@
</div>
</div>
<div class="btn-group" style="margin-top: 20px;">
<div class="btn-group" style="margin-top: 14px;">
<button type="button" class="btn btn-warning" onclick="clearLog()">
<i class="fas fa-trash"></i> Bersihkan Log
</button>
<a href="{{ route('admin.absensi-kegiatan.index') }}" class="btn btn-secondary">
<a href="{{ route('admin.kegiatan.index') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Kembali
</a>
</div>
</div>
<script>
let currentMode = 'manual';
const kegiatanId = '{{ $kegiatan->kegiatan_id }}';
// -- Kelas Filter --
function filterKelas(value) {
var groups = document.querySelectorAll('.kelas-group');
var emptyMsg = document.getElementById('noKelasSelected');
if (value === 'semua') {
for (var i = 0; i < groups.length; i++) {
groups[i].style.display = 'block';
toggleGroupInputs(groups[i], true);
}
emptyMsg.style.display = 'none';
} else {
var found = false;
for (var i = 0; i < groups.length; i++) {
if (groups[i].getAttribute('data-kelas') === value) {
groups[i].style.display = 'block';
toggleGroupInputs(groups[i], true);
found = true;
} else {
groups[i].style.display = 'none';
toggleGroupInputs(groups[i], false);
}
}
emptyMsg.style.display = found ? 'none' : 'block';
}
}
// Enable/disable all inputs in a kelas group so hidden groups don't submit
function toggleGroupInputs(group, enabled) {
var inputs = group.querySelectorAll('.absensi-input');
for (var i = 0; i < inputs.length; i++) {
inputs[i].disabled = !enabled;
}
}
// -- Set All Status (for visible groups only) --
function setAllStatus(status) {
var groups = document.querySelectorAll('.kelas-group');
for (var i = 0; i < groups.length; i++) {
if (groups[i].style.display !== 'none') {
var radios = groups[i].querySelectorAll('input.absensi-radio[value="' + status + '"]');
for (var j = 0; j < radios.length; j++) {
radios[j].checked = true;
}
}
}
}
// -- Clear radio selection for a specific santri --
function clearRadio(santriId) {
var radios = document.querySelectorAll('input[name="absensi[' + santriId + ']"]');
for (var i = 0; i < radios.length; i++) {
radios[i].checked = false;
}
}
// -- Clear all selections in visible groups --
function clearAllStatus() {
var groups = document.querySelectorAll('.kelas-group');
for (var i = 0; i < groups.length; i++) {
if (groups[i].style.display !== 'none') {
var radios = groups[i].querySelectorAll('input.absensi-radio');
for (var j = 0; j < radios.length; j++) {
radios[j].checked = false;
}
}
}
}
// -- Mode Switch --
var currentMode = 'manual';
var kegiatanId = '{{ $kegiatan->kegiatan_id }}';
function setMode(mode) {
currentMode = mode;
var modeManualEl = document.getElementById('modeManual');
var modeRfidEl = document.getElementById('modeRfid');
var btnManual = document.getElementById('btnModeManual');
var btnRfid = document.getElementById('btnModeRfid');
if (mode === 'manual') {
document.getElementById('modeManual').style.display = 'block';
document.getElementById('modeRfid').style.display = 'none';
document.getElementById('btnModeManual').classList.add('btn-primary');
document.getElementById('btnModeManual').classList.remove('btn-secondary');
document.getElementById('btnModeRfid').classList.remove('btn-success');
document.getElementById('btnModeRfid').classList.add('btn-secondary');
modeManualEl.style.display = 'block';
modeRfidEl.style.display = 'none';
btnManual.className = 'btn btn-primary';
btnRfid.className = 'btn btn-secondary';
} else {
document.getElementById('modeManual').style.display = 'none';
document.getElementById('modeRfid').style.display = 'block';
document.getElementById('btnModeManual').classList.remove('btn-primary');
document.getElementById('btnModeManual').classList.add('btn-secondary');
document.getElementById('btnModeRfid').classList.add('btn-success');
document.getElementById('btnModeRfid').classList.remove('btn-secondary');
modeManualEl.style.display = 'none';
modeRfidEl.style.display = 'block';
btnManual.className = 'btn btn-secondary';
btnRfid.className = 'btn btn-success';
document.getElementById('rfidInput').focus();
}
}
// RFID Scanner Handler
// -- RFID Scanner --
document.getElementById('rfidInput').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
const rfidUid = this.value.trim();
var rfidUid = this.value.trim();
if (rfidUid) {
scanRfid(rfidUid);
this.value = '';
@ -216,8 +370,8 @@ function setMode(mode) {
});
function scanRfid(rfidUid) {
const tanggal = document.getElementById('tanggalRfid').value;
const statusEl = document.getElementById('rfidStatus');
var tanggal = document.getElementById('tanggalRfid').value;
var statusEl = document.getElementById('rfidStatus');
statusEl.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Memproses...';
statusEl.style.color = 'var(--warning-color)';
@ -234,30 +388,28 @@ function scanRfid(rfidUid) {
tanggal: tanggal
})
})
.then(response => response.json())
.then(data => {
.then(function(response) { return response.json(); })
.then(function(data) {
if (data.success) {
statusEl.innerHTML = '<i class="fas fa-check-circle"></i> ' + data.message;
statusEl.style.color = 'var(--success-color)';
addLogEntry(data.data, 'success');
playSound('success');
} else {
statusEl.innerHTML = '<i class="fas fa-exclamation-circle"></i> ' + data.message;
statusEl.style.color = 'var(--danger-color)';
playSound('error');
}
setTimeout(() => {
setTimeout(function() {
statusEl.innerHTML = '<i class="fas fa-wifi"></i> Siap Scan RFID';
statusEl.style.color = 'var(--primary-color)';
}, 2000);
})
.catch(error => {
.catch(function(error) {
statusEl.innerHTML = '<i class="fas fa-times-circle"></i> Koneksi error';
statusEl.style.color = 'var(--danger-color)';
console.error('Error:', error);
setTimeout(() => {
setTimeout(function() {
statusEl.innerHTML = '<i class="fas fa-wifi"></i> Siap Scan RFID';
statusEl.style.color = 'var(--primary-color)';
}, 2000);
@ -265,28 +417,21 @@ function scanRfid(rfidUid) {
}
function addLogEntry(data, type) {
const logContent = document.getElementById('rfidLogContent');
var logContent = document.getElementById('rfidLogContent');
if (logContent.querySelector('p')) {
logContent.innerHTML = '';
}
const entry = document.createElement('div');
var entry = document.createElement('div');
entry.style.cssText = 'padding: 12px; margin-bottom: 10px; border-radius: 8px; background: ' +
(type === 'success' ? 'linear-gradient(135deg, #E8F7F2 0%, #D4F1E3 100%)' : 'linear-gradient(135deg, #FFE8EA 0%, #FFD5D8 100%)') +
'; border-left: 4px solid ' + (type === 'success' ? 'var(--success-color)' : 'var(--danger-color)');
entry.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<strong>${data.nama}</strong> (${data.id_santri})
<div style="font-size: 0.85rem; color: var(--text-light); margin-top: 3px;">
Kelas: ${data.kelas} | Waktu: ${data.waktu}
</div>
</div>
<span class="badge badge-success"><i class="fas fa-check"></i> Hadir</span>
</div>
`;
entry.innerHTML = '<div style="display: flex; justify-content: space-between; align-items: center;">' +
'<div><strong>' + data.nama + '</strong> (' + data.id_santri + ')' +
'<div style="font-size: 0.85rem; color: var(--text-light); margin-top: 3px;">Kelas: ' + data.kelas + ' | Waktu: ' + data.waktu + '</div></div>' +
'<span class="badge badge-success"><i class="fas fa-check"></i> Hadir</span></div>';
logContent.insertBefore(entry, logContent.firstChild);
}
@ -297,38 +442,14 @@ function clearLog() {
}
}
function playSound(type) {
// Bisa ditambahkan audio feedback
const audio = new Audio(type === 'success' ? '/sounds/success.mp3' : '/sounds/error.mp3');
audio.play().catch(() => {}); // Ignore errors
}
// Auto-focus kembali ke input RFID jika kehilangan fokus
setInterval(() => {
if (currentMode === 'rfid' && document.activeElement !== document.getElementById('rfidInput')) {
document.getElementById('rfidInput').focus();
// -- Auto-focus RFID input --
setInterval(function() {
if (currentMode === 'rfid') {
var rfidInput = document.getElementById('rfidInput');
if (document.activeElement !== rfidInput) {
rfidInput.focus();
}
}
}, 1000);
// Filter Kelas functionality (Manual Mode)
const filterKelasEl = document.getElementById('filterKelas');
if (filterKelasEl) {
filterKelasEl.addEventListener('change', function() {
const selectedKelas = this.value.toLowerCase();
const rows = document.querySelectorAll('#modeManual tbody tr');
rows.forEach(row => {
const kelasCell = row.querySelector('td:nth-child(4)'); // Kolom kelas
if (kelasCell) {
const kelasText = kelasCell.textContent.toLowerCase();
if (!selectedKelas || kelasText.includes(selectedKelas)) {
row.style.display = '';
} else {
row.style.display = 'none';
}
}
});
});
}
</script>
@endsection

View File

@ -1,3 +1,4 @@
{{-- views/admin/kegiatan/absensi/rekap.blade.php --}}
@extends('layouts.app')
@section('content')
@ -11,6 +12,11 @@
<div class="card-value">{{ $stats['Hadir'] ?? 0 }}</div>
<i class="fas fa-check-circle card-icon"></i>
</div>
<div class="card" style="border-top: 3px solid #FF9800;">
<h3>Terlambat</h3>
<div class="card-value">{{ $stats['Terlambat'] ?? 0 }}</div>
<i class="fas fa-clock card-icon" style="color: #FF9800;"></i>
</div>
<div class="card card-warning">
<h3>Izin</h3>
<div class="card-value">{{ $stats['Izin'] ?? 0 }}</div>
@ -29,28 +35,46 @@
</div>
<div class="content-box">
<div style="margin-bottom: 20px;">
<div style="margin-bottom: 14px;">
<form method="GET" class="filter-form-inline">
<input type="date" name="tanggal" class="form-control" value="{{ request('tanggal') }}">
<input type="month" name="bulan" class="form-control" value="{{ request('bulan') }}" placeholder="Pilih Bulan">
<select name="kelas_id" class="form-control" style="max-width: 200px;">
<option value="">Semua Kelas</option>
@foreach($kelasFilterList as $kelas)
<option value="{{ $kelas->id }}" {{ request('kelas_id') == $kelas->id ? 'selected' : '' }}>
{{ $kelas->nama_kelas }}
</option>
@endforeach
</select>
<button type="submit" class="btn btn-primary">
<i class="fas fa-filter"></i> Filter
</button>
@if(request()->hasAny(['tanggal', 'bulan']))
@if(request()->hasAny(['tanggal', 'bulan', 'kelas_id']))
<a href="{{ route('admin.absensi-kegiatan.rekap', $kegiatan->kegiatan_id) }}" class="btn btn-secondary">
<i class="fas fa-times"></i> Reset
</a>
@endif
<a href="{{ route('admin.absensi-kegiatan.index') }}" class="btn btn-secondary" style="margin-left: auto;">
<a href="{{ route('admin.kegiatan.index') }}" class="btn btn-secondary" style="margin-left: auto;">
<i class="fas fa-arrow-left"></i> Kembali
</a>
</form>
</div>
@if($absensis->count() > 0)
@foreach($absensiPerKelas as $namaKelas => $kelasAbsensis)
<div class="content-box" style="margin-bottom: 18px;">
<h4 style="margin: 0 0 12px; color: var(--primary-color);">
<i class="fas fa-school"></i> Kelas: {{ $namaKelas }}
<span class="badge badge-secondary" style="font-size: 0.8rem; margin-left: 6px;">
{{ $kelasAbsensis->count() }} data
</span>
</h4>
<table class="data-table">
<thead>
<tr>
@ -58,25 +82,19 @@
<th style="width: 100px;">Tanggal</th>
<th style="width: 100px;">ID Santri</th>
<th>Nama Santri</th>
<th style="width: 80px;">Kelas</th>
<th style="width: 120px; text-align: center;">Status</th>
<th style="width: 100px;">Metode</th>
<th style="width: 100px;">Waktu</th>
<th style="width: 120px; text-align: center;">Aksi</th>
</tr>
</thead>
<tbody>
@foreach($absensis as $index => $absensi)
@foreach($kelasAbsensis as $index => $absensi)
<tr>
<td>{{ $absensis->firstItem() + $index }}</td>
<td>{{ $index + 1 }}</td>
<td>{{ $absensi->tanggal->format('d/m/Y') }}</td>
<td><strong>{{ $absensi->id_santri }}</strong></td>
<td>{{ $absensi->santri->nama_lengkap }}</td>
<td>
@php
$kelasName = $absensi->santri->kelas_name ?? $absensi->santri->kelas ?? '-';
@endphp
<span class="badge badge-secondary">{{ $kelasName }}</span>
</td>
<td class="text-center">{!! $absensi->status_badge !!}</td>
<td>
@if($absensi->metode_absen == 'RFID')
@ -86,14 +104,26 @@
@endif
</td>
<td>{{ $absensi->waktu_absen ? date('H:i', strtotime($absensi->waktu_absen)) : '-' }}</td>
<td class="text-center">
<div style="display: flex; gap: 4px; justify-content: center;">
<a href="{{ route('admin.absensi-kegiatan.edit', $absensi->id) }}" class="btn btn-sm btn-warning" title="Edit">
<i class="fas fa-edit"></i>
</a>
<form action="{{ route('admin.absensi-kegiatan.hapus', $absensi->id) }}" method="POST" style="display: inline;" onsubmit="return confirm('Yakin hapus absensi {{ $absensi->santri->nama_lengkap }}?');">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-sm btn-danger" title="Hapus">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
<div style="margin-top: 20px;">
{{ $absensis->links() }}
</div>
@endforeach
@else
<div class="empty-state">
<i class="fas fa-clipboard"></i>

View File

@ -1,3 +1,4 @@
{{-- views/admin/kegiatan/data/create.blade.php --}}
@extends('layouts.app')
@section('content')
@ -10,10 +11,7 @@
@csrf
<div class="form-group">
<label for="kegiatan_id">
<i class="fas fa-hashtag form-icon"></i>
ID Kegiatan (Otomatis)
</label>
<label><i class="fas fa-hashtag form-icon"></i> ID Kegiatan (Otomatis)</label>
<input type="text" class="form-control" value="{{ $nextId }}" disabled>
<small class="form-text">ID akan dibuat otomatis saat disimpan</small>
</div>
@ -31,9 +29,7 @@
</option>
@endforeach
</select>
@error('kategori_id')
<span class="invalid-feedback">{{ $message }}</span>
@enderror
@error('kategori_id') <span class="invalid-feedback">{{ $message }}</span> @enderror
</div>
<div class="form-group">
@ -41,96 +37,75 @@
<i class="fas fa-calendar-check form-icon"></i>
Nama Kegiatan <span style="color: red;">*</span>
</label>
<input type="text"
name="nama_kegiatan"
id="nama_kegiatan"
<input type="text" name="nama_kegiatan" id="nama_kegiatan"
class="form-control @error('nama_kegiatan') is-invalid @enderror"
value="{{ old('nama_kegiatan') }}"
placeholder="Contoh: Kajian Tafsir Al-Quran"
required>
@error('nama_kegiatan')
<span class="invalid-feedback">{{ $message }}</span>
@enderror
value="{{ old('nama_kegiatan') }}" placeholder="Nama Kegiatan" required>
@error('nama_kegiatan') <span class="invalid-feedback">{{ $message }}</span> @enderror
</div>
{{-- Hari pill/tag horizontal --}}
<div class="form-group">
<label for="hari">
<label>
<i class="fas fa-calendar-day form-icon"></i>
Hari <span style="color: red;">*</span>
Hari Kegiatan <span style="color: red;">*</span>
</label>
<select name="hari" id="hari" class="form-control @error('hari') is-invalid @enderror" required>
<option value="">-- Pilih Hari --</option>
<small class="text-muted d-block mb-2" style="margin-top: -6px;">
<i class="fas fa-info-circle"></i>
Pilih satu atau lebih hari. Kegiatan dibuat otomatis untuk setiap hari yang dipilih.
</small>
<div class="hari-pills-wrap">
@foreach($hariList as $h)
<option value="{{ $h }}" {{ old('hari') == $h ? 'selected' : '' }}>{{ $h }}</option>
@php $checked = in_array($h, old('hari', [])); @endphp
<label class="hari-pill {{ $checked ? 'active' : '' }}" for="hari{{ $loop->index }}">
<input type="checkbox" name="hari[]" value="{{ $h }}"
id="hari{{ $loop->index }}" {{ $checked ? 'checked' : '' }}>
{{ $h }}
</label>
@endforeach
</select>
@error('hari')
<span class="invalid-feedback">{{ $message }}</span>
@enderror
</div>
@error('hari') <span class="invalid-feedback d-block">{{ $message }}</span> @enderror
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 11px;">
<div class="form-group">
<label for="waktu_mulai">
<i class="fas fa-clock form-icon"></i>
Waktu Mulai <span style="color: red;">*</span>
<i class="fas fa-clock form-icon"></i> Waktu Mulai <span style="color: red;">*</span>
</label>
<input type="time"
name="waktu_mulai"
id="waktu_mulai"
<input type="time" name="waktu_mulai" id="waktu_mulai"
class="form-control @error('waktu_mulai') is-invalid @enderror"
value="{{ old('waktu_mulai') }}"
required>
@error('waktu_mulai')
<span class="invalid-feedback">{{ $message }}</span>
@enderror
value="{{ old('waktu_mulai') }}" required>
@error('waktu_mulai') <span class="invalid-feedback">{{ $message }}</span> @enderror
</div>
<div class="form-group">
<label for="waktu_selesai">
<i class="fas fa-clock form-icon"></i>
Waktu Selesai <span style="color: red;">*</span>
<i class="fas fa-clock form-icon"></i> Waktu Selesai <span style="color: red;">*</span>
</label>
<input type="time"
name="waktu_selesai"
id="waktu_selesai"
<input type="time" name="waktu_selesai" id="waktu_selesai"
class="form-control @error('waktu_selesai') is-invalid @enderror"
value="{{ old('waktu_selesai') }}"
required>
@error('waktu_selesai')
<span class="invalid-feedback">{{ $message }}</span>
@enderror
value="{{ old('waktu_selesai') }}" required>
@error('waktu_selesai') <span class="invalid-feedback">{{ $message }}</span> @enderror
</div>
</div>
<div class="form-group">
<label for="materi">
<i class="fas fa-book form-icon"></i>
Materi/Topik
<i class="fas fa-book form-icon"></i> Materi/Topik
</label>
<input type="text"
name="materi"
id="materi"
<input type="text" name="materi" id="materi"
class="form-control @error('materi') is-invalid @enderror"
value="{{ old('materi') }}"
placeholder="Contoh: Surat Al-Baqarah Ayat 1-10">
@error('materi')
<span class="invalid-feedback">{{ $message }}</span>
@enderror
value="{{ old('materi') }}" placeholder="Contoh:Bacaan">
@error('materi') <span class="invalid-feedback">{{ $message }}</span> @enderror
</div>
{{-- Pilih Kelas untuk Kegiatan --}}
<div class="form-group">
<label class="form-label">
<i class="fas fa-layer-group form-icon"></i>
Kelas yang Mengikuti Kegiatan
<label>
<i class="fas fa-layer-group form-icon"></i> Kelas yang Mengikuti Kegiatan
</label>
<small class="text-muted d-block mb-3" style="margin-top: -8px;">
<i class="fas fa-info-circle"></i>
Kosongkan jika kegiatan untuk semua santri (umum).
Pilih satu atau lebih kelas yang akan mengikuti kegiatan ini.
</small>
@foreach($kelompokKelas as $kelompok)
<div class="card mb-2" style="border: 1px solid #E8F7F2;">
<div class="card-header py-2" style="background: linear-gradient(135deg, #E8F7F2 0%, #D4F1E3 100%);">
@ -143,10 +118,8 @@ class="form-control @error('materi') is-invalid @enderror"
@forelse($kelompok->kelas as $kelas)
<div class="col-md-3 col-sm-4 col-6 mb-2">
<div class="form-check">
<input class="form-check-input"
type="checkbox"
name="kelas_ids[]"
value="{{ $kelas->id }}"
<input class="form-check-input" type="checkbox"
name="kelas_ids[]" value="{{ $kelas->id }}"
id="kelas{{ $kelas->id }}"
{{ in_array($kelas->id, old('kelas_ids', [])) ? 'checked' : '' }}>
<label class="form-check-label" for="kelas{{ $kelas->id }}">
@ -155,9 +128,7 @@ class="form-control @error('materi') is-invalid @enderror"
</div>
</div>
@empty
<div class="col-12">
<small class="text-muted">Tidak ada kelas aktif</small>
</div>
<div class="col-12"><small class="text-muted">Tidak ada kelas aktif</small></div>
@endforelse
</div>
</div>
@ -167,27 +138,51 @@ class="form-control @error('materi') is-invalid @enderror"
<div class="form-group">
<label for="keterangan">
<i class="fas fa-align-left form-icon"></i>
Keterangan
<i class="fas fa-align-left form-icon"></i> Keterangan
</label>
<textarea name="keterangan"
id="keterangan"
<textarea name="keterangan" id="keterangan"
class="form-control @error('keterangan') is-invalid @enderror"
rows="4"
placeholder="Catatan tambahan tentang kegiatan ini...">{{ old('keterangan') }}</textarea>
@error('keterangan')
<span class="invalid-feedback">{{ $message }}</span>
@enderror
rows="4" placeholder="Catatan tambahan...">{{ old('keterangan') }}</textarea>
@error('keterangan') <span class="invalid-feedback">{{ $message }}</span> @enderror
</div>
<div class="btn-group">
<button type="submit" class="btn btn-success">
<i class="fas fa-save"></i> Simpan
</button>
<a href="{{ route('admin.kegiatan.index') }}" class="btn btn-secondary">
<a href="{{ route('admin.kegiatan.jadwal') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Kembali
</a>
</div>
</form>
</div>
<style>
.hari-pills-wrap {
display: flex; flex-wrap: wrap; gap: 8px;
padding: 12px 14px; border: 1px solid #E8F7F2;
border-radius: 10px; background: #FAFFFE;
}
.hari-pill {
display: inline-flex; align-items: center;
padding: 6px 16px; border-radius: 20px; cursor: pointer;
border: 1.5px solid #e2e8f0; background: #fff;
font-size: 0.85rem; font-weight: 500; color: #374151;
transition: all 0.18s; user-select: none; margin: 0;
}
.hari-pill input[type="checkbox"] { display: none; }
.hari-pill:hover { border-color: var(--primary-color); background: #f0fdf4; }
.hari-pill.active {
border-color: var(--primary-color); background: #ECFDF5;
color: var(--primary-dark); font-weight: 600;
}
</style>
<script>
document.querySelectorAll('.hari-pill input[type="checkbox"]').forEach(function(cb) {
cb.addEventListener('change', function() {
var label = this.closest('.hari-pill');
label.classList.toggle('active', this.checked);
});
});
</script>
@endsection

Some files were not shown because too many files have changed in this diff Show More