This commit is contained in:
HelgaFaisa 2026-03-04 23:53:33 +07:00
parent 7ce8586b8c
commit 46ded0ee5c
221 changed files with 14902 additions and 17891 deletions

View File

@ -1,146 +0,0 @@
# 🔧 PANDUAN LENGKAP - Cara Test & Fix
## ⚠️ PENTING: Semua File SUDAH DIUPDATE!
Semua perubahan sudah tersimpan di:
- ✅ routes/web.php
- ✅ UserController.php
- ✅ wali_accounts.blade.php
- ✅ santri_accounts.blade.php
- ✅ app_config.dart
**TAPI** mungkin browser/Flutter masih pakai file lama (cached).
---
## 🚀 LANGKAH TESTING (IKUTI URUTAN INI!)
### 1⃣ Test dengan Debug Tool
Buka browser dan akses:
```
http://localhost/TugasAkhir/debug_comprehensive.php
```
Tool ini akan cek:
- ✅ Apakah file sudah ter-update
- ✅ Apakah route sudah benar
- ✅ Apakah API berfungsi
- ✅ Apakah Flutter config sudah benar
### 2⃣ Clear Browser Cache
**PENTING!** Tekan:
- **Windows:** `Ctrl + Shift + R` atau `Ctrl + F5`
- **Mac:** `Cmd + Shift + R`
Atau buka Incognito/Private Window.
### 3⃣ Login ke Admin Panel
```
http://localhost/TugasAkhir/sim-pkpps/public/admin/login
```
Login dengan akun admin Anda.
### 4⃣ Test Delete & Reset di Web
```
http://localhost/TugasAkhir/sim-pkpps/public/admin/users/wali
```
Coba:
- Klik tombol **Hapus** → konfirmasi → lihat apakah akun terhapus
- Klik tombol **Reset** → konfirmasi → lihat pesan sukses
**Jika MASIH BELUM BISA:**
1. Tekan F12 (Developer Tools)
2. Lihat tab **Console** → ada error?
3. Lihat tab **Network** → klik tombol delete → lihat request yang dikirim
4. Screenshot errornya dan kirim ke saya
### 5⃣ Test Login Mobile
#### A. Hot Restart Flutter (BUKAN Hot Reload!)
```bash
cd c:\xampp\htdocs\TugasAkhir\sim_mobile
flutter clean
flutter run
```
Atau di VS Code: klik icon 🔄 dengan tooltip "Hot Restart"
#### B. Test Login
Gunakan credentials ini:
| Username | Password |
|----------|----------|
| Aydin Fauzan | s002 |
| HELGA FAISA_1 | s001 |
| Mifta Okta Yanti | s003 |
**PENTING:**
- Username HARUS persis sama (huruf besar/kecil)
- Password adalah NIS (lowercase untuk s001-s003)
#### C. Jika Masih Gagal
1. Cek log Flutter di terminal
2. Cek apakah muncul error "Connection refused"
3. Pastikan XAMPP Apache sudah running
4. Cek IP dengan: `ipconfig` (kalau pakai real device)
---
## 🐛 DEBUG TAMBAHAN
### Jika Delete Masih Error:
Jalankan command ini:
```bash
cd c:\xampp\htdocs\TugasAkhir\sim-pkpps
php artisan route:clear
php artisan config:clear
php artisan view:clear
php artisan cache:clear
```
### Jika Login Mobile Masih Gagal:
Test API manual:
```bash
# Di PowerShell
$body = '{"id_santri":"Aydin Fauzan","password":"s002"}'
Invoke-RestMethod -Uri "http://localhost/TugasAkhir/sim-pkpps/public/api/v1/login" -Method POST -ContentType "application/json" -Body $body
```
Jika ini berhasil, berarti API OK, masalahnya di Flutter config.
---
## 📞 Masih Belum Bisa?
Kirim screenshot:
1. Error di browser (F12 → Console)
2. Error di Flutter terminal
3. Hasil dari debug_comprehensive.php
Atau kirim:
- URL yang Anda buka
- Tombol apa yang diklik
- Error message yang muncul
---
## ✅ Expected Results
### Delete:
- Klik Hapus → Dialog konfirmasi → Klik OK → Akun hilang dari list
- Muncul pesan hijau: "Akun wali [nama] berhasil dihapus"
### Reset Password:
- Klik Reset → Dialog konfirmasi → Klik OK
- Muncul pesan hijau: "Password akun [nama] berhasil direset ke NIS: [nis]"
### Login Mobile:
- Input username & password → Klik Login
- Loading sebentar → Masuk ke Dashboard
- Menu Profil menampilkan data santri
---
**Semua code sudah benar! Tinggal clear cache & test!** 🚀

View File

@ -1,341 +0,0 @@
# 📰 DOKUMENTASI FITUR BERITA - 3 KATEGORI TARGET
## 🎯 Cara Kerja Fitur Berita
Sistem berita memiliki **3 kategori target** yang menentukan siapa yang bisa melihat berita:
### 1**SEMUA SANTRI** (`target_berita = 'semua'`)
- **Siapa yang bisa lihat?** Semua santri yang login ke mobile app
- **Kapan digunakan?** Untuk pengumuman umum, berita penting untuk semua santri
- **Contoh:** Pengumuman libur, jadwal ujian, informasi umum pondok
### 2**KELAS TERTENTU** (`target_berita = 'kelas_tertentu'`)
- **Siapa yang bisa lihat?** Hanya santri dari kelas yang dipilih
- **Field yang digunakan:** `target_kelas` (JSON array, contoh: `["PB", "Lambatan"]`)
- **Kapan digunakan?** Untuk pengumuman khusus satu atau beberapa kelas
- **Contoh:** Jadwal kegiatan kelas PB, tugas untuk kelas Cepatan
### 3**SANTRI TERTENTU** (`target_berita = 'santri_tertentu'`)
- **Siapa yang bisa lihat?** Hanya santri yang dipilih secara spesifik
- **Relasi:** Menggunakan pivot table `berita_santri`
- **Fitur tambahan:** Bisa tracking status "sudah dibaca" atau "belum dibaca"
- **Kapan digunakan?** Untuk pesan personal, reminder individual
- **Contoh:** Panggilan khusus, informasi pembayaran tertunggak, pemberitahuan pribadi
---
## 🔧 Struktur Database
### Table: `berita`
```sql
CREATE TABLE berita (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
id_berita VARCHAR(10) UNIQUE, -- B001, B002, ...
judul VARCHAR(255) NOT NULL,
konten TEXT NOT NULL,
penulis VARCHAR(255),
gambar VARCHAR(255), -- Path ke storage
status ENUM('draft', 'published'), -- Draft tidak muncul di mobile
target_berita ENUM('semua', 'kelas_tertentu', 'santri_tertentu'),
target_kelas JSON, -- ["PB", "Lambatan", "Cepatan"]
created_at TIMESTAMP,
updated_at TIMESTAMP
);
```
### Table: `berita_santri` (Pivot - untuk santri_tertentu)
```sql
CREATE TABLE berita_santri (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
id_berita VARCHAR(10), -- FK ke berita.id_berita
id_santri VARCHAR(10), -- FK ke santris.id_santri
sudah_dibaca BOOLEAN DEFAULT FALSE,
tanggal_baca TIMESTAMP NULL,
created_at TIMESTAMP,
updated_at TIMESTAMP,
FOREIGN KEY (id_berita) REFERENCES berita(id_berita),
FOREIGN KEY (id_santri) REFERENCES santris(id_santri)
);
```
---
## 🚀 Alur Kerja Backend API
### Endpoint: `GET /api/v1/berita`
**Filter Logic (di `ApiBeritaController.php`):**
```php
$query = Berita::where('status', 'published')
->where(function($q) use ($idSantri, $santri) {
// 1. Berita untuk SEMUA
$q->where('target_berita', 'semua')
// 2. Berita untuk KELAS TERTENTU (cek kelas santri)
->orWhere(function($subQ) use ($santri) {
$subQ->where('target_berita', 'kelas_tertentu')
->whereJsonContains('target_kelas', $santri->kelas);
})
// 3. Berita untuk SANTRI TERTENTU (cek pivot)
->orWhere(function($subQ) use ($idSantri) {
$subQ->where('target_berita', 'santri_tertentu')
->whereHas('santriTertentu', function($pivot) use ($idSantri) {
$pivot->where('id_santri', $idSantri);
});
});
})
->orderBy('created_at', 'desc');
```
**Response Format:**
```json
{
"success": true,
"data": [
{
"id": 1,
"id_berita": "B001",
"judul": "Pengumuman Libur",
"konten": "...",
"penulis": "Admin",
"gambar_url": "http://localhost/storage/berita/image.jpg",
"target_berita": "semua",
"tanggal": "05 Feb 2026",
"tanggal_lengkap": "05 February 2026, 10:30",
"sudah_dibaca": false,
"tanggal_baca": null
}
],
"pagination": {
"current_page": 1,
"last_page": 3,
"total": 25
}
}
```
---
## 📱 Implementasi Mobile (Flutter)
### API Service (`api_service.dart`):
```dart
Future<Map<String, dynamic>> getBerita({int page = 1}) async {
final response = await http.get(
Uri.parse('${AppConfig.baseUrl}/berita?page=$page'),
headers: await _headers(needsAuth: true), // ✅ Token diperlukan
);
if (response.statusCode == 200) {
return json.decode(response.body);
}
return {'success': false};
}
```
### UI (`berita_page.dart`):
- Menampilkan list berita yang sudah di-filter oleh backend
- Badge "BARU" untuk berita belum dibaca (khusus `santri_tertentu`)
- Pull-to-refresh untuk update data
- Load more pagination
---
## ✅ CHECKLIST TROUBLESHOOTING
### ❌ **Berita Tidak Muncul di Mobile?**
#### 1. **Cek Database - Ada Berita Published?**
```sql
SELECT id_berita, judul, status, target_berita, target_kelas
FROM berita
WHERE status = 'published';
```
- ❌ Jika kosong → **Buat berita baru dan set status 'published'**
- ❌ Jika status 'draft' → **Berita tidak akan muncul di mobile**
#### 2. **Cek Target Berita**
**Untuk target 'semua':**
- ✅ Otomatis muncul untuk semua santri yang login
**Untuk target 'kelas_tertentu':**
```sql
SELECT id_berita, judul, target_kelas
FROM berita
WHERE target_berita = 'kelas_tertentu';
```
- ✅ Pastikan `target_kelas` berisi JSON array: `["PB"]`, `["Lambatan", "Cepatan"]`
- ✅ Cek kelas santri yang login cocok dengan `target_kelas`
**Untuk target 'santri_tertentu':**
```sql
SELECT bs.*, b.judul, s.nama_lengkap
FROM berita_santri bs
JOIN berita b ON bs.id_berita = b.id_berita
JOIN santris s ON bs.id_santri = s.id_santri
WHERE b.status = 'published';
```
- ✅ Pastikan ada data di pivot table `berita_santri`
- ✅ Pastikan `id_santri` sesuai dengan santri yang login
#### 3. **Cek User Login & Role**
```sql
SELECT u.id, u.username, u.role, u.role_id, s.nama_lengkap, s.kelas
FROM users u
LEFT JOIN santris s ON u.role_id = s.id_santri
WHERE u.role = 'wali';
```
- ✅ Pastikan user memiliki `role = 'wali'`
- ✅ Pastikan `role_id` terisi dengan `id_santri` yang valid
- ✅ Pastikan santri dengan `id_santri` tersebut ada dan statusnya 'Aktif'
#### 4. **Cek API Response**
**Test di browser/Postman:**
```
GET http://localhost/TugasAkhir/sim-pkpps/public/api/v1/berita
Header: Authorization: Bearer <token_dari_login>
```
Response yang benar:
```json
{
"success": true,
"data": [...] // Array berisi berita
}
```
Response error:
```json
{
"success": false,
"message": "Unauthenticated." // ❌ Token tidak valid/expired
}
```
#### 5. **Cek Mobile App (Flutter Debug Console)**
Setelah login dan buka halaman Berita, lihat console:
```
🔵 GET BERITA URL: http://...
🔵 Berita Response Status: 200
🔵 Berita Response Body: {"success":true,"data":[...]}
✅ Berita berhasil dimuat: 5 item
```
Error yang mungkin:
```
🔴 Berita SocketException → Server tidak jalan
🔴 Berita error: 401 → Token tidak valid
🔴 Berita Error: FormatException → Response bukan JSON valid
```
---
## 🛠️ CARA MEMBUAT BERITA BARU
### Via Admin Web (Laravel):
1. **Login ke Admin Panel**
```
http://localhost/TugasAkhir/sim-pkpps/public/login
```
2. **Buka Menu Berita → Tambah Berita**
3. **Isi Form:**
- **Judul:** Judul berita yang menarik
- **Konten:** Isi berita lengkap
- **Penulis:** Nama penulis/admin
- **Gambar:** (Optional) Upload gambar
- **Status:** Pilih **"Published"** agar muncul di mobile
- **Target Berita:**
- **Semua Santri** → Semua bisa lihat
- **Kelas Tertentu** → Pilih kelas (bisa lebih dari 1)
- **Santri Tertentu** → Pilih santri spesifik (bisa lebih dari 1)
4. **Simpan**
### Via SQL (Quick Test):
**Berita untuk SEMUA santri:**
```sql
INSERT INTO berita (id_berita, judul, konten, penulis, status, target_berita, created_at, updated_at)
VALUES ('B001', 'Pengumuman Libur', 'Pondok libur tanggal 10-15 Februari 2026', 'Admin', 'published', 'semua', NOW(), NOW());
```
**Berita untuk KELAS PB:**
```sql
INSERT INTO berita (id_berita, judul, konten, penulis, status, target_berita, target_kelas, created_at, updated_at)
VALUES ('B002', 'Jadwal Kelas PB', 'Kegiatan kelas PB dimulai jam 08:00', 'Admin', 'published', 'kelas_tertentu', '["PB"]', NOW(), NOW());
```
**Berita untuk SANTRI TERTENTU (2 steps):**
```sql
-- Step 1: Buat berita
INSERT INTO berita (id_berita, judul, konten, penulis, status, target_berita, created_at, updated_at)
VALUES ('B003', 'Pesan Khusus', 'Harap menemui admin', 'Admin', 'published', 'santri_tertentu', NOW(), NOW());
-- Step 2: Tambah ke pivot table
INSERT INTO berita_santri (id_berita, id_santri, sudah_dibaca, created_at, updated_at)
VALUES ('B003', 'S001', FALSE, NOW(), NOW()); -- Ganti S001 dengan id_santri yang sesuai
```
---
## 🧪 FILE TESTING
Gunakan file `test_api_berita.php` untuk debugging:
```
http://localhost/TugasAkhir/test_api_berita.php
```
File ini akan menampilkan:
1. ✅ Semua berita di database
2. ✅ Sample data santri
3. ✅ Pivot table berita_santri
4. ✅ Data user/wali
5. ✅ Simulasi filter berita untuk santri tertentu
---
## 📊 CONTOH SKENARIO
### Skenario 1: Pengumuman Umum
- **Target:** Semua Santri
- **Contoh:** "Libur Pondok 10-15 Februari"
- **Setting:** `target_berita = 'semua'`
- **Result:** Semua santri yang login bisa lihat
### Skenario 2: Info Kelas
- **Target:** Kelas PB dan Lambatan
- **Contoh:** "Jadwal Ujian Kelas PB & Lambatan"
- **Setting:** `target_berita = 'kelas_tertentu'`, `target_kelas = ["PB", "Lambatan"]`
- **Result:** Hanya santri kelas PB dan Lambatan yang bisa lihat
### Skenario 3: Pesan Personal
- **Target:** Santri Ahmad (S001) dan Budi (S002)
- **Contoh:** "Harap menemui admin untuk pengecekan kesehatan"
- **Setting:** `target_berita = 'santri_tertentu'`, pivot table isi S001 dan S002
- **Result:** Hanya Ahmad dan Budi yang bisa lihat, dengan badge "BARU" sampai mereka buka
---
## 🎓 KESIMPULAN
Fitur berita dengan 3 kategori target ini memberikan fleksibilitas:
- **Efisien** → Tidak perlu kirim satu-satu
- **Fleksibel** → Bisa target sesuai kebutuhan
- **Trackable** → Bisa tracking siapa yang sudah baca (untuk santri_tertentu)
- **Secure** → Filter di backend, mobile tidak bisa akses berita yang bukan haknya
**Backend sudah benar**, pastikan:
1. ✅ Data berita ada dan status 'published'
2. ✅ Target berita sesuai dengan santri yang login
3. ✅ Token authentication valid
4. ✅ Server Laravel jalan
5. ✅ Koneksi database OK
Jika masih ada masalah, cek console Flutter untuk error spesifik!

View File

@ -1,547 +0,0 @@
# DOKUMENTASI FITUR CMS PEMBINAAN & SANKSI
**Tanggal:** 9 Februari 2026
**Status:** ✅ SELESAI - Full CMS Implementation
---
## 🎯 OVERVIEW FITUR
Fitur **Pembinaan & Sanksi** telah dikembangkan menjadi **Content Management System (CMS) yang fleksibel** dengan **Rich Text Editor** untuk memudahkan admin dalam membuat dan mengelola konten.
### ✨ Keunggulan Fitur:
1. **Rich Text Editor** (TinyMCE) - Tidak perlu coding HTML manual
2. **WYSIWYG** (What You See Is What You Get) - Preview langsung saat mengetik
3. **Format Konten Fleksibel** - Bisa buat apa saja: peraturan, tata tertib, pembinaan, dll
4. **Formatting Lengkap** - Bold, italic, heading, list, table, color, dll
5. **Urutan Konten** - Bisa diatur urutannya
6. **Status Aktif/Nonaktif** - Konten bisa disembunyikan tanpa dihapus
---
## 📋 FITUR YANG TERSEDIA
### 1. **Create (Tambah Konten)**
- ✅ Form dengan Rich Text Editor (Quill.js)
- ✅ Auto-generate ID (PS001, PS002, dst)
- ✅ Toolbar lengkap untuk formatting
- ✅ Info box dengan tips penggunaan
- ✅ Preview langsung saat mengetik
**Toolbar Editor:**
- 📋 **Header** - H1, H2, H3 untuk judul & sub judul
- **B** Bold - Tebal
- *I* Italic - Miring
- <u>U</u> Underline - Garis bawah
- <s>S</s> Strike - Coret
- 🎨 Text Color - Warna teks
- 🎨 Background Color - Warna latar
- ⬅️ Align Left/Center/Right/Justify
- 📋 Bullet List - Daftar dengan bullet
- 🔢 Number List - Daftar bernomor
- ↹ Indent/Outdent - Indentasi
- 🔗 Link - Hyperlink
- 🖼️ Image - Gambar (URL)
- 🧹 Clean - Hapus format
### 2. **Read (Index & Detail)**
**Index Page:**
- ✅ Daftar semua konten dalam tabel
- ✅ Preview singkat konten (100 karakter)
- ✅ Info waktu update (difForHumans)
- ✅ Sorting by urutan
- ✅ Badge urutan dan status
- ✅ Navigasi ke Master Pelanggaran
**Detail Page:**
- ✅ Tampilan informasi lengkap
- ✅ Konten ditampilkan dengan format HTML yang rapi
- ✅ Custom CSS styling untuk konten
- ✅ Info created/updated timestamp
- ✅ Tombol edit & kembali
### 3. **Update (Edit Konten)**
- ✅ Form dengan Rich Text Editor
- ✅ Load konten existing ke editor
- ✅ Toolbar sama seperti create
- ✅ Tombol "Lihat Detail" untuk preview
- ✅ Alert info untuk membantu user
### 4. **Delete (Hapus Konten)**
- ✅ Konfirmasi dengan nama judul
- ✅ Warning: data tidak bisa dikembalikan
- ✅ Soft delete ready (jika diperlukan nanti)
---
## 🛠️ TEKNOLOGI YANG DIGUNAKAN
### Rich Text Editor: **Quill.js 1.3.6**
```html
<!-- CSS -->
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
<!-- JS -->
<script src="https://cdn.quilljs.com/1.3.6/quill.js"></script>
```
**Keunggulan Quill.js:**
- ✅ **100% Gratis** - Tidak perlu API key atau registrasi
- ✅ **Open Source** - MIT License
- ✅ **Ringan** - Hanya ~50KB gzipped
- ✅ **Modern** - API yang clean dan mudah digunakan
- ✅ **Cross-browser** - Support semua browser modern
- ✅ **Mobile Friendly** - Touch support
**Konfigurasi:**
- Theme: Snow (clean & modern)
- Height: Min 350px, Max 600px (scrollable)
- Toolbar: Header, Bold, Italic, Color, List, Align, Link, Image
- Auto-sync: Real-time sync ke textarea
- Validation: Empty content check
### Database Structure:
**Table:** `pembinaan_sanksis`
| Column | Type | Description |
|--------|------|-------------|
| id | bigint unsigned | Primary key |
| id_pembinaan | varchar(10) | Auto ID (PS001, PS002) |
| judul | varchar(255) | Judul konten |
| konten | text | HTML content |
| urutan | int | Urutan tampilan (default 0) |
| is_active | boolean | Status (default true) |
| created_at | timestamp | Waktu dibuat |
| updated_at | timestamp | Waktu diupdate |
**Indexes:**
- id_pembinaan (unique)
- urutan
- is_active
---
## 📁 FILE YANG DIUPDATE
### 1. **Views**
```
resources/views/admin/pembinaan_sanksi/
├── index.blade.php ✅ Updated dengan preview & navigasi
├── create.blade.php ✅ Updated dengan TinyMCE
├── edit.blade.php ✅ Updated dengan TinyMCE
└── show.blade.php ✅ Updated dengan HTML rendering & styling
```
### 2. **Controller**
```
app/Http/Controllers/Admin/PembinaanSanksiController.php
```
✅ Sudah lengkap (tidak perlu update)
- CRUD complete
- Validation proper
- Route model binding
### 3. **Model**
```
app/Models/PembinaanSanksi.php
```
✅ Sudah lengkap
- Auto-generate ID
- Scopes: aktif(), byUrutan()
- Fillable & casts proper
### 4. **Migration**
```
database/migrations/2026_02_09_071441_create_pembinaan_sanksis_table.php
```
✅ Sudah lengkap
---
## 🎨 CONTOH PENGGUNAAN
### Contoh 1: Membuat Peraturan Pondok
**Judul:** `Tata Tertib Pondok`
**Konten (menggunakan editor):**
```
TATA TERTIB PONDOK PESANTREN
I. Kewajiban Santri
Setiap santri wajib:
1. Mengikuti seluruh kegiatan yang telah dijadwalkan
2. Menjaga kebersihan kamar dan lingkungan pondok
3. Berpakaian sesuai dengan ketentuan yang berlaku
II. Larangan Bagi Santri
Dilarang keras:
• Keluar pondok tanpa izin
• Membawa handphone tanpa izin
• Berkelahi atau berbuat kerusuhan
```
### Contoh 2: Membuat Pembinaan & Sanksi
**Judul:** `PEMBINAAN DAN SANKSI`
**Konten (dengan formatting):**
- Heading 1 untuk judul utama
- Heading 2 untuk sub judul
- Bold untuk penekanan
- Numbered list untuk poin-poin
- Color untuk highlight penting
- Table untuk jadwal
### Contoh 3: Membuat Peraturan Khusus
**Judul:** `Peraturan Kepulangan Santri`
**Konten:**
- Bisa pakai emoji/icon
- Background color untuk warning box
- Border styling untuk info penting
- List dengan sub-list
---
## 🚀 CARA PENGGUNAAN
### A. Menambah Konten Baru
1. **Akses Menu**
```
Admin Menu → Master Pelanggaran → Pembinaan & Sanksi
```
Atau:
```
http://localhost/TugasAkhir/sim-pkpps/public/admin/pembinaan-sanksi
```
2. **Klik "Tambah Konten"**
3. **Isi Form:**
- **Judul:** Masukkan judul yang jelas (contoh: "Tata Tertib Pondok")
- **Konten:** Gunakan editor untuk membuat konten
- **Urutan:** Atur urutan tampilan (0 = paling atas)
- **Status:** Centang "Aktif" agar ditampilkan
4. **Gunakan Toolbar:**
- Blok teks → Bold/Italic/Underline
- Pilih Styles → Heading 1/2/3 untuk judul
- Klik icon list → Numbered atau Bullet list
- Klik icon table → Insert table
5. **Klik "Simpan"**
### B. Edit Konten
1. **Dari index, klik tombol Edit (kuning)**
2. **Ubah konten di editor**
3. **Preview dengan "Lihat Detail"** (opsional)
4. **Klik "Update"**
### C. Menghapus Konten
1. **Dari index, klik tombol Hapus (merah)**
2. **Konfirmasi penghapusan**
3. **Konten akan terhapus permanen**
### D. Mengatur Urutan
1. **Edit konten yang ingin diatur**
2. **Ubah "Urutan Tampilan"**
- 0 = Paling atas
- 1 = Kedua
- 2 = Ketiga, dst
3. **Klik "Update"**
---
## 💡 TIPS & TRIK
### 1. **Membuat Judul yang Menarik**
```
Gunakan Heading 1 untuk judul utama
Gunakan Heading 2 untuk sub judul
Gunakan Bold untuk penekanan kata
```
### 2. **Membuat Daftar Bernomor**
```
Pilih text → Klik icon "Numbered list"
Tekan Enter untuk nomor berikutnya
Tekan Tab untuk sub-list (nested)
```
### 3. **Membuat Warning Box**
```
1. Ketik text warning
2. Blok text
3. Ubah background color → Kuning/Merah
4. Tambah border dengan align center
```
### 4. **Membuat Tabel**
```
1. Klik icon Table
2. Pilih rows x columns
3. Isi data di cell
4. Right click → Table properties untuk styling
```
### 5. **Copy dari Word/Excel**
```
⚠️ Jangan copy-paste langsung!
1. Copy dari Word
2. Klik "Paste as text" di editor
3. Format ulang dengan toolbar
```
### 6. **Best Practices**
- ✅ Gunakan heading untuk struktur
- ✅ Konsisten dalam formatting
- ✅ Gunakan list untuk poin-poin
- ✅ Hindari terlalu banyak warna
- ✅ Test preview sebelum publish
---
## 📊 SAMPLE KONTEN YANG SUDAH DIBUAT
### 1. **PEMBINAAN DAN SANKSI**
- Urutan: 1
- Konten: Tujuan pembinaan, jenis sanksi, ketentuan kafaroh
- Format: H1, H2, numbered list, text color, bold/italic
### 2. **Tata Tertib Pondok**
- Urutan: 2
- Konten: Kewajiban santri, larangan, jadwal harian
- Format: H1, H2, bullet list, table, text color
### 3. **Peraturan Kepulangan Santri**
- Urutan: 3
- Konten: Waktu kepulangan, prosedur, hal penting
- Format: H1, H2, emoji/icon, colored boxes, lists
---
## 🎯 KEGUNAAN KONTEN
### Untuk Admin:
✅ Mudah membuat dan update peraturan
✅ Tidak perlu coding HTML
✅ Format konten profesional
✅ Bisa buat berbagai jenis dokumen
### Untuk Santri/Wali:
✅ Informasi jelas dan terstruktur
✅ Mudah dibaca dengan formatting yang baik
✅ Bisa akses kapan saja
✅ Update otomatis jika ada perubahan
---<2D> DOKUMENTASI QUILL.JS
### Kenapa Quill.js?
**Sebelumnya:** TinyMCE (perlu API key, ada warning)
**Sekarang:** Quill.js (100% gratis, no API key!)
**Perbandingan:**
| Fitur | TinyMCE | Quill.js |
|-------|---------|----------|
| API Key | ❌ Perlu (gratis tapi harus daftar) | ✅ Tidak perlu |
| Warning | ⚠️ Ada | ✅ Tidak ada |
| Size | ~500KB | ✅ ~50KB |
| License | Freemium | ✅ MIT (Open Source) |
| Setup | Complex | ✅ Simple |
| Mobile | Good | ✅ Excellent |
### Features Quill.js:
**WYSIWYG Editor** - What You See Is What You Get
**Semantic HTML** - Output HTML yang clean
**Custom Toolbar** - Toolbar sesuai kebutuhan
**Keyboard Shortcuts** - Ctrl+B, Ctrl+I, dll
**Paste from Word** - Copy-paste dari Word/Excel
**Cross-platform** - Windows, Mac, Linux, Mobile
### Official Resources:
- Website: https://quilljs.com/
- Documentation: https://quilljs.com/docs/
- GitHub: https://github.com/quilljs/quill
- License: MIT (Free forever!)
---
## 🔗 INTEGRASI DENGAN MENU LAIN
### Navigasi Breadcrumb:
```
Master Pelanggaran → Pembinaan & Sanksi
```
**Dari Pembinaan & Sanksi**, ada tombol:
- "Master Pelanggaran" → Kembali ke kategori pelanggaran
**Dari Master Pelanggaran**, ada tombol:
- "Klasifikasi Pelanggaran" → Ke klasifikasi
- "Pembinaan & Sanksi" → Ke pembinaan & sanksi
- "Tambah Pelanggaran" → Tambah pelanggaran
---
## 🧪 TESTING
### Test 1: Create Konten
1. ✅ Buka create form
2. ✅ Editor TinyMCE loaded
3. ✅ Isi judul dan konten
4. ✅ Gunakan berbagai formatting
5. ✅ Submit → Data tersimpan
6. ✅ HTML di database
### Test 2: Edit Konten
1. ✅ Buka edit form
2. ✅ Konten HTML di-load ke editor
3. ✅ Edit konten
4. ✅ Submit → Data terupdate
### Test 3: View Konten
1. ✅ Buka detail page
2. ✅ HTML di-render dengan benar
3. ✅ Formatting tetap terjaga
4. ✅ Styling CSS applied
### Test 4: Delete Konten
1. ✅ Klik delete
2. ✅ Konfirmasi muncul
3. ✅ Data terhapus dari database
---
## 🎓 VIDEO TUTORIAL (Untuk User)
### Topik yang Bisa Dibuat:
1. **Cara Menambah Konten Baru**
- Login admin
- Akses menu
- Isi form dengan editor
- Submit & review
2. **Cara Menggunakan Rich Text Editor**
- Toolbar overview
- Membuat heading
- Membuat list
- Membuat table
- Coloring & formatting
3. **Tips Membuat Konten Profesional**
- Structure content
- Consistent formatting
- Use of headings
- Best practices
---
## 📱 RESPONSIVE DESIGN
**Desktop:** Full editor dengan toolbar lengkap
**Tablet:** Editor adjustable, toolbar wrap
**Mobile:** Editor tetap usable (tapi recommend desktop)
**Note:** Untuk edit konten yang kompleks, sangat disarankan menggunakan desktop/laptop.
---
## 🔐 SECURITY
### XSS Protection:
- ✅ Konten disimpan sebagai HTML (sanitized by TinyMCE)
- ✅ Output dengan `{!! !!}` untuk render HTML
- ✅ Input validation di controller
- ✅ CSRF protection
### Access Control:
- ✅ Only admin can CRUD
- ✅ Middleware: `auth`, `role:admin`
- ✅ Route protection
---
## 🚀 FUTURE ENHANCEMENTS (Opsional)
### 1. **Image Upload**
- Upload gambar ke server
- Insert image di konten
- Image gallery
### 2. **Template Library**
- Pre-made templates
- Quick insert template
- Custom save template
### 3. **Version Control**
- History perubahan konten
- Rollback to previous version
- Compare versions
### 4. **Export/Import**
- Export konten ke PDF
- Import dari Word
- Backup & restore
### 5. **Multi-language**
- Konten dalam bahasa Indonesia & Inggris
- Switch language di frontend
---
## 📞 SUPPORT
Jika ada pertanyaan atau masalah:
1. Cek dokumentasi ini
2. Lihat sample konten yang sudah dibuat
3. Test di environment development dulu
4. Contact developer jika perlu
---
## ✅ CHECKLIST IMPLEMENTASI
- [x] Rich Text Editor (TinyMCE) integrated
- [x] Create form dengan editor
- [x] Edit form dengan editor
- [x] Show page dengan HTML rendering
- [x] Index page dengan preview
- [x] Toolbar lengkap (heading, bold, italic, list, table, color, dll)
- [x] Auto-save to database as HTML
- [x] WYSIWYG editor
- [x] Sample content inserted
- [x] CSS styling untuk konten
- [x] Navigation buttons
- [x] Responsive design
- [x] Security (XSS, CSRF)
- [x] Validation proper
- [x] User-friendly interface
- [x] Info boxes & tips
- [x] Dokumentasi lengkap
---
## 🎉 KESIMPULAN
Fitur **Pembinaan & Sanksi** telah berhasil dikembangkan menjadi **CMS yang fleksibel dan mudah digunakan**. Admin dapat dengan mudah membuat, mengedit, dan mengelola konten dengan format yang profesional tanpa perlu mengetahui coding HTML.
**Keunggulan Utama:**
1. ✅ **User-Friendly** - Editor WYSIWYG yang mudah
2. ✅ **Fleksibel** - Bisa buat konten apa saja
3. ✅ **Profesional** - Format rapi dengan styling
4. ✅ **Efisien** - Tidak perlu coding manual
5. ✅ **Terintegrasi** - Part dari menu pelanggaran
**Ready to Use!** 🚀
---
**Dibuat oleh:** GitHub Copilot
**Tanggal:** 9 Februari 2026
**Verified:** ✅ All Features Working

View File

@ -1,296 +0,0 @@
# DOKUMENTASI DASHBOARD KEGIATAN SANTRI
## 📊 Overview
Dashboard Kegiatan Santri adalah fitur baru yang menampilkan jadwal kegiatan hari ini dengan progress absensi real-time, mengurangi redundansi menu, dan menambahkan visualisasi yang berguna.
## ✅ Fitur yang Telah Diimplementasikan
### A. Halaman Dashboard Kegiatan Hari Ini
**Route:** `/admin/kegiatan`
#### 1. KPI Cards (Key Performance Indicators)
Dashboard menampilkan 4 kartu statistik utama:
- **Total Kegiatan Hari Ini** - Jumlah kegiatan yang dijadwalkan untuk hari yang dipilih
- **Kegiatan Selesai** - Jumlah kegiatan yang sudah selesai dilaksanakan
- **Rata-rata Kehadiran** - Persentase rata-rata kehadiran santri di semua kegiatan
- **Sedang Berlangsung** - Jumlah kegiatan yang sedang berlangsung (real-time)
#### 2. Filter & Quick Actions
- **Dropdown Pilih Hari** - Filter berdasarkan hari (Senin-Ahad)
- **Date Picker** - Filter berdasarkan tanggal spesifik
- **Tombol "Lihat Semua Jadwal"** - Link ke halaman jadwal lengkap
- **Tombol "Tambah Kegiatan"** - Link ke form tambah kegiatan baru
- **Tombol "Reset"** - Reset filter ke hari ini
#### 3. Card Kegiatan (Timeline View)
Setiap kegiatan ditampilkan dalam card dengan informasi:
**Informasi Kegiatan:**
- Waktu (jam mulai - jam selesai)
- Hari dengan badge berwarna
- Nama Kegiatan dengan icon kategori
- Kategori dengan badge berwarna sesuai kategori
- Materi (jika ada)
**Status Badge:**
- 🟢 **Sedang Berlangsung** (hijau) - Animasi pulse
- 🔵 **Selesai** (biru)
- ⚪ **Belum Dimulai** (abu-abu)
Status diupdate otomatis berdasarkan waktu real-time sistem.
**Progress Bar Absensi:**
- Menampilkan: "X/Y santri hadir (Z%)"
- Warna dinamis:
- 🟢 Hijau: >85% hadir
- 🟡 Kuning: 70-85% hadir
- 🟠 Orange: 50-70% hadir
- 🔴 Merah: <50% hadir
- Animasi smooth transition
**Quick Actions per Kegiatan:**
- **Input Absensi** → Redirect ke halaman input absensi kegiatan
- **Lihat Detail** → Modal popup (coming soon)
- **Rekap** → Redirect ke rekap absensi kegiatan
- **Info** → Detail kegiatan lengkap
#### 4. Empty State
Jika tidak ada kegiatan di hari yang dipilih, ditampilkan:
- Icon kalender
- Pesan "Tidak ada kegiatan dijadwalkan hari ini"
- Button "Buat Kegiatan Baru"
- Button "Lihat Semua Jadwal"
### B. Halaman Jadwal Lengkap
**Route:** `/admin/kegiatan/jadwal/semua`
Menampilkan daftar semua jadwal kegiatan dalam tabel dengan fitur:
- **Filter:** Hari, Kategori, Search
- **Pagination:** 15 data per halaman
- **Action Buttons:** Detail, Edit, Hapus
- **Quick Access:**
- Button ke Dashboard Kegiatan
- Button ke Kategori Kegiatan
- Button Tambah Kegiatan
**Note:** Menggunakan view yang sama dengan index lama (`index.blade.php`) untuk menghindari duplikasi.
### C. Struktur Menu Sidebar (Updated)
**Kegiatan Santri** (Parent Menu - Dropdown)
```
├── 📊 Dashboard Kegiatan (NEW)
├── ✅ Absensi Kegiatan
├── 💳 Kartu RFID
└── 📊 Laporan & Statistik
```
**Perubahan dari struktur lama:**
- ❌ Removed: Menu "Kategori Kegiatan" (dipindah ke quick access di halaman jadwal)
- ❌ Removed: Menu "Jadwal Kegiatan" (sekarang jadi Dashboard)
- ✅ Added: Menu "Dashboard Kegiatan" sebagai landing page utama
- ✅ Updated: Icon "Laporan & Statistik" dari `fa-history` ke `fa-chart-bar`
## 🎨 Styling & UI/UX
### Desain Visual
- **Card-based layout** - Modern dan clean
- **Gradient KPI cards** - Dengan efek radial overlay
- **Smooth animations:**
- Progress bar: 0.6s ease transition
- Pulse animation untuk status "Berlangsung": 2s loop
- Modal: fadeIn & slideUp animation 0.3s
- Card hover: transform translateY & shadow transition
### Color Scheme
- Primary: `#6FBA9D` (hijau tosca)
- Success: `#28a745` (hijau)
- Warning: `#ffc107` (kuning)
- Info: `#17a2b8` (biru)
- Danger: `#dc3545` (merah)
### Responsive Design
- **Desktop:** Grid layout optimal
- **Tablet:** Flexible grid adjustment
- **Mobile:**
- KPI cards: 1 kolom
- Filter: vertical stack
- Card kegiatan: full width
## 🔧 Technical Implementation
### Controller Updates
**File:** `app/Http/Controllers/Admin/KegiatanController.php`
**Method Baru:**
1. **`index()`** - Dashboard kegiatan hari ini
- Query kegiatan berdasarkan hari
- Join dengan absensis untuk hari yang dipilih
- Hitung statistik (hadir, persentase, status)
- Status kegiatan berdasarkan waktu real-time
2. **`jadwal()`** - Jadwal lengkap (moved from old index)
- Filter hari, kategori, search
- Pagination 15 per halaman
### Views Created
1. **`resources/views/admin/kegiatan/data/dashboard.blade.php`** - Dashboard utama
2. **`resources/views/admin/kegiatan/data/index.blade.php`** - Diupdate untuk jadwal lengkap (reuse existing view)
### Routes Updated
**File:** `routes/web.php`
```php
// Dashboard Kegiatan (default index)
Route::get('kegiatan', [KegiatanController::class, 'index'])->name('kegiatan.index');
// Jadwal Lengkap
Route::get('kegiatan/jadwal/semua', [KegiatanController::class, 'jadwal'])->name('kegiatan.jadwal');
// Resource routes lainnya tetap sama
Route::resource('kegiatan', KegiatanController::class);
```
### Database Queries Optimization
- **Eager Loading:** `with(['kategori', 'absensis'])`
- **Date Filtering:** `whereDate()` untuk filter tanggal spesifik
- **Select Specific Columns:** Hanya mengambil kolom yang diperlukan
- **No N+1 Problem:** Semua relasi dimuat di awal
## 📱 User Flow
### Flow 1: Monitoring Kegiatan Hari Ini
```
Sidebar > Dashboard Kegiatan
Lihat KPI Cards (statistik overview)
Review Timeline Kegiatan Hari Ini
Cek Progress Bar Absensi
Klik "Input Absensi" atau "Rekap"
```
### Flow 2: Lihat Jadwal Lengkap
```
Dashboard Kegiatan > Button "Lihat Semua Jadwal"
Filter (jika perlu): Hari, Kategori, Search
Review Tabel Jadwal
Action: Detail, Edit, atau Hapus
```
### Flow 3: Input Absensi Cepat
```
Dashboard Kegiatan
Scroll ke kegiatan yang sedang berlangsung
Klik "Input Absensi"
Form Input Absensi (dengan pre-filled kegiatan & tanggal)
```
## ⚡ Performance
### Load Time
- **Target:** < 1 detik
- **Actual:** ~0.3-0.5 detik (optimal)
### Optimizations Applied
- Eager loading relasi
- Cache busting untuk query berulang
- Minimal JavaScript (vanilla JS only)
- CSS inline untuk komponen spesifik
- No heavy libraries (no React/Vue/Angular)
## 🔐 Security
- CSRF Protection pada semua form
- Role-based access (admin only)
- Input validation di controller
- SQL injection prevention (Eloquent ORM)
## 🧪 Testing Checklist
### ✅ Functional Testing
- [x] Dashboard load dengan data benar
- [x] KPI cards hitung dengan akurat
- [x] Filter hari bekerja
- [x] Filter tanggal bekerja
- [x] Status kegiatan update real-time
- [x] Progress bar warna sesuai persentase
- [x] Link "Input Absensi" benar
- [x] Link "Rekap" benar
- [x] Link "Info" benar
- [x] Empty state tampil jika tidak ada kegiatan
- [x] Sidebar menu update
- [x] Jadwal lengkap load dengan pagination
### ✅ UI/UX Testing
- [x] Responsive di mobile
- [x] Responsive di tablet
- [x] Responsive di desktop
- [x] Animasi smooth
- [x] Hover effects bekerja
- [x] Modal open/close (prepared for future)
### ✅ Performance Testing
- [x] No N+1 query
- [x] Load time < 1 detik
- [x] No JavaScript errors
- [x] CSS tidak conflict
## 📝 Future Enhancements
### Modal Detail (Coming Soon)
Fitur yang direncanakan:
- Info kegiatan lengkap
- Statistik absensi hari ini (Hadir, Izin, Sakit, Alpa)
- Pie chart kecil
- Daftar santri dengan status (scrollable)
- Button "Download Rekap PDF"
### Real-time Updates (Optional)
- Auto-refresh status kegiatan setiap menit
- WebSocket untuk update absensi real-time
- Push notification untuk admin
### Advanced Analytics
- Grafik trend kehadiran per kegiatan
- Perbandingan antar periode
- Export data ke Excel/CSV
## 🐛 Known Issues & Fixes
### ✅ Fixed: Carbon Parsing Error
**Issue:** `Could not parse '2026-02-12 2026-02-12 13:00:00': Double date specification`
**Cause:** `waktu_mulai` dan `waktu_selesai` sudah dalam format datetime/Carbon object, bukan string waktu saja.
**Solution:** Extract waktu dengan `format('H:i')` sebelum digabung dengan tanggal:
```php
$waktuMulaiStr = is_string($kegiatan->waktu_mulai)
? $kegiatan->waktu_mulai
: $kegiatan->waktu_mulai->format('H:i');
$waktuMulai = Carbon::parse($selectedDate->format('Y-m-d') . ' ' . $waktuMulaiStr);
```
### ✅ Fixed: Duplicate View Files
**Issue:** `index.blade.php` dan `jadwal.blade.php` memiliki konten yang sama.
**Solution:** Hapus `jadwal.blade.php`, reuse `index.blade.php` untuk route jadwal lengkap.
### Future Improvements
- Modal detail belum fully implemented (placeholder saja)
- Mobile landscape orientation need adjustment untuk KPI cards
## 📞 Support
Untuk pertanyaan atau issue terkait fitur ini, hubungi:
- Developer: [Your Name]
- Email: [your@email.com]
---
**Last Updated:** 12 Februari 2026
**Version:** 1.0.0
**Status:** ✅ Production Ready

View File

@ -1,307 +0,0 @@
# DOKUMENTASI PENGEMBANGAN FITUR PEMBAYARAN SPP
## 📋 Overview
Fitur Pembayaran SPP telah dikembangkan dengan sistem tab yang memisahkan antara santri yang sudah bayar dan belum bayar, dilengkapi dengan sistem filter yang komprehensif dan badge status yang jelas.
## ✨ Fitur yang Dikembangkan
### 1. **Sistem Tab "Sudah Bayar" & "Belum Bayar"**
- **Tab Sudah Bayar**: Menampilkan daftar santri yang telah melunasi SPP periode tertentu
- Menampilkan nominal yang dibayarkan
- Tanggal pembayaran
- Link ke riwayat pembayaran santri
- Tombol cetak bukti pembayaran
- **Tab Belum Bayar**: Menampilkan daftar santri yang belum melunasi SPP
- Menampilkan nominal tagihan
- Batas waktu pembayaran
- Jumlah hari keterlambatan (jika telat)
- Link ke halaman tagihan santri
- Tombol untuk membuat tagihan baru (jika belum ada)
### 2. **Status Pembayaran**
Tiga status utama:
- ✅ **Sudah Bayar (Lunas)**: Badge hijau dengan gradient
- ⏰ **Belum Bayar (Belum Lunas)**: Badge warning dengan gradient pink
- 🚨 **Terlambat**: Badge merah dengan animasi pulse dan highlight baris
### 3. **Filter Data**
- **Filter Bulan**: Dropdown untuk memilih bulan (1-12)
- **Filter Tahun**: Dropdown tahun berdasarkan data yang ada
- **Filter Status** (hanya di tab Belum Bayar):
- Semua Status
- Belum Lunas
- Terlambat
- Belum Ada Tagihan
- **Search**: Pencarian berdasarkan nama santri, NIS, atau ID Santri
- **Default Filter**: Otomatis menampilkan bulan dan tahun saat ini
### 4. **Badge & Penanda Khusus**
- Badge "TERLAMBAT" berwarna merah terang dengan animasi pulse
- Highlight baris dengan background merah muda untuk santri yang terlambat
- Informasi jumlah hari keterlambatan
- Badge dengan gradient yang menarik untuk setiap status
### 5. **Statistik Dashboard**
Empat card statistik dengan gradient:
- 👥 **Total Santri**: Jumlah total santri aktif
- ✅ **Sudah Bayar**: Jumlah santri yang sudah bayar + total nominal
- ❌ **Belum Bayar**: Jumlah santri yang belum bayar + total tunggakan
- ⏰ **Terlambat**: Jumlah santri yang melewati batas waktu
### 6. **Navigasi & UX**
- Tab navigation dengan counter badge
- Informasi periode yang sedang ditampilkan
- Tombol reset filter
- Pagination manual dengan info halaman
- Hover effects pada tombol dan baris tabel
- Responsive design
### 7. **Integrasi Form Create**
- Pre-fill form dengan parameter dari URL
- Otomatis memilih santri, bulan, dan tahun dari link "Buat Tagihan"
## 🗂️ File yang Dimodifikasi
### 1. **Controller** - `PembayaranSppController.php`
```php
// Method index() - Complete rewrite
- Menambahkan sistem tab (sudah-bayar / belum-bayar)
- Grouping data per santri (bukan per transaksi)
- Filter berdasarkan bulan, tahun, search, dan status
- Perhitungan statistik real-time
- Manual pagination
- Default filter ke bulan/tahun saat ini
```
**Fitur Utama:**
- Eager loading untuk optimasi query
- Collection mapping untuk data transformation
- Filter dinamis berdasarkan tab
- Statistik agregasi (count & sum)
### 2. **View** - `index.blade.php`
**Struktur Baru:**
```php
1. Alert messages (success/error)
2. Filter section dengan label dan icon
3. Statistics cards (4 cards dengan gradient)
4. Tab navigation (Belum Bayar & Sudah Bayar)
5. Action buttons (Generate, Tambah, Laporan)
6. Periode info
7. Data table dengan kolom dinamis
8. Manual pagination
9. Custom CSS untuk badge dan animasi
```
**Styling:**
- Gradient backgrounds untuk cards
- Badge dengan animasi pulse untuk status terlambat
- Hover effects
- Highlight baris untuk santri terlambat
- Responsive grid layout
### 3. **View** - `create.blade.php`
**Modifikasi:**
- Pre-fill `id_santri` dari request parameter
- Pre-fill `bulan` dari request parameter
- Pre-fill `tahun` dari request parameter
- Fallback ke nilai default jika parameter tidak ada
## 📊 Flow Data
### Tab "Belum Bayar"
```
1. Query santri aktif dengan eager load pembayaran
2. Filter by bulan & tahun
3. Filter santri yang belum lunas atau belum ada tagihan
4. Apply search filter
5. Apply status filter (Belum Lunas/Telat/Belum Ada Tagihan)
6. Hitung statistik
7. Manual pagination
8. Return view dengan data
```
### Tab "Sudah Bayar"
```
1. Query santri aktif dengan eager load pembayaran
2. Filter by bulan & tahun
3. Filter santri yang status = Lunas
4. Apply search filter
5. Hitung statistik
6. Manual pagination
7. Return view dengan data
```
## 🎨 Design Decisions
### 1. **Grouping per Santri (bukan per transaksi)**
**Alasan:**
- Lebih intuitif untuk monitoring pembayaran
- Mudah melihat siapa yang sudah/belum bayar
- Menghindari duplikasi data santri
### 2. **Default Filter ke Bulan/Tahun Saat Ini**
**Alasan:**
- Fokus pada periode aktif
- Mengurangi clutter data
- Admin biasanya ingin cek bulan berjalan
### 3. **Manual Pagination**
**Alasan:**
- Data sudah difilter di collection
- Built-in paginator tidak cocok untuk collection hasil transform
- Lebih fleksibel untuk custom logic
### 4. **Badge dengan Animasi Pulse**
**Alasan:**
- Menarik perhatian untuk santri yang telat
- Visual feedback yang jelas
- Meningkatkan UX
### 5. **Tab System**
**Alasan:**
- Pemisahan yang jelas antara lunas dan belum lunas
- Mengurangi cognitive load
- Mudah fokus pada salah satu kelompok
## 🔍 Query Optimization
### Eager Loading
```php
Santri::where('status', 'Aktif')
->with(['pembayaranSpp' => function($q) use ($bulan, $tahun) {
$q->where('bulan', $bulan)->where('tahun', $tahun);
}])
```
**Benefit:**
- Menghindari N+1 query problem
- Load hanya data pembayaran yang relevan
- Performa lebih cepat
### Collection Filtering vs Query Filtering
- Query filtering untuk periode (bulan/tahun)
- Collection filtering untuk status dan search
- Lebih fleksibel untuk logic complex
## 🎯 Key Features Breakdown
### Penanda Telat
```php
// Check telat di Model
public function isTelat() {
if ($this->status === 'Lunas') return false;
return Carbon::now()->isAfter($this->batas_bayar);
}
// Highlight visual
- Background baris: #fff5f5 (pink muda)
- Badge: Gradient merah dengan animasi
- Info: Jumlah hari keterlambatan
```
### Filter yang Sedang Aktif
```php
// Preserve filter saat pindah tab
array_merge(request()->except('tab'), ['tab' => 'sudah-bayar'])
// Show reset button jika ada filter
@if(request()->hasAny(['search', 'filter_status']) || $bulan != date('n') || $tahun != date('Y'))
```
### Link ke Riwayat/Tagihan
```php
// Riwayat pembayaran per santri
route('admin.pembayaran-spp.riwayat', $item['id_santri'])
// Create dengan pre-fill
route('admin.pembayaran-spp.create', [
'id_santri' => $item['id_santri'],
'bulan' => $bulan,
'tahun' => $tahun
])
```
## 📱 Responsive Design
### Grid Layout
```css
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
```
**Benefit:**
- Auto-responsive tanpa media queries manual
- Kartu statistik menyesuaikan lebar layar
### Form Filter
```css
display: flex;
flex-wrap: wrap;
```
**Benefit:**
- Input fields wrap ke baris baru di layar kecil
- Tetap horizontal di layar besar
## ⚡ Performance Considerations
1. **Pagination**: 20 items per page - balance antara UX dan performa
2. **Eager Loading**: Hindari N+1 queries
3. **Collection Operations**: Lebih cepat daripada multiple queries
4. **CSS Animations**: Hardware-accelerated (opacity, transform)
## 🚀 Testing Checklist
- [ ] Tab switching preserve filter
- [ ] Filter bulan & tahun berfungsi
- [ ] Search santri berfungsi
- [ ] Filter status di tab Belum Bayar
- [ ] Badge terlambat muncul untuk santri telat
- [ ] Statistik terupdate sesuai filter
- [ ] Pagination berfungsi
- [ ] Link riwayat pembayaran
- [ ] Link buat tagihan dengan pre-fill
- [ ] Tombol reset filter
- [ ] Cetak bukti di tab Sudah Bayar
- [ ] Responsive di mobile
## 📝 Notes untuk Developer
### Jangan Ubah:
- ❌ Struktur database
- ❌ Alur bisnis (create, update, delete)
- ❌ Routes yang sudah ada
- ❌ Model relationships
### Boleh Dikustomisasi:
- ✅ Warna gradient badge
- ✅ Jumlah item per page
- ✅ Default filter (jika tidak ingin ke bulan saat ini)
- ✅ Kolom tambahan di tabel
- ✅ Statistik tambahan
### Tips Maintenance:
1. Gunakan Collection operations untuk filtering complex
2. Keep controller logic readable dengan method extract jika perlu
3. Cache tahunList jika data besar
4. Monitor query performance dengan Laravel Debugbar
## 🐛 Known Limitations
1. **Manual Pagination**: Tidak kompatibel dengan Laravel Pagination Links bawaan
2. **Collection Filtering**: Semua data santri di-load dulu sebelum filter - bisa lambat jika santri > 1000
3. **Real-time Stats**: Dihitung setiap request - pertimbangkan caching untuk production
## 💡 Future Enhancements
1. **Export Excel**: Export data berdasarkan filter
2. **Bulk Actions**: Tandai lunas multiple santri sekaligus
3. **Notifications**: Email/SMS reminder untuk yang telat
4. **Dashboard Chart**: Visualisasi trend pembayaran
5. **Auto Reminder**: Cron job untuk reminder otomatis
6. **Payment Gateway**: Integrasi pembayaran online
---
**Last Updated**: February 6, 2026
**Version**: 1.0
**Developer**: GitHub Copilot Assistant

View File

@ -1,231 +0,0 @@
# PERBAIKAN FITUR CAPAIAN SANTRI - MOBILE APP
**Tanggal:** 10 Februari 2026
**Status:** ✅ **SELESAI**
## 🐛 Masalah yang Ditemukan
Fitur Capaian Santri di aplikasi mobile gagal mengambil data dari API, meskipun route API sudah terdaftar dengan benar.
### Root Cause
Kesalahan query database pada model **Semester**:
- Migrasi database menggunakan kolom: `is_active` (boolean)
- Kode controller menggunakan: `where('status', 'Aktif')`
- Error: `SQLSTATE[42S22]: Column not found: 1054 Unknown column 'status' in 'where clause'`
## ✅ Perbaikan yang Dilakukan
### 1. File: `ApiCapaianController.php`
**Lokasi:** `sim-pkpps/app/Http/Controllers/Api/ApiCapaianController.php`
#### Perubahan:
| Baris | Sebelum | Sesudah |
|-------|---------|---------|
| 57 | `Semester::where('status', 'Aktif')->first()` | `Semester::aktif()->first()` |
| 115 | `$s->status === 'Aktif'` | `$s->is_active == 1` |
| 115 | `'status'` dalam select | `'is_active'` dalam select |
| 192 | `Semester::where('status', 'Aktif')->first()` | `Semester::aktif()->first()` |
**Detail Perubahan:**
```php
// ❌ SEBELUM
$semesterAktif = Semester::where('status', 'Aktif')->first();
$semesters = Semester::select('id_semester', 'nama_semester', 'tahun_ajaran', 'periode', 'status')
->get()
->map(function($s) {
return [
'id_semester' => $s->id_semester,
'nama_semester' => $s->nama_semester,
'is_aktif' => $s->status === 'Aktif',
];
});
// ✅ SESUDAH
$semesterAktif = Semester::aktif()->first();
$semesters = Semester::select('id_semester', 'nama_semester', 'tahun_ajaran', 'periode', 'is_active')
->get()
->map(function($s) {
return [
'id_semester' => $s->id_semester,
'nama_semester' => $s->nama_semester,
'is_aktif' => $s->is_active == 1,
];
});
```
### 2. File: `DashboardController.php`
**Lokasi:** `sim-pkpps/app/Http/Controllers/DashboardController.php`
**Baris 77:** Diperbaiki query semester
```php
// ❌ SEBELUM
$semesterAktif = Semester::where('status', 'aktif')->first();
// ✅ SESUDAH
$semesterAktif = Semester::aktif()->first();
```
## 🧪 Testing
### 1. Test Database Query
```bash
php test_capaian_api.php
```
**Hasil:**
```
✅ Santri: HELGA FAISA (ID: S001, Kelas: Lambatan)
✅ Semester Aktif: Semester 1 2024/2025 (ID: SEM001)
📚 Materi untuk kelas Lambatan: 1 materi
📊 Capaian Santri: 1 capaian
```
### 2. Test API Endpoint
```bash
php test_capaian_endpoint.php
```
**Endpoint:** `GET /api/v1/capaian/overview`
**Response (200 OK):**
```json
{
"success": true,
"data": {
"santri": {
"id_santri": "S001",
"nama_lengkap": "HELGA FAISA",
"kelas": "Lambatan"
},
"semester": {
"id_semester": "SEM001",
"nama_semester": "Semester 1 2024/2025",
"list_semester": [
{
"id_semester": "SEM002",
"nama_semester": "Semester 2 2025/2026",
"is_aktif": false
},
{
"id_semester": "SEM001",
"nama_semester": "Semester 1 2024/2025",
"is_aktif": true
}
]
},
"statistik_umum": {
"total_materi": 1,
"rata_rata_progress": 6,
"materi_selesai": 0
},
"per_kategori": [
{
"kategori": "Al-Qur'an",
"icon": "book_quran",
"color": "#6FBAA5",
"total_materi": 1,
"rata_rata_progress": 6,
"materi_selesai": 0
},
{
"kategori": "Hadist",
"icon": "scroll",
"color": "#81C6E8",
"total_materi": 0,
"rata_rata_progress": 0,
"materi_selesai": 0
},
{
"kategori": "Materi Tambahan",
"icon": "book",
"color": "#FFD56B",
"total_materi": 0,
"rata_rata_progress": 0,
"materi_selesai": 0
}
]
}
}
```
**Validasi Struktur Data:**
- ✅ Santri data exists
- ✅ Semester data exists
- ✅ Statistik umum exists
- ✅ Per kategori exists
- ✅ List semester: 2 items
- ✅ Categories: 3 items
## 📱 Verifikasi Mobile App
### API Endpoints yang Diperbaiki
1. ✅ `GET /api/v1/capaian/overview` - Overview capaian dengan statistik
2. ✅ `GET /api/v1/capaian/kategori/{kategori}` - List materi per kategori
3. ✅ `GET /api/v1/capaian/detail/{idCapaian}` - Detail capaian per materi
4. ✅ `GET /api/v1/capaian/grafik-progress` - Grafik progress historis
### Model Semester (Referensi)
**File:** `app/Models/Semester.php`
**Kolom Database:**
- `is_active` (boolean) - Status aktif semester
- Scope helper: `scopeAktif()` untuk query semester aktif
```php
// ✅ CARA YANG BENAR
Semester::aktif()->first()
Semester::where('is_active', 1)->first()
// ❌ CARA YANG SALAH (kolom tidak ada)
Semester::where('status', 'Aktif')->first()
```
## 📝 Catatan Tambahan
### Data Testing
File `add_capaian_test_data.php` ditambahkan untuk membuat data testing dengan progress 6%.
### Logika Filtering
API hanya menghitung capaian dengan `persentase > 0` dalam statistik:
```php
$capaiansBerisi = $capaians->where('persentase', '>', 0);
```
Ini berarti capaian dengan 0 halaman selesai tidak akan muncul di statistik.
## 🔍 Checklist Verifikasi
- [x] Semester query diperbaiki di `ApiCapaianController`
- [x] Semester query diperbaiki di `DashboardController`
- [x] Model `Semester` scope `aktif()` digunakan dengan benar
- [x] API endpoint `capaian/overview` mengembalikan response 200
- [x] Struktur JSON response sesuai dengan model Flutter
- [x] Data testing ditambahkan dengan progress > 0%
- [x] Field `is_aktif` dalam list_semester bernilai boolean
## ✨ Kesimpulan
Masalah **berhasil diperbaiki** dengan mengubah query dari kolom `status` yang tidak ada menjadi `is_active` yang sesuai dengan struktur database.
Mobile app sekarang dapat:
- ✅ Mengambil overview capaian santri
- ✅ Melihat statistik per kategori
- ✅ Filter berdasarkan semester
- ✅ Menampilkan progress capaian
**Status:** Siap untuk testing di aplikasi mobile Flutter! 🚀

View File

@ -1,502 +0,0 @@
# DOKUMENTASI PERBAIKAN MENU PELANGGARAN
**Tanggal:** 9 Februari 2026
**Status:** ✅ SELESAI
---
## 🔧 MASALAH YANG DIPERBAIKI
### Error Kolom Database
**Error:**
```
SQLSTATE[42S22]: Column not found: 1054 Unknown column 'is_active' in 'where clause'
select * from `klasifikasi_pelanggarans` where `is_active` = 1 order by `urutan` asc, `nama_klasifikasi` asc
```
**Lokasi Error:**
- `RiwayatPelanggaranController::index()` - Line 80
- `KategoriPelanggaranController::index()` - Line 27
**Penyebab:**
Table `klasifikasi_pelanggarans` tidak memiliki kolom `is_active` dan `urutan` karena migration belum dijalankan.
---
## ✅ SOLUSI YANG DITERAPKAN
### 1. Update Migration Files
#### a. File: `2026_02_09_071146_create_klasifikasi_pelanggarans_table.php`
- ✅ Ditambahkan check `Schema::hasTable()` sebelum create table
- ✅ Mencegah error jika table sudah ada
#### b. File: `2026_02_09_071244_update_kategori_pelanggarans_add_klasifikasi_and_kafaroh.php`
- ✅ Ditambahkan check `Schema::hasColumn()` untuk setiap kolom
- ✅ Foreign key ditambahkan dengan try-catch untuk mencegah duplicate error
- ✅ Menghindari dependency pada Doctrine DBAL
#### c. File: `2026_02_09_071335_update_riwayat_pelanggarans_add_kafaroh_and_parent_fields.php`
- ✅ Ditambahkan check `Schema::hasColumn()` untuk semua kolom baru
- ✅ Foreign key ditambahkan dengan try-catch
- ✅ Index ditambahkan bersamaan dengan kolom
#### d. File: `2026_02_09_071441_create_pembinaan_sanksis_table.php`
- ✅ Ditambahkan check `Schema::hasTable()`
#### e. File (BARU): `2026_02_09_080305_add_missing_columns_to_klasifikasi_pelanggarans_table.php`
- ✅ Menambahkan kolom `deskripsi`, `is_active`, dan `urutan` yang hilang
- ✅ Menambahkan index untuk `is_active`
- ✅ Mencegah error jika kolom sudah ada
### 2. Jalankan Migration
```bash
php artisan migrate
```
**Hasil:**
```
✓ 2026_02_09_071146_create_klasifikasi_pelanggarans_table .......... DONE
✓ 2026_02_09_071244_update_kategori_pelanggarans_add_klasifikasi_and_kafaroh .. DONE
✓ 2026_02_09_071335_update_riwayat_pelanggarans_add_kafaroh_and_parent_fields . DONE
✓ 2026_02_09_071441_create_pembinaan_sanksis_table ................. DONE
✓ 2026_02_09_080305_add_missing_columns_to_klasifikasi_pelanggarans_table ..... DONE
```
### 3. Insert Data Sample
Sample data telah ditambahkan untuk testing:
- ✅ 4 Klasifikasi Pelanggaran:
- Pelanggaran Akhlaq
- Pelanggaran Ketertiban
- Pelanggaran Kerapian
- Pelanggaran Akademik
- ✅ 2 Kategori Pelanggaran sample
---
## 📊 STRUKTUR TABLE YANG DIHASILKAN
### Table: `klasifikasi_pelanggarans`
| Column | Type | Description |
|--------|------|-------------|
| id | bigint(20) unsigned | Primary key |
| id_klasifikasi | varchar(10) | ID format KL001, KL002, dst |
| nama_klasifikasi | varchar(100) | Nama klasifikasi |
| keterangan | text | Keterangan klasifikasi |
| deskripsi | text | Deskripsi klasifikasi |
| is_active | tinyint(1) | Status aktif/nonaktif ✅ |
| urutan | int(11) | Urutan tampilan ✅ |
| created_at | timestamp | - |
| updated_at | timestamp | - |
### Table: `kategori_pelanggarans` (Updated)
Added columns:
- ✅ `id_klasifikasi` - varchar(10) - Foreign key to klasifikasi_pelanggarans
- ✅ `kafaroh` - text - Kafaroh/Taqorrub yang harus dilakukan
- ✅ `is_active` - tinyint(1) - Status aktif/nonaktif
### Table: `riwayat_pelanggarans` (Updated)
Added columns:
- ✅ `is_kafaroh_selesai` - boolean - Status kafaroh
- ✅ `tanggal_kafaroh_selesai` - timestamp - Tanggal kafaroh diselesaikan
- ✅ `admin_kafaroh_id` - unsignedBigInteger - Admin yang menyelesaikan
- ✅ `catatan_kafaroh` - text - Catatan kafaroh
- ✅ `poin_asli` - integer - Poin asli sebelum dilebur
- ✅ `is_published_to_parent` - boolean - Status kirim ke wali
- ✅ `tanggal_published` - timestamp - Tanggal dikirim ke wali
- ✅ `admin_published_id` - unsignedBigInteger - Admin yang publish
### Table: `pembinaan_sanksis` (NEW)
| Column | Type | Description |
|--------|------|-------------|
| id | bigint unsigned | Primary key |
| id_pembinaan | varchar(10) | ID format PS001, PS002 |
| judul | varchar(255) | Judul pembinaan/sanksi |
| konten | text | Konten pembinaan (HTML supported) |
| urutan | int | Urutan tampilan |
| is_active | boolean | Status aktif/nonaktif |
| created_at | timestamp | - |
| updated_at | timestamp | - |
---
## 🎯 FITUR YANG SUDAH LENGKAP
### 1. Klasifikasi Pelanggaran
**Controller:** `KlasifikasiPelanggaranController.php`
**Routes:** `admin.klasifikasi-pelanggaran.*`
**Views:** ✅
- [x] index.blade.php
- [x] create.blade.php
- [x] edit.blade.php
- [x] show.blade.php
**Fitur:**
- [x] CRUD lengkap
- [x] Auto-generate ID (KL001, KL002, dst)
- [x] Urutan tampilan
- [x] Status aktif/nonaktif
- [x] Count jumlah pelanggaran per klasifikasi
- [x] Proteksi hapus jika masih digunakan
### 2. Kategori Pelanggaran
**Controller:** `KategoriPelanggaranController.php`
**Routes:** `admin.kategori-pelanggaran.*`
**Views:** ✅
- [x] index.blade.php
- [x] create.blade.php
- [x] edit.blade.php
- [x] show.blade.php
**Fitur:**
- [x] CRUD lengkap
- [x] Auto-generate ID (KP001, KP002, dst)
- [x] Relasi dengan Klasifikasi
- [x] Field Kafaroh/Taqorrub
- [x] Poin pelanggaran
- [x] Status aktif/nonaktif
- [x] Filter by klasifikasi & status
- [x] Proteksi hapus jika masih digunakan
### 3. Riwayat Pelanggaran
**Controller:** `RiwayatPelanggaranController.php` ✅ LENGKAP
**Routes:** `admin.riwayat-pelanggaran.*`
**Views:** ✅
- [x] index.blade.php
- [x] create.blade.php
- [x] edit.blade.php
- [x] show.blade.php
- [x] riwayat_santri.blade.php
**Fitur:**
- [x] CRUD lengkap
- [x] Auto-generate ID (P001, P002, dst)
- [x] Filter by santri, kategori, klasifikasi
- [x] Filter by status kafaroh
- [x] Filter by status publish
- [x] Filter by tanggal & bulan
- [x] **Selesaikan Kafaroh** dengan catatan
- [x] **Publish ke Wali Santri**
- [x] **Batalkan Publish ke Wali**
- [x] View riwayat per santri
- [x] Statistik dashboard
- [x] Poin dilebur jadi 0 setelah kafaroh selesai
**Methods Controller:**
1. ✅ `index()` - Daftar dengan filter lengkap
2. ✅ `create()` - Form tambah
3. ✅ `store()` - Simpan data
4. ✅ `show()` - Detail dengan riwayat lainnya
5. ✅ `edit()` - Form edit
6. ✅ `update()` - Update data
7. ✅ `destroy()` - Hapus data
8. ✅ `riwayatSantri()` - Riwayat per santri
9. ✅ `selesaikanKafaroh()` - Selesaikan kafaroh & lebur poin
10. ✅ `publishToParent()` - Kirim ke wali santri
11. ✅ `unpublishFromParent()` - Batalkan kirim ke wali
### 4. Pembinaan & Sanksi (CMS Fleksibel)
**Controller:** `PembinaanSanksiController.php`
**Routes:** `admin.pembinaan-sanksi.*`
**Views:** ✅
- [x] index.blade.php - List dengan preview & navigation
- [x] create.blade.php - Form dengan Quill.js Rich Text Editor
- [x] edit.blade.php - Form edit dengan Quill.js Rich Text Editor
- [x] show.blade.php - Display dengan HTML rendering & custom CSS
**🎨 Rich Text Editor: Quill.js 1.3.6**
- ✅ 100% Gratis - Tidak perlu API key
- ✅ Open Source (MIT License)
- ✅ Ringan (hanya ~50KB gzipped)
- ✅ WYSIWYG - What You See Is What You Get
- ✅ Mobile friendly dengan touch support
**Toolbar Editor:**
- Header (H1, H2, H3) untuk judul & sub judul
- Bold, Italic, Underline, Strike untuk format teks
- Text & Background Color untuk warna
- Bullet & Number List untuk daftar
- Align (Left, Center, Right, Justify)
- Link untuk hyperlink internal/eksternal
- Image untuk embed gambar via URL
- Clean untuk hapus format
**Fitur CMS:**
- [x] CRUD lengkap (Create, Read, Update, Delete)
- [x] Auto-generate ID (PS001, PS002, dst)
- [x] Konten tersimpan sebagai HTML (support rich formatting)
- [x] Urutan tampilan (sortable)
- [x] Status aktif/nonaktif
- [x] Preview konten dengan styling custom
- [x] Form validation (tidak bisa submit konten kosong)
- [x] Info box dengan tips penggunaan editor
---
## 🔗 ROUTES YANG SUDAH TERDAFTAR
### Klasifikasi Pelanggaran
```php
Route::resource('klasifikasi-pelanggaran', KlasifikasiPelanggaranController::class);
```
### Kategori Pelanggaran
```php
Route::resource('kategori-pelanggaran', KategoriPelanggaranController::class);
```
### Riwayat Pelanggaran
```php
Route::resource('riwayat-pelanggaran', RiwayatPelanggaranController::class);
Route::prefix('riwayat-pelanggaran')->name('riwayat-pelanggaran.')->group(function () {
Route::get('santri/{id_santri}', [RiwayatPelanggaranController::class, 'riwayatSantri'])
->name('riwayat-santri');
Route::post('/{riwayatPelanggaran}/selesaikan-kafaroh', [RiwayatPelanggaranController::class, 'selesaikanKafaroh'])
->name('selesaikan-kafaroh');
Route::post('/{riwayatPelanggaran}/publish-to-parent', [RiwayatPelanggaranController::class, 'publishToParent'])
->name('publish-to-parent');
Route::post('/{riwayatPelanggaran}/unpublish-from-parent', [RiwayatPelanggaranController::class, 'unpublishFromParent'])
->name('unpublish-from-parent');
});
```
### Pembinaan & Sanksi
```php
Route::resource('pembinaan-sanksi', PembinaanSanksiController::class);
```
---
## 🧪 CARA TESTING
### 1. Akses Menu Klasifikasi Pelanggaran
```
http://localhost/TugasAkhir/sim-pkpps/public/admin/klasifikasi-pelanggaran
```
✅ Harus bisa akses tanpa error
### 2. Akses Menu Kategori Pelanggaran
```
http://localhost/TugasAkhir/sim-pkpps/public/admin/kategori-pelanggaran
```
✅ Harus bisa akses tanpa error
✅ Dropdown klasifikasi terisi
### 3. Akses Menu Riwayat Pelanggaran
```
http://localhost/TugasAkhir/sim-pkpps/public/admin/riwayat-pelanggaran
```
✅ Harus bisa akses tanpa error
✅ Filter klasifikasi, status kafaroh, dan status publish berfungsi
### 4. Test Fitur Kafaroh
1. Buat riwayat pelanggaran baru
2. Buka detail riwayat
3. Klik "Selesaikan Kafaroh"
4. Isi catatan (opsional)
5. Submit
6. ✅ Poin harus menjadi 0
7. ✅ Status kafaroh menjadi "Selesai"
### 5. Test Fitur Publish ke Wali
1. Buka detail riwayat pelanggaran
2. Klik "Kirim ke Wali Santri"
3. ✅ Status publish menjadi "Terkirim"
4. Klik "Batalkan Kirim ke Wali"
5. ✅ Status publish kembali "Belum Terkirim"
### 6. Test Fitur Pembinaan & Sanksi (CMS)
```
http://localhost/TugasAkhir/sim-pkpps/public/admin/pembinaan-sanksi
```
**Test Create:**
1. Klik "Tambah Konten"
2. ✅ Quill.js editor muncul tanpa API key warning
3. Isi judul dan konten (coba bold, italic, heading, list)
4. Klik "Simpan"
5. ✅ Konten tersimpan dengan formatting
**Test Edit:**
1. Klik "Edit" pada konten
2. ✅ Konten muncul di editor dengan formatting utuh
3. Ubah konten
4. Klik "Update"
5. ✅ Perubahan tersimpan
**Test View:**
1. Klik "Lihat Detail"
2. ✅ Konten tampil dengan HTML formatting
3. ✅ Custom CSS styling teraplikasi (heading, list, alignment)
**Test Features:**
- ✅ Bold & Italic berfungsi
- ✅ Header H1, H2, H3 berfungsi
- ✅ Bullet & Number list berfungsi
- ✅ Text alignment berfungsi
- ✅ Color picker berfungsi
- ✅ Link & Image embed berfungsi
---
## 📝 MODEL YANG DIGUNAKAN
### 1. KlasifikasiPelanggaran
- ✅ Auto-generate ID
- ✅ `scopeAktif()` - Filter aktif
- ✅ `scopeByUrutan()` - Sort by urutan
- ✅ Relasi `hasMany` ke KategoriPelanggaran
### 2. KategoriPelanggaran
- ✅ Auto-generate ID
- ✅ `scopeAktif()` - Filter aktif
- ✅ `scopeByKlasifikasi()` - Filter by klasifikasi
- ✅ Relasi `belongsTo` ke KlasifikasiPelanggaran
- ✅ Relasi `hasMany` ke RiwayatPelanggaran
- ✅ Accessor `getNamaLengkapAttribute()`
### 3. RiwayatPelanggaran
- ✅ Auto-generate ID
- ✅ Auto-set `poin_asli` saat created
- ✅ Multiple Scopes:
- `scopeBySantri()`
- `scopeByKategori()`
- `scopeByTanggal()`
- `scopeBulanIni()`
- `scopeTerbaru()`
- `scopeKafarohSelesai()`
- `scopeKafarohBelumSelesai()`
- `scopePublishedToParent()`
- `scopeNotPublishedToParent()`
- `scopeSearch()`
- ✅ Relasi:
- `belongsTo` Santri
- `belongsTo` KategoriPelanggaran
- `belongsTo` User (adminKafaroh)
- `belongsTo` User (adminPublished)
- ✅ Accessors:
- `getTanggalFormatAttribute()`
- `getStatusKafarohAttribute()`
- `getStatusPublishAttribute()`
### 4. PembinaanSanksi
- ✅ Auto-generate ID (PS001, PS002, dst)
- ✅ `scopeAktif()` - Filter aktif
- ✅ `scopeByUrutan()` - Sort by urutan
- ✅ Support HTML content untuk rich text formatting
- ✅ Integration dengan Quill.js Rich Text Editor
- ✅ Custom CSS styling untuk tampilan konten
---
## 🎉 KESIMPULAN
### Status Perbaikan: ✅ BERHASIL
**Yang telah diperbaiki:**
1. ✅ Error kolom database (`is_active`, `urutan`, `deskripsi`)
2. ✅ Migration files updated dengan column checks
3. ✅ Semua migration berhasil dijalankan
4. ✅ Sample data tersedia untuk testing
5. ✅ Semua controller lengkap dan berfungsi
6. ✅ Semua routes terdaftar
7. ✅ Semua views tersedia dan lengkap
8. ✅ Fitur kafaroh berfungsi (lebur poin jadi 0)
9. ✅ Fitur publish ke wali berfungsi
10. ✅ Model dengan relasi dan scopes lengkap
11. ✅ CMS Pembinaan & Sanksi dengan Quill.js Rich Text Editor
12. ✅ No API key requirement (100% gratis)
**Menu Pelanggaran yang sudah lengkap:**
1. ✅ Klasifikasi Pelanggaran (CRUD)
2. ✅ Kategori Pelanggaran (CRUD + Kafaroh)
3. ✅ Riwayat Pelanggaran (CRUD + Kafaroh + Publish)
4. ✅ Pembinaan & Sanksi (CMS dengan Rich Text Editor)
**Teknologi yang digunakan:**
- Laravel 10.x untuk backend framework
- Blade Templates untuk views
- MySQL untuk database
- Quill.js 1.3.6 untuk Rich Text Editor (no API key!)
- CDN-based libraries (zero installation required)
**Tidak ada error lagi!** 🎊
---
## 📚 DOKUMENTASI TAMBAHAN
### Cara Menambah Klasifikasi Baru:
1. Login sebagai Admin
2. Menu: Klasifikasi Pelanggaran → Tambah Klasifikasi
3. Isi nama, deskripsi, dan urutan
4. Sistem otomatis generate ID (KL001, KL002, dst)
### Cara Menambah Kategori Pelanggaran:
1. Menu: Master Pelanggaran → Tambah Pelanggaran
2. Pilih klasifikasi
3. Isi nama pelanggaran, poin, dan kafaroh
4. Sistem otomatis generate ID (KP001, KP002, dst)
### Cara Input Riwayat Pelanggaran:
1. Menu: Riwayat Pelanggaran → Tambah Riwayat
2. Pilih santri
3. Pilih klasifikasi → kategori akan difilter otomatis
4. Pilih kategori → poin ditarik otomatis
5. Isi tanggal dan keterangan (opsional)
6. Submit
### Cara Selesaikan Kafaroh:
1. Buka detail riwayat pelanggaran
2. Klik "Selesaikan Kafaroh"
3. Isi catatan (opsional)
4. Poin otomatis menjadi 0
5. Admin yang menyelesaikan tercatat
### Cara Publish ke Wali:
1. Buka detail riwayat pelanggaran
2. Klik "Kirim ke Wali Santri"
3. Konfirmasi
4. Status berubah menjadi "Terkirim"
5. Admin yang publish tercatat
### Cara Mengelola Konten Pembinaan & Sanksi:
**Tambah Konten Baru:**
1. Menu: Pembinaan & Sanksi → Tambah Konten
2. Isi judul (misal: "Tata Tertib Santri")
3. Gunakan editor Quill.js untuk membuat konten:
- Klik H1/H2/H3 untuk heading
- Bold/Italic untuk penekanan
- Klik bullet/number untuk daftar
- Pilih warna untuk highlight
- Gunakan align untuk rata kiri/tengah/kanan
4. Set urutan tampilan
5. Klik "Simpan"
6. Sistem otomatis generate ID (PS001, PS002, dst)
**Edit Konten:**
1. Klik "Edit" pada konten yang ingin diubah
2. Konten akan muncul di editor dengan formatting utuh
3. Ubah sesuai kebutuhan
4. Klik "Update"
**Lihat Detail:**
1. Klik "Lihat Detail"
2. Konten tampil dengan HTML formatting lengkap
3. Custom CSS styling teraplikasi otomatis
**Tips Menggunakan Editor:**
- **Header:** Gunakan H1 untuk judul utama, H2 untuk sub judul, H3 untuk sub-sub judul
- **List:** Gunakan bullet list untuk poin-poin, number list untuk langkah-langkah
- **Bold/Italic:** Gunakan untuk penekanan kata penting
- **Color:** Gunakan dengan bijak, jangan terlalu banyak warna
- **Alignment:** Sesuaikan dengan kebutuhan layout (biasanya left)
- **Link:** Bisa link ke halaman lain atau website eksternal
- **Image:** Masukkan URL gambar (harus online/CDN)
---
**Dibuat oleh:** GitHub Copilot
**Verified:** ✅ All Tests Passed

View File

@ -1,249 +0,0 @@
# 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

@ -1,215 +0,0 @@
# 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

@ -1,166 +0,0 @@
# 🔧 FIX: "Koneksi Gagal" di Mobile App
## 🎯 Masalah
Aplikasi Flutter menampilkan error: **"Koneksi gagal, periksa internet Anda"**
## ✅ Solusi Sudah Diterapkan
File `app_config.dart` sudah diupdate dengan IP komputer Anda: **10.130.244.240**
---
## 📱 LANGKAH-LANGKAH FIX
### 1⃣ Pastikan Device & Komputer di WiFi yang Sama
**PENTING!** HP dan komputer harus terhubung ke WiFi yang sama.
Cek WiFi:
- Komputer: Lihat icon WiFi di taskbar
- HP: Settings → WiFi → lihat nama network
### 2⃣ Test Koneksi dari HP
**A. Buka Browser di HP, akses:**
```
http://10.130.244.240/TugasAkhir/test_mobile_api.html
```
**B. Klik tombol:**
- "Test Koneksi Server" → harus muncul ✅ KONEKSI BERHASIL
- "Test Login API" → harus muncul ✅ LOGIN BERHASIL
**Jika halaman tidak bisa dibuka:**
→ Lanjut ke Step 3 (Windows Firewall)
### 3⃣ Fix Windows Firewall
Windows Firewall mungkin memblokir koneksi dari HP.
**Cara 1: Izinkan Apache (Recommended)**
1. Buka Command Prompt **as Administrator**
2. Jalankan:
```cmd
netsh advfirewall firewall add rule name="Apache HTTP" dir=in action=allow protocol=TCP localport=80
netsh advfirewall firewall add rule name="Apache HTTPS" dir=in action=allow protocol=TCP localport=443
```
**Cara 2: Matikan Firewall Sementara (untuk testing)**
1. Windows Settings → Update & Security → Windows Security
2. Firewall & network protection
3. Private network → Turn off (HANYA untuk testing!)
4. Setelah berhasil, nyalakan lagi dan gunakan Cara 1
### 4⃣ Restart Flutter App
Setelah test koneksi berhasil:
```bash
cd c:\xampp\htdocs\TugasAkhir\sim_mobile
flutter clean
flutter run
```
**PENTING:** Harus **Hot Restart** (bukan hot reload!)
- VS Code: Klik icon 🔄
- Android Studio: Klik lightning bolt hijau
### 5⃣ Test Login
**Gunakan credentials:**
- Username: `Aydin Fauzan`
- Password: `s002`
---
## 🐛 Troubleshooting
### Error: "Halaman test_mobile_api.html tidak bisa dibuka"
**Penyebab:** Firewall atau WiFi berbeda
**Solusi:**
1. Ping dari HP ke komputer:
- Install app "Network Utilities" atau "Fing"
- Ping ke: 10.130.244.240
- Jika timeout → WiFi berbeda atau Firewall
2. Cek XAMPP Apache:
- Buka XAMPP Control Panel
- Pastikan Apache **running** (hijau)
### Error: "Test koneksi berhasil, tapi login gagal"
**Penyebab:** API atau database bermasalah
**Solusi:**
Test dari komputer dulu:
```bash
$body = '{"id_santri":"Aydin Fauzan","password":"s002"}'
Invoke-RestMethod -Uri "http://localhost/TugasAkhir/sim-pkpps/public/api/v1/login" -Method POST -ContentType "application/json" -Body $body
```
Jika ini gagal:
- Cek routes: `php artisan route:list --name=login`
- Cek database connection
- Cek Laravel log: `sim-pkpps/storage/logs/laravel.log`
### Error: "Flutter masih error 'koneksi gagal'"
**Penyebab:** Config tidak ter-reload
**Solusi:**
1. Stop Flutter app (Shift+F5)
2. Jalankan:
```bash
flutter clean
flutter pub get
flutter run
```
3. Atau uninstall app dari HP, install ulang
---
## 🔍 Cek IP Komputer Berubah
Jika IP komputer berubah (setelah restart/ganti WiFi):
1. Cek IP baru:
```bash
ipconfig | findstr IPv4
```
2. Update `app_config.dart`:
```dart
static const String baseUrl = 'http://[IP_BARU]/TugasAkhir/sim-pkpps/public/api/v1';
```
3. Hot restart Flutter
---
## ✅ Checklist Final
- [ ] HP dan komputer di WiFi yang sama
- [ ] XAMPP Apache running
- [ ] Firewall rule untuk Apache sudah dibuat
- [ ] Test koneksi dari HP berhasil (test_mobile_api.html)
- [ ] Flutter app sudah hot restart
- [ ] Login dengan username & password yang benar
---
## 📞 Masih Error?
Kirim screenshot:
1. Error di Flutter (terminal log)
2. Hasil test dari test_mobile_api.html
3. XAMPP Control Panel (Apache status)
4. WiFi settings (HP dan komputer)
---
**IP Komputer Anda: 10.130.244.240**
**Test Page: http://10.130.244.240/TugasAkhir/test_mobile_api.html**

View File

@ -1,905 +0,0 @@
# Santri.kelas Usage Mapping
_Generated: 2026-02-12 16:30:36_
This document maps all usage of `$santri->kelas` and related patterns in the codebase to guide refactoring to the new kelas system.
---
## 📊 Summary
- **Total files with kelas usage:** 40
- **Total matches found:** 115
---
## 🎯 Priority Levels
### 🔴 HIGH Priority (Break functionality)
- **app/Http/Controllers/Admin/CapaianController.php**
- Issue: Query filtering by kelas column
- Action Required: Update to use kelasSantri relationship
- **app/Http/Controllers/Admin/SantriController.php**
- Issue: Query filtering by kelas column
- Action Required: Update to use kelasSantri relationship
- **database/migrations/2025_09_29_033444_create_santris_table.php**
- Issue: Database schema definition
- Action Required: Review but DO NOT modify old migrations
- **database/migrations/2025_10_31_064743_create_materi_table.php**
- Issue: Database schema definition
- Action Required: Review but DO NOT modify old migrations
### 🟡 MEDIUM Priority (UI/Display)
- **app/Models/Materi.php**
- Issue: Model attribute or accessor
- Action Required: Review accessor implementation
- **app/Models/Santri.php**
- Issue: Model attribute or accessor
- Action Required: Review accessor implementation
- **resources/views/admin/berita/show.blade.php**
- Issue: Display kelas in UI
- Action Required: Change to use $santri->kelas_name accessor
- **resources/views/admin/capaian/create.blade.php**
- Issue: Display kelas in UI
- Action Required: Change to use $santri->kelas_name accessor
- **resources/views/admin/capaian/export-rapor.blade.php**
- Issue: Display kelas in UI
- Action Required: Change to use $santri->kelas_name accessor
- **resources/views/admin/capaian/index.blade.php**
- Issue: Display kelas in UI
- Action Required: Change to use $santri->kelas_name accessor
- **resources/views/admin/capaian/riwayat-santri.blade.php**
- Issue: Display kelas in UI
- Action Required: Change to use $santri->kelas_name accessor
- **resources/views/admin/kegiatan/absensi/input.blade.php**
- Issue: Display kelas in UI
- Action Required: Change to use $santri->kelas_name accessor
- **resources/views/admin/kegiatan/kartu/cetak.blade.php**
- Issue: Display kelas in UI
- Action Required: Change to use $santri->kelas_name accessor
- **resources/views/admin/kegiatan/kartu/daftar.blade.php**
- Issue: Display kelas in UI
- Action Required: Change to use $santri->kelas_name accessor
- **resources/views/admin/kegiatan/kartu/index.blade.php**
- Issue: Display kelas in UI
- Action Required: Change to use $santri->kelas_name accessor
- **resources/views/admin/kegiatan/riwayat/detail-santri.blade.php**
- Issue: Display kelas in UI
- Action Required: Change to use $santri->kelas_name accessor
- **resources/views/admin/kepulangan/create.blade.php**
- Issue: Display kelas in UI
- Action Required: Change to use $santri->kelas_name accessor
- **resources/views/admin/kepulangan/over-limit.blade.php**
- Issue: Display kelas in UI
- Action Required: Change to use $santri->kelas_name accessor
- **resources/views/admin/kepulangan/surat-pdf.blade.php**
- Issue: Display kelas in UI
- Action Required: Change to use $santri->kelas_name accessor
- **resources/views/admin/kesehatan-santri/riwayat.blade.php**
- Issue: Display kelas in UI
- Action Required: Change to use $santri->kelas_name accessor
- **resources/views/admin/pembayaran-spp/create.blade.php**
- Issue: Display kelas in UI
- Action Required: Change to use $santri->kelas_name accessor
- **resources/views/admin/pembayaran-spp/edit.blade.php**
- Issue: Display kelas in UI
- Action Required: Change to use $santri->kelas_name accessor
- **resources/views/admin/riwayat_pelanggaran/riwayat_santri.blade.php**
- Issue: Display kelas in UI
- Action Required: Change to use $santri->kelas_name accessor
- **resources/views/admin/santri/form.blade.php**
- Issue: Display kelas in UI
- Action Required: Change to use $santri->kelas_name accessor
- **resources/views/admin/santri/index.blade.php**
- Issue: Display kelas in UI
- Action Required: Change to use $santri->kelas_name accessor
- **resources/views/admin/santri/show.blade.php**
- Issue: Display kelas in UI
- Action Required: Change to use $santri->kelas_name accessor
- **resources/views/admin/users/wali_accounts.blade.php**
- Issue: Display kelas in UI
- Action Required: Change to use $santri->kelas_name accessor
- **resources/views/santri/berita/index.blade.php**
- Issue: Display kelas in UI
- Action Required: Change to use $santri->kelas_name accessor
- **resources/views/santri/capaian/index.blade.php**
- Issue: Display kelas in UI
- Action Required: Change to use $santri->kelas_name accessor
- **resources/views/santri/kegiatan/index.blade.php**
- Issue: Display kelas in UI
- Action Required: Change to use $santri->kelas_name accessor
### 🟢 LOW Priority (Backward compatible)
- **app/Http/Controllers/Admin/AbsensiKegiatanController.php**
- Note: Other usage
- **app/Http/Controllers/Admin/BeritaController.php**
- Note: Other usage
- **app/Http/Controllers/Admin/MateriController.php**
- Note: Other usage
- **app/Http/Controllers/Admin/PembayaranSppController.php**
- Note: Other usage
- **app/Http/Controllers/Api/ApiAuthController.php**
- Note: Other usage
- **app/Http/Controllers/Api/ApiBeritaController.php**
- Note: Other usage
- **app/Http/Controllers/Api/ApiCapaianController.php**
- Note: Other usage
- **app/Http/Controllers/DashboardController.php**
- Note: Other usage
- **app/Http/Controllers/Santri/SantriBeritaController.php**
- Note: Other usage
- **database/seeders/KelasSeeder.php**
- Note: Other usage
---
## 📂 Detailed Listing by Directory
### App / Http / Controllers
#### 📄 `app/Http/Controllers/Admin/AbsensiKegiatanController.php`
**Pattern: `property_access`**
- **Line 179:** `'kelas' => $santri->kelas,`
**Pattern: `kelas_column`**
- **Line 179:** `'kelas' => $santri->kelas,`
**💡 Suggested Action:**
Review usage and update as needed based on context.
---
#### 📄 `app/Http/Controllers/Admin/BeritaController.php`
**Pattern: `enum_values`**
- **Line 51:** `$kelasOptions = ['PB', 'Lambatan', 'Cepatan'];`
- **Line 127:** `$kelasOptions = ['PB', 'Lambatan', 'Cepatan'];`
**💡 Suggested Action:**
Review usage and update as needed based on context.
---
#### 📄 `app/Http/Controllers/Admin/CapaianController.php`
**Pattern: `where_kelas`**
- **Line 35:** `$query->where('kelas', $selectedKelas);`
- **Line 344:** `->when($kelas, fn($q) => $q->where('kelas', $kelas))`
- **Line 347:** `->when($kelas, fn($q) => $q->where('kelas', $kelas))`
- **Line 352:** `->when($kelas, fn($q) => $q->whereHas('santri', fn($sq) => $sq->where('kelas', $kelas)))`
- **Line 393:** `$kelasMateris = $materis->where('kelas', $k);`
- **Line 463:** `$filteredMateris = $kelas ? $materis->where('kelas', $kelas) : $materis;`
- **Line 480:** `$heatmapMateris = $kelas ? $materis->where('kelas', $kelas)->values() : $materis->take(15)->values();`
- **Line 835:** `$q->where('kelas', $kelas);`
- **Line 896:** `$query->where('kelas', $kelas);`
**Pattern: `property_access`**
- **Line 116:** `$materis = Materi::where('kelas', $santri->kelas)`
- **Line 123:** `'kelas' => $santri->kelas,`
- **Line 453:** `'kelas' => $santri->kelas,`
- **Line 484:** `$row = ['nama' => $santri->nama_lengkap, 'id_santri' => $santri->id_santri, 'kelas' => $santri->kelas];`
**Pattern: `kelas_column`**
- **Line 123:** `'kelas' => $santri->kelas,`
- **Line 453:** `'kelas' => $santri->kelas,`
- **Line 484:** `$row = ['nama' => $santri->nama_lengkap, 'id_santri' => $santri->id_santri, 'kelas' => $santri->kelas];`
**Pattern: `enum_values`**
- **Line 341:** `$kelasList = ['Lambatan', 'Cepatan', 'PB'];`
- **Line 708:** `$kelas = $request->input('kelas', 'Lambatan');`
**💡 Suggested Action:**
1. Replace `where('kelas')` with `whereHas('kelasSantri')`
2. Update query to use kelas ID instead of name
3. Test filter functionality thoroughly
---
#### 📄 `app/Http/Controllers/Admin/MateriController.php`
**Pattern: `kelas_column`**
- **Line 82:** `'kelas' => 'required|in:Lambatan,Cepatan,PB',`
- **Line 156:** `'kelas' => 'required|in:Lambatan,Cepatan,PB',`
**💡 Suggested Action:**
Review usage and update as needed based on context.
---
#### 📄 `app/Http/Controllers/Admin/PembayaranSppController.php`
**Pattern: `property_access`**
- **Line 54:** `'kelas' => $santri->kelas,`
**Pattern: `kelas_column`**
- **Line 54:** `'kelas' => $santri->kelas,`
**💡 Suggested Action:**
Review usage and update as needed based on context.
---
#### 📄 `app/Http/Controllers/Admin/SantriController.php`
**Pattern: `where_kelas`**
- **Line 38:** `$query->where('kelas', $request->kelas);`
**Pattern: `kelas_column`**
- **Line 86:** `'kelas' => 'required|in:PB,Lambatan,Cepatan',`
- **Line 154:** `'kelas' => 'required|in:PB,Lambatan,Cepatan',`
**💡 Suggested Action:**
1. Replace `where('kelas')` with `whereHas('kelasSantri')`
2. Update query to use kelas ID instead of name
3. Test filter functionality thoroughly
---
#### 📄 `app/Http/Controllers/Api/ApiAuthController.php`
**Pattern: `property_access`**
- **Line 158:** `'kelas' => $santri->kelas,`
**Pattern: `kelas_column`**
- **Line 158:** `'kelas' => $santri->kelas,`
**💡 Suggested Action:**
Review usage and update as needed based on context.
---
#### 📄 `app/Http/Controllers/Api/ApiBeritaController.php`
**Pattern: `property_access`**
- **Line 42:** `->whereJsonContains('target_kelas', $santri->kelas);`
- **Line 146:** `$bolehAkses = in_array($santri->kelas, $berita->target_kelas ?? []);`
**💡 Suggested Action:**
Review usage and update as needed based on context.
---
#### 📄 `app/Http/Controllers/Api/ApiCapaianController.php`
**Pattern: `property_access`**
- **Line 125:** `'kelas' => $santri->kelas,`
- **Line 490:** `->where('santris.kelas', $santri->kelas)`
- **Line 523:** `->where('santris.kelas', $santri->kelas)`
- **Line 591:** `'kelas' => $santri->kelas,`
**Pattern: `kelas_column`**
- **Line 125:** `'kelas' => $santri->kelas,`
- **Line 295:** `'kelas' => $capaian->materi->kelas,`
- **Line 591:** `'kelas' => $santri->kelas,`
**💡 Suggested Action:**
Review usage and update as needed based on context.
---
#### 📄 `app/Http/Controllers/DashboardController.php`
**Pattern: `property_access`**
- **Line 204:** `'kelas' => $santri->kelas,`
- **Line 251:** `->whereJsonContains('target_kelas', $santri->kelas);`
**Pattern: `kelas_column`**
- **Line 204:** `'kelas' => $santri->kelas,`
**💡 Suggested Action:**
Review usage and update as needed based on context.
---
#### 📄 `app/Http/Controllers/Santri/SantriBeritaController.php`
**Pattern: `property_access`**
- **Line 44:** `->whereJsonContains('target_kelas', $santri->kelas);`
- **Line 89:** `->whereJsonContains('target_kelas', $santri->kelas);`
**💡 Suggested Action:**
Review usage and update as needed based on context.
---
### App / Models
#### 📄 `app/Models/Materi.php`
**Pattern: `where_kelas`**
- **Line 80:** `return $query->where('kelas', $kelas);`
**💡 Suggested Action:**
1. Review model methods and accessors
2. Ensure backward compatibility
3. Add tests for new relations
---
#### 📄 `app/Models/Santri.php`
**Pattern: `enum_values`**
- **Line 177:** `'Lambatan' => 'Lambatan',`
- **Line 178:** `'Cepatan' => 'Cepatan',`
**Pattern: `where_kelas`**
- **Line 306:** `return $query->where('kelas', $kelas);`
**💡 Suggested Action:**
1. Review model methods and accessors
2. Ensure backward compatibility
3. Add tests for new relations
---
### Resources / views
#### 📄 `resources/views/admin/berita/show.blade.php`
**Pattern: `property_access`**
- **Line 130:** `<i class="fas fa-graduation-cap"></i> {{ $santri->kelas }}`
**Pattern: `blade_kelas`**
- **Line 130:** `<i class="fas fa-graduation-cap"></i> {{ $santri->kelas }}`
**💡 Suggested Action:**
1. Replace `{{ $santri->kelas }}` with `{{ $santri->kelas_name }}`
2. Test display in browser
---
#### 📄 `resources/views/admin/capaian/create.blade.php`
**Pattern: `property_access`**
- **Line 25:** `data-kelas="{{ $santri->kelas }}"`
- **Line 27:** `{{ $santri->nama_lengkap }} ({{ $santri->nis }}) - Kelas: {{ $santri->kelas }}`
**Pattern: `blade_kelas`**
- **Line 25:** `data-kelas="{{ $santri->kelas }}"`
- **Line 27:** `{{ $santri->nama_lengkap }} ({{ $santri->nis }}) - Kelas: {{ $santri->kelas }}`
**💡 Suggested Action:**
1. Replace `{{ $santri->kelas }}` with `{{ $santri->kelas_name }}`
2. Test display in browser
---
#### 📄 `resources/views/admin/capaian/export-rapor.blade.php`
**Pattern: `property_access`**
- **Line 96:** `<div class="info-item"><span class="label">Kelas</span> <span class="value">{{ $santri->kelas }}</span></div>`
**Pattern: `blade_kelas`**
- **Line 96:** `<div class="info-item"><span class="label">Kelas</span> <span class="value">{{ $santri->kelas }}</span></div>`
**💡 Suggested Action:**
1. Replace `{{ $santri->kelas }}` with `{{ $santri->kelas_name }}`
2. Test display in browser
---
#### 📄 `resources/views/admin/capaian/index.blade.php`
**Pattern: `enum_values`**
- **Line 38:** `<a href="{{ route('admin.capaian.index', array_merge(request()->except('kelas'), ['kelas' => 'PB'])) }}"`
- **Line 43:** `<a href="{{ route('admin.capaian.index', array_merge(request()->except('kelas'), ['kelas' => 'Lambatan'])) }}"`
- **Line 48:** `<a href="{{ route('admin.capaian.index', array_merge(request()->except('kelas'), ['kelas' => 'Cepatan'])) }}"`
- **Line 112:** `@if($data['santri']->kelas == 'PB')`
- **Line 114:** `@elseif($data['santri']->kelas == 'Lambatan')`
**Pattern: `kelas_column`**
- **Line 38:** `<a href="{{ route('admin.capaian.index', array_merge(request()->except('kelas'), ['kelas' => 'PB'])) }}"`
- **Line 43:** `<a href="{{ route('admin.capaian.index', array_merge(request()->except('kelas'), ['kelas' => 'Lambatan'])) }}"`
- **Line 48:** `<a href="{{ route('admin.capaian.index', array_merge(request()->except('kelas'), ['kelas' => 'Cepatan'])) }}"`
**💡 Suggested Action:**
Review usage and update as needed based on context.
---
#### 📄 `resources/views/admin/capaian/riwayat-santri.blade.php`
**Pattern: `property_access`**
- **Line 18:** `<strong>Kelas:</strong> <span class="badge badge-secondary">{{ $santri->kelas }}</span>`
**Pattern: `blade_kelas`**
- **Line 18:** `<strong>Kelas:</strong> <span class="badge badge-secondary">{{ $santri->kelas }}</span>`
**💡 Suggested Action:**
1. Replace `{{ $santri->kelas }}` with `{{ $santri->kelas_name }}`
2. Test display in browser
---
#### 📄 `resources/views/admin/kegiatan/absensi/input.blade.php`
**Pattern: `property_access`**
- **Line 63:** `<td><span class="badge badge-secondary">{{ $santri->kelas }}</span></td>`
**Pattern: `blade_kelas`**
- **Line 63:** `<td><span class="badge badge-secondary">{{ $santri->kelas }}</span></td>`
**💡 Suggested Action:**
1. Replace `{{ $santri->kelas }}` with `{{ $santri->kelas_name }}`
2. Test display in browser
---
#### 📄 `resources/views/admin/kegiatan/kartu/cetak.blade.php`
**Pattern: `property_access`**
- **Line 423:** `<span class="value">: @if(isset($santri)){{ $santri->kelas }}@else Lambatan @endif</span>`
**Pattern: `blade_kelas`**
- **Line 423:** `<span class="value">: @if(isset($santri)){{ $santri->kelas }}@else Lambatan @endif</span>`
**💡 Suggested Action:**
1. Replace `{{ $santri->kelas }}` with `{{ $santri->kelas_name }}`
2. Test display in browser
---
#### 📄 `resources/views/admin/kegiatan/kartu/daftar.blade.php`
**Pattern: `property_access`**
- **Line 29:** `<td><span class="badge badge-secondary">{{ $santri->kelas }}</span></td>`
**Pattern: `blade_kelas`**
- **Line 29:** `<td><span class="badge badge-secondary">{{ $santri->kelas }}</span></td>`
**💡 Suggested Action:**
1. Replace `{{ $santri->kelas }}` with `{{ $santri->kelas_name }}`
2. Test display in browser
---
#### 📄 `resources/views/admin/kegiatan/kartu/index.blade.php`
**Pattern: `property_access`**
- **Line 60:** `<td><span class="badge badge-secondary">{{ $santri->kelas }}</span></td>`
**Pattern: `blade_kelas`**
- **Line 60:** `<td><span class="badge badge-secondary">{{ $santri->kelas }}</span></td>`
**💡 Suggested Action:**
1. Replace `{{ $santri->kelas }}` with `{{ $santri->kelas_name }}`
2. Test display in browser
---
#### 📄 `resources/views/admin/kegiatan/riwayat/detail-santri.blade.php`
**Pattern: `property_access`**
- **Line 15:** `Kelas: <strong>{{ $santri->kelas }}</strong> |`
**Pattern: `blade_kelas`**
- **Line 15:** `Kelas: <strong>{{ $santri->kelas }}</strong> |`
**💡 Suggested Action:**
1. Replace `{{ $santri->kelas }}` with `{{ $santri->kelas_name }}`
2. Test display in browser
---
#### 📄 `resources/views/admin/kepulangan/create.blade.php`
**Pattern: `property_access`**
- **Line 49:** `{{ $santri->nama_lengkap }} ({{ $santri->id_santri }} - {{ $santri->kelas }})`
**Pattern: `blade_kelas`**
- **Line 49:** `{{ $santri->nama_lengkap }} ({{ $santri->id_santri }} - {{ $santri->kelas }})`
**💡 Suggested Action:**
1. Replace `{{ $santri->kelas }}` with `{{ $santri->kelas_name }}`
2. Test display in browser
---
#### 📄 `resources/views/admin/kepulangan/over-limit.blade.php`
**Pattern: `property_access`**
- **Line 78:** `<td>{{ $santri->kelas }}</td>`
**Pattern: `blade_kelas`**
- **Line 78:** `<td>{{ $santri->kelas }}</td>`
**💡 Suggested Action:**
1. Replace `{{ $santri->kelas }}` with `{{ $santri->kelas_name }}`
2. Test display in browser
---
#### 📄 `resources/views/admin/kepulangan/surat-pdf.blade.php`
**Pattern: `property_access`**
- **Line 269:** `<div class="data-value">{{ $santri->kelas }}</div>`
- **Line 394:** `<td style="padding: 5px;">: {{ $santri->kelas }}</td>`
**Pattern: `blade_kelas`**
- **Line 269:** `<div class="data-value">{{ $santri->kelas }}</div>`
- **Line 394:** `<td style="padding: 5px;">: {{ $santri->kelas }}</td>`
**💡 Suggested Action:**
1. Replace `{{ $santri->kelas }}` with `{{ $santri->kelas_name }}`
2. Test display in browser
---
#### 📄 `resources/views/admin/kesehatan-santri/riwayat.blade.php`
**Pattern: `property_access`**
- **Line 21:** `<strong>Kelas:</strong> {{ $santri->kelas }}<br>`
**Pattern: `blade_kelas`**
- **Line 21:** `<strong>Kelas:</strong> {{ $santri->kelas }}<br>`
**💡 Suggested Action:**
1. Replace `{{ $santri->kelas }}` with `{{ $santri->kelas_name }}`
2. Test display in browser
---
#### 📄 `resources/views/admin/pembayaran-spp/create.blade.php`
**Pattern: `property_access`**
- **Line 35:** `{{ $santri->id_santri }} - {{ $santri->nama_lengkap }} ({{ $santri->kelas }})`
**Pattern: `blade_kelas`**
- **Line 35:** `{{ $santri->id_santri }} - {{ $santri->nama_lengkap }} ({{ $santri->kelas }})`
**💡 Suggested Action:**
1. Replace `{{ $santri->kelas }}` with `{{ $santri->kelas_name }}`
2. Test display in browser
---
#### 📄 `resources/views/admin/pembayaran-spp/edit.blade.php`
**Pattern: `property_access`**
- **Line 36:** `{{ $santri->id_santri }} - {{ $santri->nama_lengkap }} ({{ $santri->kelas }})`
**Pattern: `blade_kelas`**
- **Line 36:** `{{ $santri->id_santri }} - {{ $santri->nama_lengkap }} ({{ $santri->kelas }})`
**💡 Suggested Action:**
1. Replace `{{ $santri->kelas }}` with `{{ $santri->kelas_name }}`
2. Test display in browser
---
#### 📄 `resources/views/admin/riwayat_pelanggaran/riwayat_santri.blade.php`
**Pattern: `property_access`**
- **Line 33:** `{{ $santri->id_santri }} | {{ $santri->kelas }}`
**Pattern: `blade_kelas`**
- **Line 33:** `{{ $santri->id_santri }} | {{ $santri->kelas }}`
**💡 Suggested Action:**
1. Replace `{{ $santri->kelas }}` with `{{ $santri->kelas_name }}`
2. Test display in browser
---
#### 📄 `resources/views/admin/santri/form.blade.php`
**Pattern: `property_access`**
- **Line 87:** `<option value="PB" {{ old('kelas', $isEdit ? $santri->kelas : '') == 'PB' ? 'selected' : '' }}>PB (Pembinaan)</option>`
- **Line 88:** `<option value="Lambatan" {{ old('kelas', $isEdit ? $santri->kelas : '') == 'Lambatan' ? 'selected' : '' }}>Lambatan</option>`
- **Line 89:** `<option value="Cepatan" {{ old('kelas', $isEdit ? $santri->kelas : '') == 'Cepatan' ? 'selected' : '' }}>Cepatan</option>`
**💡 Suggested Action:**
1. Replace `{{ $santri->kelas }}` with `{{ $santri->kelas_name }}`
2. Test display in browser
---
#### 📄 `resources/views/admin/santri/index.blade.php`
**Pattern: `property_access`**
- **Line 89:** `<td><strong>{{ $santri->kelas }}</strong></td>`
**Pattern: `blade_kelas`**
- **Line 89:** `<td><strong>{{ $santri->kelas }}</strong></td>`
**💡 Suggested Action:**
1. Replace `{{ $santri->kelas }}` with `{{ $santri->kelas_name }}`
2. Test display in browser
---
#### 📄 `resources/views/admin/santri/show.blade.php`
**Pattern: `property_access`**
- **Line 75:** `<strong style="color: #6FBA9D; font-size: 1.1rem;">{{ $santri->kelas }}</strong>`
- **Line 76:** `@if($santri->kelas == 'PB')`
**Pattern: `blade_kelas`**
- **Line 75:** `<strong style="color: #6FBA9D; font-size: 1.1rem;">{{ $santri->kelas }}</strong>`
**Pattern: `enum_values`**
- **Line 76:** `@if($santri->kelas == 'PB')`
**💡 Suggested Action:**
1. Replace `{{ $santri->kelas }}` with `{{ $santri->kelas_name }}`
2. Test display in browser
---
#### 📄 `resources/views/admin/users/wali_accounts.blade.php`
**Pattern: `property_access`**
- **Line 95:** `<td>{{ $santri->kelas }}</td>`
**Pattern: `blade_kelas`**
- **Line 95:** `<td>{{ $santri->kelas }}</td>`
**💡 Suggested Action:**
1. Replace `{{ $santri->kelas }}` with `{{ $santri->kelas_name }}`
2. Test display in browser
---
#### 📄 `resources/views/santri/berita/index.blade.php`
**Pattern: `property_access`**
- **Line 10:** `Informasi terbaru untuk <strong>{{ $santri->kelas }}</strong>`
**Pattern: `blade_kelas`**
- **Line 10:** `Informasi terbaru untuk <strong>{{ $santri->kelas }}</strong>`
**💡 Suggested Action:**
1. Replace `{{ $santri->kelas }}` with `{{ $santri->kelas_name }}`
2. Test display in browser
---
#### 📄 `resources/views/santri/capaian/index.blade.php`
**Pattern: `property_access`**
- **Line 56:** `<div class="card-value-small">{{ $santri->kelas }}</div>`
**Pattern: `blade_kelas`**
- **Line 56:** `<div class="card-value-small">{{ $santri->kelas }}</div>`
**💡 Suggested Action:**
1. Replace `{{ $santri->kelas }}` with `{{ $santri->kelas_name }}`
2. Test display in browser
---
#### 📄 `resources/views/santri/kegiatan/index.blade.php`
**Pattern: `property_access`**
- **Line 9:** `{{ $santri->nama_lengkap }} - Kelas {{ $santri->kelas }}`
**Pattern: `blade_kelas`**
- **Line 9:** `{{ $santri->nama_lengkap }} - Kelas {{ $santri->kelas }}`
**💡 Suggested Action:**
1. Replace `{{ $santri->kelas }}` with `{{ $santri->kelas_name }}`
2. Test display in browser
---
### Database / migrations
#### 📄 `database/migrations/2025_09_29_033444_create_santris_table.php`
**Pattern: `enum_values`**
- **Line 25:** `$table->enum('kelas', ['PB', 'Lambatan', 'Cepatan']); // PB = Pembinaan`
**💡 Suggested Action:**
Review usage and update as needed based on context.
---
#### 📄 `database/migrations/2025_10_31_064743_create_materi_table.php`
**Pattern: `enum_values`**
- **Line 18:** `$table->enum('kelas', ['Lambatan', 'Cepatan', 'PB'])->index();`
**💡 Suggested Action:**
Review usage and update as needed based on context.
---
### Database / seeders
#### 📄 `database/seeders/KelasSeeder.php`
**Pattern: `enum_values`**
- **Line 29:** `'nama_kelas' => 'PB',`
- **Line 38:** `'nama_kelas' => 'Lambatan',`
- **Line 47:** `'nama_kelas' => 'Cepatan',`
**💡 Suggested Action:**
Review usage and update as needed based on context.
---
## 📖 Refactoring Guide
### General Patterns
#### 1. Display in Views (Blade)
```php
// OLD:
{{ $santri->kelas }}
// NEW (backward compatible):
{{ $santri->kelas_name }}
```
#### 2. Filter in Controllers
```php
// OLD:
$santris = Santri::where('kelas', 'PB')->get();
// NEW:
$santris = Santri::whereHas('kelasSantri', function($q) {
$q->where('id_kelas', 1); // PB = 1
})->get();
```
#### 3. Kegiatan-Kelas Relation
```php
// OLD: Filter santri by kelas for kegiatan
$santris = Santri::whereIn('kelas', ['PB', 'Lambatan'])->get();
// NEW: Use kegiatan relation
$santris = $kegiatan->getEligibleSantris();
```
### Testing Checklist
- [ ] Santri detail page displays correct kelas
- [ ] Santri list filter by kelas works
- [ ] Dashboard statistics by kelas accurate
- [ ] Kegiatan filtering by kelas works
- [ ] Absensi shows correct santri per kegiatan
- [ ] Reports include correct kelas information
- [ ] Mobile API returns kelas data correctly

View File

@ -1,311 +0,0 @@
# Multiple Kelas API Response Documentation
## Overview
Backend API telah diupdate untuk mendukung **multiple kelas** per santri dengan sistem relasi baru (kelompok_kelas → kelas → santri_kelas).
## Endpoints yang Diupdate
### 1. POST `/api/login`
### 2. GET `/api/profile`
Kedua endpoint ini sekarang return data kelas dalam struktur baru dengan backward compatibility.
---
## Response Structure (BARU)
### Example Response JSON
```json
{
"success": true,
"token": "1|abc123...",
"user": {
"name": "Ahmad Santoso",
"role": "santri",
"role_id": "S001"
},
"santri": {
"id_santri": "S001",
"nis": "2024001",
"nama_lengkap": "Ahmad Santoso",
"jenis_kelamin": "Laki-laki",
"status": "Aktif",
"alamat_santri": "Jl. Raya No. 123, Jakarta",
"daerah_asal": "Jakarta",
"nama_orang_tua": "Bapak Fulan",
"nomor_hp_ortu": "08123456789",
"foto": "santri/S001.jpg",
"foto_url": "http://localhost:8000/storage/santri/S001.jpg",
// ✅ BACKWARD COMPATIBILITY: Tetap ada field 'kelas' lama
"kelas": "Lambatan B", // Kelas primary atau pertama
// 🆕 NEW: Array semua kelas yang diikuti, GROUPED BY KELOMPOK
"kelas_list": [
{
"kelompok_id": "KLMPK001",
"kelompok_name": "PB",
"kelas": [
{
"id_kelas": 1,
"kode_kelas": "KLS001",
"nama_kelas": "PB Putra A",
"is_primary": false
}
]
},
{
"kelompok_id": "KLMPK002",
"kelompok_name": "Lambatan",
"kelas": [
{
"id_kelas": 5,
"kode_kelas": "KLS005",
"nama_kelas": "Lambatan B",
"is_primary": true // ⭐ Kelas utama
},
{
"id_kelas": 6,
"kode_kelas": "KLS006",
"nama_kelas": "Lambatan A",
"is_primary": false
}
]
},
{
"kelompok_id": "KLMPK003",
"kelompok_name": "Cepatan",
"kelas": [
{
"id_kelas": 8,
"kode_kelas": "KLS008",
"nama_kelas": "Cepatan A",
"is_primary": false
}
]
},
{
"kelompok_id": "KLMPK004",
"kelompok_name": "Hadist",
"kelas": [
{
"id_kelas": 15,
"kode_kelas": "KLS015",
"nama_kelas": "Hadist Pemula",
"is_primary": false
}
]
}
],
"bergabung_sejak": "14 February 2026"
}
}
```
---
## Field Description
### Field Lama (Backward Compatibility)
| Field | Type | Description |
|-------|------|-------------|
| `kelas` | string | Nama kelas utama (primary). Fallback: kelas pertama atau "Belum Ada Kelas" |
### Field Baru (kelas_list)
| Field | Type | Description |
|-------|------|-------------|
| `kelas_list` | array | Array kelompok kelas yang diikuti santri |
| `kelas_list[].kelompok_id` | string | ID kelompok (KLMPK001, KLMPK002, dst) |
| `kelas_list[].kelompok_name` | string | Nama kelompok (PB, Lambatan, Cepatan, dst) |
| `kelas_list[].kelas` | array | Array kelas dalam kelompok ini |
| `kelas[].id_kelas` | int | ID kelas (primary key) |
| `kelas[].kode_kelas` | string | Kode kelas (KLS001, KLS002, dst) |
| `kelas[].nama_kelas` | string | Nama kelas lengkap |
| `kelas[].is_primary` | boolean | **true** jika ini kelas utama santri, **false** untuk kelas lainnya |
---
## Edge Cases Handling
### Case 1: Santri Belum Punya Kelas
```json
{
"kelas": "Belum Ada Kelas",
"kelas_list": []
}
```
### Case 2: Santri Punya 1 Kelas Saja
```json
{
"kelas": "PB Putra A",
"kelas_list": [
{
"kelompok_id": "KLMPK001",
"kelompok_name": "PB",
"kelas": [
{
"id_kelas": 1,
"kode_kelas": "KLS001",
"nama_kelas": "PB Putra A",
"is_primary": true
}
]
}
]
}
```
### Case 3: Santri Punya Banyak Kelas, Tidak Ada Primary
```json
{
"kelas": "PB Putra A", // Fallback ke kelas pertama
"kelas_list": [
{
"kelompok_id": "KLMPK001",
"kelompok_name": "PB",
"kelas": [
{
"id_kelas": 1,
"kode_kelas": "KLS001",
"nama_kelas": "PB Putra A",
"is_primary": false
}
]
},
{
"kelompok_id": "KLMPK002",
"kelompok_name": "Lambatan",
"kelas": [
{
"id_kelas": 5,
"kode_kelas": "KLS005",
"nama_kelas": "Lambatan B",
"is_primary": false
}
]
}
]
}
```
---
## Backend Implementation Details
### File: `app/Http/Controllers/Api/ApiAuthController.php`
**Methods Updated:**
- `login()` - Lines ~74-120
- `profile()` - Lines ~160-210
- `buildKelasListGrouped()` - NEW private method (Lines ~215-270)
**Query Optimization:**
```php
$santri = Santri::with(['kelasSantri.kelas.kelompok', 'kelasPrimary.kelas'])
->where('id_santri', $user->role_id)
->first();
```
- **Eager loading** mencegah N+1 query problem
- Query count: **2-3 queries** (optimal)
- Response size: **< 10KB** untuk santri dengan 5-10 kelas
**Grouping Logic:**
1. Ambil semua `santri_kelas` records
2. Group by `kelompok_id`
3. Map ke struktur JSON
4. Sort by `is_primary DESC` (kelas primary di atas)
---
## Testing Checklist
### Backend Testing
```bash
# Test login endpoint
curl -X POST http://localhost:8000/api/login \
-H "Content-Type: application/json" \
-d '{"id_santri": "S001", "password": "password123"}'
# Test profile endpoint (dengan token)
curl -X GET http://localhost:8000/api/profile \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
```
**Expected Results:**
- ✅ Response includes both `kelas` and `kelas_list`
- ✅ `kelas_list` is array, grouped by kelompok
- ✅ `is_primary` flag correct
- ✅ No SQL errors in Laravel log
- ✅ Response time < 500ms
### Backward Compatibility Testing
**Test dengan App Versi Lama:**
1. App lama hanya baca field `kelas` (string)
2. Field `kelas` tetap ada → ✅ App lama masih berfungsi
3. Field `kelas_list` diabaikan oleh app lama → ✅ No crash
**Test dengan App Versi Baru:**
1. App baru baca field `kelas_list` (array)
2. Jika `kelas_list` null/empty → Fallback ke field `kelas`
3. Tampilkan multiple kelas dengan UI baru
---
## Troubleshooting
### Problem: kelas_list selalu empty
**Solution:**
- Cek apakah santri sudah punya data di tabel `santri_kelas`
- Jalankan migration: `php artisan migrate:santri-kelas-full`
### Problem: is_primary selalu false
**Solution:**
- Cek data di `santri_kelas`, kolom `is_primary`
- Pastikan ada minimal 1 record dengan `is_primary = 1`
- Update manual:
```sql
UPDATE santri_kelas SET is_primary = 1
WHERE id_santri = 'S001' AND id_kelas = 5 LIMIT 1;
```
### Problem: kelompok_name null
**Solution:**
- Cek relasi `kelas.kelompok` sudah eager loaded
- Pastikan `id_kelompok` di tabel `kelas` valid
- Cek tabel `kelompok_kelas` ada data
---
## Performance Metrics
| Metric | Before | After | Notes |
|--------|--------|-------|-------|
| Query Count | 1 | 2-3 | Optimal dengan eager loading |
| Response Size | ~2KB | ~5KB | Masih sangat ringan |
| Response Time | 50ms | 80ms | Masih < 100ms (excellent) |
| Memory Usage | 2MB | 3MB | Minimal |
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0.0 | 2026-02-14 | Initial release: Single kelas (field 'kelas' saja) |
| 2.0.0 | 2026-02-14 | **NEW**: Multiple kelas dengan `kelas_list`, backward compatible |
---
## Contact
Questions? Check:
- Laravel log: `storage/logs/laravel.log`
- API documentation: `/api/documentation` (if available)
- Database: Check `santri_kelas` table structure

View File

@ -1,428 +0,0 @@
# Flutter Multiple Kelas UI Documentation
## Overview
Aplikasi mobile Flutter telah diupdate untuk menampilkan **multiple kelas** per santri dengan UI yang clean, informatif, dan responsive.
---
## UI/UX Changes Summary
### BEFORE (Version 1.0)
```
┌────────────────────────────────────┐
│ Profil Santri │
├────────────────────────────────────┤
│ [Foto Avatar] │
│ Ahmad Santoso │
│ S001 │
│ [Aktif] │
├────────────────────────────────────┤
│ 📋 Informasi Dasar │
│ ID Santri: S001 │
│ NIS: 2024001 │
│ Nama Lengkap: Ahmad Santoso │
│ Jenis Kelamin: Laki-laki │
│ Kelas: Lambatan B ← SINGLE KELAS
│ Status: Aktif │
└────────────────────────────────────┘
```
### AFTER (Version 2.0)
```
┌────────────────────────────────────┐
│ Profil Santri │
├────────────────────────────────────┤
│ [Foto Avatar] │
│ Ahmad Santoso │
│ S001 │
│ ┌──────────────────────┐ │
│ │ 📚 Lambatan B │ ← Primary badge
│ └──────────────────────┘ │
│ +3 kelas lainnya ↓ ← Hint │
│ [Aktif] │
├────────────────────────────────────┤
│ 📋 Informasi Dasar │
│ ID Santri: S001 │
│ NIS: 2024001 │
│ Nama Lengkap: Ahmad Santoso │
│ Jenis Kelamin: Laki-laki │
│ Status: Aktif │ ← Kelas dihapus
├────────────────────────────────────┤
│ 🎓 Kelas yang Diikuti ← NEW SECTION
│ │
│ ▼ 🔵 PB (1 kelas) ← Expanded │
│ ├─ PB Putra A │
│ │ KLS001 │
│ │
│ ▼ 🟠 Lambatan (2 kelas) ← Expanded │
│ ├─ Lambatan B [⭐ Utama] ← Primary
│ │ KLS005 │
│ ├─ Lambatan A │
│ │ KLS006 │
│ │
│ ▶ 🟢 Cepatan (1 kelas) ← Collapsed│
│ │
│ ▶ 🔴 Hadist (1 kelas) ← Collapsed│
└────────────────────────────────────┘
```
---
## New Features
### 1. Primary Kelas Badge (Header)
**Location:** Di header, antara ID Santri dan Status Badge
**Features:**
- Menampilkan kelas utama (primary kelas)
- Icon 📚 (sekolah)
- Background: Semi-transparent white
- Border: White border (subtle)
- Hint: "+X kelas lainnya" jika total kelas > 1
**Code:**
```dart
Widget _buildPrimaryKelasBadge() { ... }
```
### 2. Kelas yang Diikuti Section
**Location:** Setelah "Informasi Dasar", sebelum "Alamat & Asal"
**Features:**
- Section card dengan icon 🎓
- ExpansionTile per kelompok (collapsible)
- Color-coded badges per kelompok
- Sortir: Primary kelas di atas
- Badge "⭐ Utama" untuk primary kelas
**Code:**
```dart
Widget _buildKelasListSection() { ... }
Widget _buildKelompokExpansionTile(String kelompokName, List kelasItems) { ... }
```
### 3. Color Coding System
Setiap kelompok memiliki warna unik:
| Kelompok | Color | Hex Code | Icon |
|----------|-------|----------|------|
| PB / Pondok | 🔵 Blue | #3b82f6 | 🏫 Icons.school |
| Lambatan | 🟠 Orange | #fb923c | 📖 Icons.menu_book |
| Cepatan | 🟢 Green | #10b981 | ⚡ Icons.speed |
| Tahfidz | 🟣 Purple | #7C3AED | 📚 Icons.auto_stories |
| Hadist | 🔵 Teal | #14b8a6 | 📗 Icons.import_contacts |
| Default | ⚫ Gray | #6b7280 | 🎓 Icons.class_ |
**Code:**
```dart
Color _getKelompokColor(String kelompokName) { ... }
IconData _getKelompokIcon(String kelompokName) { ... }
```
---
## UI Component Breakdown
### ExpansionTile Structure
```dart
Container (Border + Border Radius)
└─ Theme (Hide default divider)
└─ ExpansionTile
├─ Leading: Colored icon badge
├─ Title: Kelompok name (bold, colored)
├─ Subtitle: "X kelas" (gray)
└─ Children: List of kelas items
└─ Container (Kelas item)
├─ Left: Nama kelas + Kode kelas
└─ Right: Badge "⭐ Utama" (if primary)
```
### Primary Badge Indicator
**Styling:**
- background: Gold (#fbbf24)
- Icon: ⭐ Star (white, size 12)
- Text: "Utama" (white, size 10, bold)
- Padding: 8px horizontal, 4px vertical
- Border radius: 8px
### Kelas Item Styling
**Primary Kelas:**
- Background: Kelompok color with 10% opacity
- Border: Kelompok color with 30% opacity, width 1.5px
- Text: Bold, kelompok color
- Badge: "⭐ Utama" visible
**Non-Primary Kelas:**
- Background: Light gray (5% opacity)
- Border: None
- Text: Semi-bold, black87
- Badge: Hidden
---
## Responsive Design
### Screen Sizes Supported
- Min width: 320px (iPhone SE)
- Max width: 800px (iPad)
- Optimal: 360-428px (Most smartphones)
### Adaptive Behavior
- ExpansionTile: Auto-adjust height
- Text overflow: Ellipsis
- Padding: Proportional to screen width
- Card elevation: 2 (consistent)
---
## Performance Optimizations
### 1. Lazy Loading
- Section "Kelas yang Diikuti" hanya render saat visible
- ExpansionTile default collapsed
- Children di-render saat expanded
### 2. Minimal Dependencies
- **NO EXTERNAL PACKAGES** untuk kelas display
- Hanya Flutter built-in widgets:
- ExpansionTile
- Card
- Container
- Row, Column
- Icon, Text
### 3. No Heavy Assets
- Semua icon menggunakan `Icons.*` (Flutter built-in)
- No image assets loaded
- No SVG files
### 4. Efficient State Management
- Single `_santriData` map
- No redundant API calls
- Cache-first strategy dengan SharedPreferences
---
## Code Files Modified
### File: `lib/features/profil/profil_page.dart`
**New Methods Added:**
1. `_buildPrimaryKelasBadge()` - Lines ~305-360
2. `_buildKelasListSection()` - Lines ~365-440
3. `_buildKelompokExpansionTile()` - Lines ~445-570
4. `_getKelompokColor()` - Lines ~575-595
5. `_getKelompokIcon()` - Lines ~600-620
**Modified Sections:**
1. `build()` method - Added conditional section display
2. `_buildHeader()` - Added primary kelas badge call
3. "Informasi Dasar" card - Removed kelas row
**Total Lines:** ~620 lines (dari ~300 lines sebelumnya)
---
## Error Handling
### Defensive Programming
```dart
// Handle null kelas_list
if (_santriData?['kelas_list'] != null &&
(_santriData!['kelas_list'] as List).isNotEmpty) {
_buildKelasListSection()
}
// Handle null kelompok
final kelompokName = kelompok['kelompok_name'] ?? 'Unknown';
final kelasItems = kelompok['kelas'] as List? ?? [];
// Handle null kelas properties
final namaKelas = kelas['nama_kelas'] ?? '-';
final kodeKelas = kelas['kode_kelas'] ?? '-';
final isPrimary = kelas['is_primary'] == true;
```
### Empty State
```dart
if (kelasList.isEmpty) {
return _buildSectionCard(
title: 'Kelas yang Diikuti',
icon: Icons.class_,
children: [
Center(
child: Text(
'Belum mengikuti kelas apapun',
style: TextStyle(color: Colors.grey[600]),
),
),
],
);
}
```
---
## Testing Guide
### Manual Testing Steps
#### Test 1: Display Multiple Kelas
1. Login sebagai santri yang punya multiple kelas
2. Navigasi ke tab "Profil"
3. **Expected:**
- Header menampilkan primary kelas badge
- Hint "+X kelas lainnya" muncul
- Section "Kelas yang Diikuti" visible
- Kelompok di-group dengan benar
- Primary kelas punya badge "⭐ Utama"
#### Test 2: Expansion/Collapse
1. Tap kelompok yang collapsed
2. **Expected:** ExpansionTile expand, menampilkan kelas items
3. Tap lagi
4. **Expected:** ExpansionTile collapse
#### Test 3: Primary Badge Visibility
1. Cari kelas dengan `is_primary = true`
2. **Expected:** Badge "⭐ Utama" muncul di kanan kelas item
3. Cari kelas dengan `is_primary = false`
4. **Expected:** Badge tidak muncul
#### Test 4: Empty State
1. Login sebagai santri belum punya kelas
2. **Expected:**
- Section "Kelas yang Diikuti" TIDAK muncul
- Field kelas di "Informasi Dasar" tidak ada
#### Test 5: Single Kelas
1. Login sebagai santri dengan 1 kelas saja
2. **Expected:**
- Primary kelas badge muncul
- Hint "+X kelas lainnya" TIDAK muncul (karena cuma 1)
- Section "Kelas yang Diikuti" muncul dengan 1 kelompok
#### Test 6: Color Coding
1. Cek kelompok "PB" → Blue
2. Cek kelompok "Lambatan" → Orange
3. Cek kelompok "Cepatan" → Green
4. Cek kelompok "Tahfidz" → Purple
5. Cek kelompok "Hadist" → Teal
#### Test 7: Responsive
1. Test di screen 320px (iPhone SE)
2. Test di screen 375px (iPhone 13)
3. Test di screen 428px (iPhone 13 Pro Max)
4. **Expected:** No horizontal overflow, text ellipsis bekerja
#### Test 8: Pull-to-Refresh
1. Swipe down di profil page
2. **Expected:** Loading indicator muncul, data refresh dari API
---
## Debugging Tips
### Problem: Section tidak muncul
**Check:**
```dart
print('kelas_list: ${_santriData?['kelas_list']}');
print('is List: ${_santriData?['kelas_list'] is List}');
print('isEmpty: ${(_santriData?['kelas_list'] as List?)?.isEmpty}');
```
### Problem: ExpansionTile tidak expand
**Check:**
- Pastikan `Theme` wrapper ada (untuk hide default divider)
- Cek console error saat tap
### Problem: Badge "Utama" tidak muncul
**Check:**
```dart
print('isPrimary: ${kelas['is_primary']}');
print('isPrimary type: ${kelas['is_primary'].runtimeType}');
```
### Problem: Color salah
**Check:**
```dart
print('kelompokName: $kelompokName');
print('color: ${_getKelompokColor(kelompokName)}');
```
---
## Future Enhancements (Optional)
### Phase 2 (Nice to Have)
1. **Smooth Animation**
- Add `AnimatedSwitcher` untuk smooth transition
- Fade animation saat expand/collapse
2. **Search/Filter**
- Search box untuk cari kelas
- Filter by kelompok
3. **Tap to Detail**
- Tap kelas item → Navigate ke detail kelas page
- Show jadwal, materi, guru, dll
4. **Statistics**
- Show kehadiran per kelas
- Show nilai rata-rata per kelas
### Phase 3 (Advanced)
1. **Tahun Ajaran**
- Display tahun ajaran per kelas
- Filter by tahun ajaran
2. **Kelas History**
- Show riwayat kelas tahun-tahun sebelumnya
3. **QR Code**
- Generate QR code untuk absensi per kelas
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0.0 | 2026-02-14 | Initial: Single kelas display |
| 2.0.0 | 2026-02-14 | **NEW**: Multiple kelas with ExpansionTile, color coding, primary badge |
---
## Troubleshooting
### Flutter Analyze Errors
```bash
cd sim_mobile
flutter analyze
```
### Flutter Format
```bash
flutter format lib/features/profil/profil_page.dart
```
### Build APK (Test)
```bash
flutter build apk --debug
```
---
## Contact & Support
- File: `lib/features/profil/profil_page.dart`
- Backup: `lib/features/profil/profil_page.dart.backup`
- Flutter version: 3.x
- Dart version: 3.x

View File

@ -1,245 +0,0 @@
# Panduan Perbaikan Sistem Login Mobile SIM-PKPPS
## ✅ Perbaikan yang Sudah Dilakukan
### 1. **Auto-Fill Username & Password**
- JavaScript diperbaiki dari `@push('scripts')` menjadi inline `<script>` di [create_account.blade.php](sim-pkpps/resources/views/admin/users/create_account.blade.php)
- Saat memilih santri di dropdown, otomatis mengisi:
- **Username**: Nama Santri
- **Password**: NIS Santri
- Field menjadi readonly saat sudah terisi otomatis
- Jika santri belum punya NIS, akan muncul alert dan field bisa diisi manual
### 2. **Fungsi Delete Akun**
- Ditambahkan method `destroyAccount()` di [UserController.php](sim-pkpps/app/Http/Controllers/Admin/UserController.php)
- Routes ditambahkan:
- `DELETE /admin/users/santri/{user}``admin.users.santri_destroy`
- `DELETE /admin/users/wali/{user}``admin.users.wali_destroy`
- Tombol delete dengan konfirmasi di:
- [santri_accounts.blade.php](sim-pkpps/resources/views/admin/users/santri_accounts.blade.php)
- [wali_accounts.blade.php](sim-pkpps/resources/views/admin/users/wali_accounts.blade.php)
### 3. **Fungsi Reset Password**
- Ditambahkan method `resetPassword()` di [UserController.php](sim-pkpps/app/Http/Controllers/Admin/UserController.php)
- Reset password otomatis ke NIS santri
- Routes ditambahkan:
- `POST /admin/users/santri/{user}/reset-password``admin.users.santri_reset_password`
- `POST /admin/users/wali/{user}/reset-password``admin.users.wali_reset_password`
- Tombol reset dengan konfirmasi di view akun santri/wali
---
## 🔧 Cara Testing Login Mobile
### A. Test API Login Menggunakan File PHP Test
1. **Edit file [test_login.php](test_login.php)**
```php
$username = "Ahmad Fauzi"; // Ganti dengan nama santri yang sudah punya akun wali
$password = "2024001"; // Ganti dengan NIS santri tersebut
```
2. **Jalankan dari terminal:**
```bash
php test_login.php
```
3. **Hasil yang diharapkan:**
```
✅ LOGIN BERHASIL!
Token: 1|xxxxxxxxxxxxx
User: Ahmad Fauzi
Role: wali
```
### B. Cek Database Untuk Memastikan Akun Ada
```sql
-- Cek akun wali yang sudah dibuat
SELECT
u.id,
u.username,
u.role,
s.nama_lengkap,
s.nis
FROM users u
JOIN santris s ON u.role_id = s.id_santri
WHERE u.role = 'wali';
```
### C. Troubleshooting Login Mobile Gagal
#### ❌ Error: "Username atau password salah"
**Penyebab:**
- Username tidak match persis dengan database (case-sensitive, spasi, typo)
- Password salah (pastikan menggunakan NIS yang benar)
**Solusi:**
1. Cek username di database:
```sql
SELECT username FROM users WHERE role='wali';
```
2. Pastikan di Flutter login menggunakan username yang **PERSIS SAMA** termasuk huruf besar/kecil dan spasi
3. Password harus NIS santri (bisa dicek di tabel santris)
#### ❌ Error: "Connection refused" / "Network error"
**Penyebab:**
- Laravel server tidak jalan
- Base URL salah di Flutter
**Solusi:**
1. Pastikan Laravel server running:
```bash
cd sim-pkpps
php artisan serve
```
2. Cek [app_config.dart](sim_mobile/lib/core/config/app_config.dart):
```dart
static const String baseUrl = 'http://10.0.2.2:8000/api/v1'; // Emulator
// atau
static const String baseUrl = 'http://192.168.x.x:8000/api/v1'; // Real device
```
#### ❌ Error: "Akun tidak memiliki akses mobile"
**Penyebab:**
- User role bukan 'santri' atau 'wali'
**Solusi:**
- Pastikan di database field `role` adalah 'wali', bukan 'admin' atau lainnya
---
## 📋 Checklist Testing Lengkap
### 1. Testing Web Admin (Buat Akun Wali)
- [ ] Buka halaman Manajemen Akun Wali (`/admin/users/wali`)
- [ ] Klik "Buat Akun Wali"
- [ ] Pilih santri dari dropdown
- [ ] **Cek:** Username otomatis terisi dengan nama santri ✅
- [ ] **Cek:** Password otomatis terisi dengan NIS ✅
- [ ] Klik "Simpan"
- [ ] **Cek:** Akun muncul di daftar dengan info login ✅
### 2. Testing Fungsi Delete
- [ ] Di halaman Manajemen Akun Wali
- [ ] Klik tombol "Hapus" pada salah satu akun
- [ ] **Cek:** Muncul konfirmasi dialog ✅
- [ ] Klik OK
- [ ] **Cek:** Akun terhapus dari daftar ✅
### 3. Testing Fungsi Reset Password
- [ ] Di halaman Manajemen Akun Wali
- [ ] Klik tombol "Reset" pada salah satu akun
- [ ] **Cek:** Muncul konfirmasi dialog ✅
- [ ] Klik OK
- [ ] **Cek:** Muncul pesan sukses dengan info password baru (NIS) ✅
### 4. Testing Login Mobile
- [ ] Jalankan Flutter app (emulator/real device)
- [ ] Pastikan Laravel server running (`php artisan serve`)
- [ ] Di login page, masukkan:
- **Username**: Nama santri (persis seperti di database)
- **Password**: NIS santri
- [ ] Klik Login
- [ ] **Cek:** Berhasil masuk ke dashboard ✅
- [ ] **Cek:** Menu Profil menampilkan data santri ✅
---
## 🐛 Debug Mode - Jika Masih Gagal
### 1. Tambahkan Log di ApiAuthController
Edit [ApiAuthController.php](sim-pkpps/app/Http/Controllers/Api/ApiAuthController.php):
```php
public function login(Request $request)
{
// Log untuk debug
\Log::info('Login attempt', [
'username' => $request->id_santri,
'password_length' => strlen($request->password)
]);
$user = User::where('username', $request->id_santri)->first();
if (!$user) {
\Log::warning('User not found', ['username' => $request->id_santri]);
}
// ... kode lainnya
}
```
Cek log di `storage/logs/laravel.log`
### 2. Test Manual Dengan Postman/cURL
```bash
curl -X POST http://localhost:8000/api/v1/login \
-H "Content-Type: application/json" \
-d '{
"id_santri": "Ahmad Fauzi",
"password": "2024001"
}'
```
### 3. Validasi Data di Database
```sql
-- Cek akun wali yang baru dibuat
SELECT
u.id,
u.username,
u.role,
u.role_id,
s.nama_lengkap,
s.nis,
LENGTH(u.password) as password_hash_length
FROM users u
JOIN santris s ON u.role_id = s.id_santri
WHERE u.role = 'wali'
ORDER BY u.id DESC
LIMIT 5;
```
**Password hash length seharusnya 60 karakter (bcrypt)**
---
## 📱 Format Login yang Benar
| Field | Value | Contoh |
|-------|-------|--------|
| Username | Nama Santri (PERSIS seperti di database) | `Ahmad Fauzi` |
| Password | NIS Santri | `2024001` |
| Role | Otomatis terdeteksi dari database | `wali` |
**⚠️ PENTING:**
- Username **case-sensitive**: "Ahmad Fauzi" ≠ "ahmad fauzi"
- Spasi dihitung: "Ahmad Fauzi" ≠ "AhmadFauzi"
- Password adalah NIS **plain text** (tidak di-hash saat input), Laravel akan auto-verify hash
---
## 🔒 Keamanan
- Password di database di-hash dengan bcrypt (60 karakter)
- Token menggunakan Laravel Sanctum
- Setiap login, token lama dihapus (single device per account)
- API hanya bisa diakses oleh role 'santri' dan 'wali'
---
## 📞 Troubleshooting Contact
Jika masih ada masalah:
1. Cek file log Laravel: `sim-pkpps/storage/logs/laravel.log`
2. Cek Flutter console untuk error network
3. Pastikan username & password **100% match** dengan database
4. Test dengan file [test_login.php](test_login.php) terlebih dahulu sebelum test di mobile
---
**Semua fungsi sudah diimplementasikan:**
- ✅ Auto-fill username & password
- ✅ Delete akun
- ✅ Reset password
- ✅ Login mobile ready (tinggal test dengan data yang benar)

View File

@ -1,350 +0,0 @@
# 🎓 Multiple Kelas System - Implementation Summary
## ✅ COMPLETED
Sistem multiple kelas untuk santri telah **SELESAI DIIMPLEMENTASI** pada backend Laravel dan aplikasi mobile Flutter.
---
## 📦 What's New
### Backend (Laravel)
✅ API `/api/login` dan `/api/profile` sekarang return **multiple kelas** grouped by kelompok
✅ Field `kelas_list` (array) untuk semua kelas santri
✅ Field `kelas` (string) tetap ada untuk **backward compatibility**
✅ Flag `is_primary` untuk menandai kelas utama
**Eager loading** untuk optimasi query (No N+1 problem)
### Frontend (Flutter)
✅ Section baru **"Kelas yang Diikuti"** di profil page
**Primary kelas badge** di header (dengan icon 📚)
**ExpansionTile** per kelompok (collapsible/expandable)
**Color-coded** badges untuk setiap kelompok kelas
✅ Badge **"⭐ Utama"** untuk kelas primary
**Responsive design** (support 320px - 800px screen width)
**Pull-to-refresh** untuk update data
**Empty state handling** (santri tanpa kelas)
---
## 📁 Files Modified/Created
### Backend
| File | Status | Description |
|------|--------|-------------|
| `app/Http/Controllers/Api/ApiAuthController.php` | ✏️ **MODIFIED** | Added kelas_list support in login() & profile() |
| | | Added buildKelasListGrouped() helper method |
### Frontend
| File | Status | Description |
|------|--------|-------------|
| `sim_mobile/lib/features/profil/profil_page.dart` | ✏️ **MODIFIED** | Complete rewrite with multi-kelas support |
| `sim_mobile/lib/features/profil/profil_page.dart.backup` | 📄 **CREATED** | Backup of original file |
### Documentation
| File | Status | Description |
|------|--------|-------------|
| `MULTIPLE_KELAS_API_RESPONSE.md` | 📄 **CREATED** | API structure & response examples |
| `MULTIPLE_KELAS_UI_FLUTTER.md` | 📄 **CREATED** | UI/UX design & implementation guide |
| `TESTING_CHECKLIST_MULTIPLE_KELAS.md` | 📄 **CREATED** | Complete testing checklist (26 tests) |
| `README_MULTIPLE_KELAS.md` | 📄 **CREATED** | This file - Quick start guide |
---
## 🚀 Quick Start Guide
### Step 1: Verify Backend
```bash
# Navigate to Laravel project
cd c:\xampp\htdocs\TugasAkhir\sim-pkpps
# Check for syntax errors
php artisan route:list | grep api
# Test login endpoint (replace S001 & password)
curl -X POST http://localhost:8000/api/login \
-H "Content-Type: application/json" \
-d '{"id_santri": "S001", "password": "password123"}'
```
**Expected:** Response includes `kelas` (string) and `kelas_list` (array)
---
### Step 2: Test Flutter App
```bash
# Navigate to Flutter project
cd c:\xampp\htdocs\TugasAkhir\sim_mobile
# Clean build
flutter clean
flutter pub get
# Run on device/emulator
flutter run
```
**Test Flow:**
1. Login dengan santri yang punya multiple kelas
2. Tap tab "Profil"
3. **Expected:** Section "Kelas yang Diikuti" muncul
4. Tap kelompok → ExpansionTile expand
5. Verify badge "⭐ Utama" di primary kelas
---
### Step 3: Create Test Data (Manual)
```sql
-- Connect to your MySQL database
USE sim_pkpps;
-- Insert sample kelas for testing
-- Replace S001 with your test santri ID
-- Kelas 1: PB Putra A (not primary)
INSERT INTO santri_kelas (id_santri, id_kelas, tahun_ajaran, is_primary)
VALUES ('S001', 1, '2025/2026', 0);
-- Kelas 2: Lambatan B (PRIMARY)
INSERT INTO santri_kelas (id_santri, id_kelas, tahun_ajaran, is_primary)
VALUES ('S001', 5, '2025/2026', 1);
-- Kelas 3: Cepatan A (not primary)
INSERT INTO santri_kelas (id_santri, id_kelas, tahun_ajaran, is_primary)
VALUES ('S001', 8, '2025/2026', 0);
-- Kelas 4: Hadist Pemula (not primary)
INSERT INTO santri_kelas (id_santri, id_kelas, tahun_ajaran, is_primary)
VALUES ('S001', 15, '2025/2026', 0);
-- Verify
SELECT * FROM santri_kelas WHERE id_santri = 'S001';
```
---
## 🎨 UI Preview (Text Description)
### HEADER
```
┌────────────────────────────────────┐
│ [Avatar Foto Santri] │
│ Ahmad Santoso │
│ S001 │
│ ┌──────────────────────┐ │
│ │ 📚 Lambatan B │ ← Primary badge
│ └──────────────────────┘ │
│ +3 kelas lainnya ↓ ← Hint │
│ [Aktif] │
└────────────────────────────────────┘
```
### KELAS SECTION
```
┌────────────────────────────────────┐
│ 🎓 Kelas yang Diikuti ← NEW │
├────────────────────────────────────┤
│ ▼ 🔵 PB (1 kelas) ← Expanded │
│ ├─ PB Putra A │
│ │ KLS001 │
│ │
│ ▼ 🟠 Lambatan (1 kelas) │
│ ├─ Lambatan B [⭐ Utama] ← Primary
│ │ KLS005 │
│ │
│ ▶ 🟢 Cepatan (1 kelas) ← Collapsed│
│ ▶ 🟣 Hadist (1 kelas) │
└────────────────────────────────────┘
```
---
## 📊 API Response Example
### Login/Profile Response
```json
{
"success": true,
"data": {
"id_santri": "S001",
"nama_lengkap": "Ahmad Santoso",
// ✅ Backward compatibility
"kelas": "Lambatan B",
// 🆕 NEW: Multiple kelas
"kelas_list": [
{
"kelompok_id": "KLMPK001",
"kelompok_name": "PB",
"kelas": [
{
"id_kelas": 1,
"kode_kelas": "KLS001",
"nama_kelas": "PB Putra A",
"is_primary": false
}
]
},
{
"kelompok_id": "KLMPK002",
"kelompok_name": "Lambatan",
"kelas": [
{
"id_kelas": 5,
"kode_kelas": "KLS005",
"nama_kelas": "Lambatan B",
"is_primary": true // ⭐ Primary
}
]
}
]
}
}
```
---
## 🎨 Color Coding Reference
| Kelompok | Color | Hex | Icon |
|----------|-------|-----|------|
| PB / Pondok | 🔵 Blue | #3b82f6 | 🏫 school |
| Lambatan | 🟠 Orange | #fb923c | 📖 menu_book |
| Cepatan | 🟢 Green | #10b981 | ⚡ speed |
| Tahfidz | 🟣 Purple | #7C3AED | 📚 auto_stories |
| Hadist | 🔵 Teal | #14b8a6 | 📗 import_contacts |
| Default | ⚫ Gray | #6b7280 | 🎓 class_ |
---
## ✅ Testing Checklist (Quick)
### Backend
- [ ] Login returns `kelas_list` array
- [ ] Primary kelas has `is_primary: true`
- [ ] Field `kelas` masih ada (backward compat)
- [ ] Query count < 5 (no N+1)
- [ ] Response time < 500ms
### Frontend
- [ ] Section "Kelas yang Diikuti" muncul
- [ ] Primary kelas badge di header
- [ ] ExpansionTile bisa expand/collapse
- [ ] Badge "⭐ Utama" di primary kelas
- [ ] Color coding benar per kelompok
- [ ] Pull-to-refresh works
- [ ] Empty state handled (santri tanpa kelas)
- [ ] Responsive (320px - 800px)
### Integration
- [ ] Admin add kelas → Mobile refresh → Kelas baru muncul
- [ ] Admin change primary → Mobile refresh → Primary berubah
- [ ] Old app + New backend → No crash
- [ ] New app + Old backend → Fallback to single kelas
**📋 Full Testing Checklist:** See `TESTING_CHECKLIST_MULTIPLE_KELAS.md` (26 detailed tests)
---
## 🐛 Troubleshooting
### Problem: kelas_list always empty
**Solution:**
1. Check `santri_kelas` table has data
2. Run: `SELECT * FROM santri_kelas WHERE id_santri = 'YOUR_SANTRI_ID';`
3. If empty, insert sample data (see Step 3 above)
### Problem: Primary badge not showing in Flutter
**Solution:**
1. Check `is_primary` column in database
2. Ensure at least 1 record has `is_primary = 1`
3. Pull-to-refresh in app
### Problem: ExpansionTile not expanding
**Solution:**
1. Check Flutter console for errors
2. Ensure `kelas_list` is properly parsed as List
3. Debug: `print(_santriData?['kelas_list']);`
### Problem: API returns 500 error
**Solution:**
1. Check Laravel log: `storage/logs/laravel.log`
2. Verify database relationships (kelompok, kelas, santri_kelas)
3. Test query manually in MySQL
---
## 📚 Documentation Reference
| Document | Description | When to Read |
|----------|-------------|--------------|
| **MULTIPLE_KELAS_API_RESPONSE.md** | API structure, response examples, edge cases | Backend development/testing |
| **MULTIPLE_KELAS_UI_FLUTTER.md** | UI design, widget breakdown, code explanation | Frontend development/customization |
| **TESTING_CHECKLIST_MULTIPLE_KELAS.md** | Complete test scenarios (26 tests) | Quality assurance/testing |
| **README_MULTIPLE_KELAS.md** | This file - Quick start & overview | Getting started |
---
## 🔜 Next Steps (Optional Enhancements)
### Phase 2 (Nice to Have)
- [ ] Smooth expand/collapse animation
- [ ] Search/filter kelas by name
- [ ] Tap kelas → Navigate to detail page
### Phase 3 (Advanced)
- [ ] Display tahun_ajaran per kelas
- [ ] Kelas history (riwayat tahun sebelumnya)
- [ ] Statistics per kelas (kehadiran, nilai)
- [ ] QR code for absensi per kelas
---
## 📞 Support & Contact
**Created by:** GitHub Copilot (Claude Sonnet 4.5)
**Date:** February 14, 2026
**Version:** 2.0.0
**Files to Check:**
- Laravel Log: `sim-pkpps/storage/logs/laravel.log`
- Database: `sim_pkpps``santri_kelas` table
- Flutter Console: Run `flutter run` to see real-time logs
**Backup Files:**
- `sim_mobile/lib/features/profil/profil_page.dart.backup` (original version)
---
## ✨ Key Features Summary
1. **Multiple Kelas per Santri** - 1 santri bisa ikut banyak kelas dari berbagai kelompok
2. **Primary Kelas Flag** - Tandai kelas utama dengan `is_primary`
3. **Backward Compatible** - Field `kelas` lama tetap ada
4. **Optimized Queries** - Eager loading, no N+1 problem
5. **Clean UI** - ExpansionTile, color-coded, responsive
6. **Lightweight** - No heavy libraries, pure Flutter widgets
7. **Well Documented** - 3 comprehensive docs + testing checklist
---
## 🎉 Success Criteria
✅ Backend API returns `kelas_list` in proper structure
✅ Flutter app displays multiple kelas grouped by kelompok
✅ Primary kelas clearly indicated with badge
✅ App responsive on all screen sizes
✅ No performance degradation (< 500ms API, 60 FPS UI)
✅ Backward compatible with old app versions
✅ Comprehensive documentation created
✅ Testing checklist provided
---
**STATUS: READY FOR TESTING** 🚀
Start with **Step 1** above and follow the testing checklist!

View File

@ -1,177 +0,0 @@
# REFACTORING QUICK REFERENCE
# ============================
## 1. MIGRATE DATA
```bash
# Test migration (dry-run)
php artisan migrate:santri-kelas --dry-run
# Run actual migration
php artisan migrate:santri-kelas
# Force overwrite existing data
php artisan migrate:santri-kelas --force
```
## 2. SCAN CODEBASE
```bash
# Generate usage report
php scan_kelas_usage.php
# View report
cat KELAS_USAGE_MAP.md
# or open in editor
code KELAS_USAGE_MAP.md
```
## 3. REFACTORING PATTERNS
### Pattern 1: Display in Views (MEDIUM Priority)
```blade
<!-- OLD -->
{{ $santri->kelas }}
<!-- NEW (Backward Compatible) -->
{{ $santri->kelas_name }}
```
### Pattern 2: Filter in Controllers (HIGH Priority)
```php
// OLD
$santris = Santri::where('kelas', 'PB')->get();
// NEW
$santris = Santri::whereHas('kelasSantri', function($q) {
$q->where('id_kelas', 1); // PB = 1
})->get();
// OR using kelas relation
$kelas = Kelas::where('nama_kelas', 'PB')->first();
$santris = $kelas->santris;
```
### Pattern 3: Multiple Kelas Filter (HIGH Priority)
```php
// OLD
$santris = Santri::whereIn('kelas', ['PB', 'Lambatan'])->get();
// NEW
$kelasIds = Kelas::whereIn('nama_kelas', ['PB', 'Lambatan'])->pluck('id');
$santris = Santri::whereHas('kelasSantri', function($q) use ($kelasIds) {
$q->whereIn('id_kelas', $kelasIds);
})->get();
```
### Pattern 4: Kegiatan Eligible Santris (HIGH Priority)
```php
// OLD: Manual filter by kelas
$santris = Santri::whereIn('kelas', ['PB', 'Lambatan'])->get();
// NEW: Use helper method
$santris = $kegiatan->getEligibleSantris();
// This automatically handles:
// - Umum (all santri)
// - Specific kelas (filtered)
```
### Pattern 5: Check Santri Kelas
```php
// NEW: Check if santri in specific kelas
if ($santri->hasKelas($id_kelas)) {
// Do something
}
// Get all kelas for santri in current year
$kelasList = $santri->getKelasByTahun('2024/2025');
```
## 4. TESTING CHECKLIST
```bash
# After each refactor, test:
□ Display: Santri detail page shows correct kelas
□ Filter: Santri list filter by kelas works
□ Stats: Dashboard statistics by kelas accurate
□ Kegiatan: Filtering by kelas works
□ Absensi: Shows correct santri per kegiatan
□ Reports: Include correct kelas information
□ Mobile: API returns kelas data correctly
```
## 5. KELAS ID MAPPING
```
PB -> ID: 1 (KLS001)
Lambatan -> ID: 2 (KLS002)
Cepatan -> ID: 3 (KLS003)
```
## 6. COMMON ISSUES & FIXES
### Issue: Query too slow
```php
// Add eager loading
$santris = Santri::with(['kelasPrimary.kelas'])->get();
```
### Issue: Kelas not showing
```php
// Make sure santri has been migrated
php artisan migrate:santri-kelas
// Check in database
SELECT * FROM santri_kelas WHERE id_santri = 'S001';
```
### Issue: Multiple kelas showing
```php
// Get only primary kelas
$primaryKelas = $santri->kelasPrimary->kelas->nama_kelas;
```
## 7. ROLLBACK (If needed)
```php
// If something goes wrong, you can:
// 1. Delete migrated data
DELETE FROM santri_kelas WHERE tahun_ajaran = '2024/2025';
// 2. Re-run migration
php artisan migrate:santri-kelas --force
// 3. Old column 'kelas' is still there for fallback
```
## 8. FILES TO PRIORITIZE
### HIGH (Do First):
1. app/Http/Controllers/Admin/CapaianController.php
2. app/Http/Controllers/Admin/SantriController.php
3. Any controller with where('kelas') or whereIn('kelas')
### MEDIUM (Do After High):
1. All blade view files (24 files)
2. Change {{ $santri->kelas }} to {{ $santri->kelas_name }}
### LOW (Do Last):
1. API controllers (already may work with accessor)
2. Other controllers without direct kelas query
## 9. USEFUL COMMANDS
```bash
# Check migration status
php artisan migrate:status
# Rollback last migration (if needed)
php artisan migrate:rollback
# Seed kelas data
php artisan db:seed --class=KelompokKelasSeeder
php artisan db:seed --class=KelasSeeder
# Check current data
php artisan tinker
>>> Santri::with('kelasPrimary.kelas')->first()->kelas_name
>>> SantriKelas::count()
```

View File

@ -1,140 +0,0 @@
# RINGKASAN PERBAIKAN SISTEM LOGIN MOBILE
## ✅ SUDAH DIPERBAIKI
### 1. **Auto-Fill Username & Password**
- File: `sim-pkpps/resources/views/admin/users/create_account.blade.php`
- JavaScript diperbaiki (tidak lagi menggunakan @push)
- Saat pilih santri → otomatis isi username (nama) & password (NIS)
- Field readonly otomatis untuk wali
### 2. **Fungsi Delete Akun**
- File: `sim-pkpps/app/Http/Controllers/Admin/UserController.php`
- Method baru: `destroyAccount()`
- Routes:
- DELETE `/admin/users/santri/{user}`
- DELETE `/admin/users/wali/{user}`
- Tombol delete ada di view santri_accounts dan wali_accounts
### 3. **Fungsi Reset Password**
- File: `sim-pkpps/app/Http/Controllers/Admin/UserController.php`
- Method baru: `resetPassword()`
- Auto-reset password ke NIS santri
- Routes:
- POST `/admin/users/santri/{user}/reset-password`
- POST `/admin/users/wali/{user}/reset-password`
- Tombol reset ada di view santri_accounts dan wali_accounts
---
## 🔍 CARA TEST LOGIN MOBILE
### Step 1: Pastikan Server Running
```bash
cd c:\xampp\htdocs\TugasAkhir\sim-pkpps
php artisan serve
```
### Step 2: Buat Akun Wali (Jika Belum Ada)
1. Buka browser: http://localhost:8000/admin/users/wali
2. Login sebagai admin
3. Klik "Buat Akun Wali"
4. Pilih santri dari dropdown
5. **PERHATIKAN**: Username dan password akan terisi otomatis
6. Klik Simpan
### Step 3: Catat Username & Password
- **Username**: Nama santri (misal: "Ahmad Fauzi")
- **Password**: NIS santri (misal: "2024001")
### Step 4: Test API dengan PHP Script
```bash
php c:\xampp\htdocs\TugasAkhir\test_login.php
```
Edit dulu file test_login.php, ganti username dan password sesuai akun yang dibuat.
### Step 5: Test di Flutter Mobile App
1. Pastikan base URL di Flutter sudah benar:
- Emulator: `http://10.0.2.2:8000/api/v1`
- Real device: `http://192.168.x.x:8000/api/v1`
2. Run Flutter app:
```bash
cd c:\xampp\htdocs\TugasAkhir\sim_mobile
flutter run
```
3. Di login page, masukkan:
- Username: **PERSIS** seperti nama santri di database
- Password: NIS santri
4. Klik Login
---
## ❓ TROUBLESHOOTING
### ❌ "Username atau password salah"
**Penyebab**: Username tidak match persis dengan database
**Solusi**:
1. Cek username di database:
```sql
SELECT username FROM users WHERE role='wali';
```
2. Pastikan huruf besar/kecil dan spasi PERSIS SAMA
### ❌ "Connection refused"
**Penyebab**: Server Laravel tidak running atau base URL salah
**Solusi**:
1. Jalankan: `php artisan serve`
2. Cek base URL di Flutter (app_config.dart)
### ❌ Auto-fill tidak jalan
**Sudah diperbaiki**: JavaScript sekarang inline di file create_account.blade.php
### ❌ Tombol Delete/Reset tidak ada
**Sudah diperbaiki**: Tombol sudah ditambahkan di view santri_accounts dan wali_accounts
---
## 📁 FILE YANG DIUBAH
1. ✅ `sim-pkpps/app/Http/Controllers/Admin/UserController.php`
- Method: destroyAccount(), resetPassword()
2. ✅ `sim-pkpps/routes/web.php`
- Routes baru untuk delete & reset password
3. ✅ `sim-pkpps/resources/views/admin/users/create_account.blade.php`
- JavaScript auto-fill diperbaiki
4. ✅ `sim-pkpps/resources/views/admin/users/wali_accounts.blade.php`
- Tombol delete & reset ditambahkan
5. ✅ `sim-pkpps/resources/views/admin/users/santri_accounts.blade.php`
- Tombol delete & reset ditambahkan
---
## 🚀 LANGKAH SELANJUTNYA
1. **Test auto-fill di web admin**
- Buka halaman buat akun wali
- Pilih santri
- Pastikan username & password terisi otomatis
2. **Test delete & reset**
- Coba hapus akun
- Coba reset password
- Pastikan ada konfirmasi dialog
3. **Test login mobile**
- Gunakan username & password yang PERSIS dari database
- Test dengan emulator atau real device
- Pastikan server Laravel running
---
**SEMUA FUNGSI SUDAH SELESAI! TINGGAL TESTING!** ✅

View File

@ -1,722 +0,0 @@
# Testing Checklist - Multiple Kelas System
## Overview
Checklist lengkap untuk testing fitur Multiple Kelas pada backend Laravel dan aplikasi mobile Flutter.
---
## 📋 PHASE 1: Backend API Testing
### A. Persiapan Data Testing
#### ✅ Step 1: Cek Database Structure
```sql
-- Cek tabel santri_kelas
DESC santri_kelas;
-- Expected columns:
-- id, id_santri, id_kelas, tahun_ajaran, is_primary, created_at, updated_at
```
#### ✅ Step 2: Insert Sample Data (Manual)
```sql
-- Insert santri dengan multiple kelas
-- Contoh: Santri S001 masuk 4 kelas
-- Kelas 1: PB Putra A (bukan primary)
INSERT INTO santri_kelas (id_santri, id_kelas, tahun_ajaran, is_primary)
VALUES ('S001', 1, '2025/2026', 0);
-- Kelas 2: Lambatan B (PRIMARY)
INSERT INTO santri_kelas (id_santri, id_kelas, tahun_ajaran, is_primary)
VALUES ('S001', 5, '2025/2026', 1);
-- Kelas 3: Cepatan A (bukan primary)
INSERT INTO santri_kelas (id_santri, id_kelas, tahun_ajaran, is_primary)
VALUES ('S001', 8, '2025/2026', 0);
-- Kelas 4: Hadist Pemula (bukan primary)
INSERT INTO santri_kelas (id_santri, id_kelas, tahun_ajaran, is_primary)
VALUES ('S001', 15, '2025/2026', 0);
```
#### ✅ Step 3: Verify Sample Data
```sql
-- Cek data santri S001
SELECT sk.*, k.nama_kelas, kk.nama_kelompok
FROM santri_kelas sk
JOIN kelas k ON sk.id_kelas = k.id
JOIN kelompok_kelas kk ON k.id_kelompok = kk.id_kelompok
WHERE sk.id_santri = 'S001';
-- Expected: 4 rows, 1 dengan is_primary = 1
```
---
### B. Testing Login Endpoint
#### ✅ Test 1: Login Berhasil
```bash
curl -X POST http://localhost:8000/api/login \
-H "Content-Type: application/json" \
-d '{
"id_santri": "S001",
"password": "password123"
}'
```
**Expected Response:**
```json
{
"success": true,
"message": "Login berhasil",
"token": "1|abc123xyz...",
"user": {
"name": "Ahmad Santoso",
"role": "santri",
"role_id": "S001"
},
"santri": {
"id_santri": "S001",
...
"kelas": "Lambatan B",
"kelas_list": [ ... ]
}
}
```
**Checklist:**
- [ ] Response status: 200 OK
- [ ] Field `token` ada dan valid
- [ ] Field `santri.kelas` = "Lambatan B" (primary)
- [ ] Field `santri.kelas_list` adalah array
- [ ] `kelas_list` punya 4 kelompok (atau sesuai data)
- [ ] Ada 1 kelas dengan `is_primary = true`
#### ✅ Test 2: Login Gagal (Password Salah)
```bash
curl -X POST http://localhost:8000/api/login \
-H "Content-Type: application/json" \
-d '{
"id_santri": "S001",
"password": "wrongpassword"
}'
```
**Expected Response:**
```json
{
"message": "ID Santri atau password salah.",
"errors": {
"id_santri": ["ID Santri atau password salah."]
}
}
```
**Checklist:**
- [ ] Response status: 422 Unprocessable Entity
- [ ] Error message jelas
---
### C. Testing Profile Endpoint
#### ✅ Test 3: Get Profile (With Valid Token)
```bash
# Ganti YOUR_TOKEN dengan token dari login
curl -X GET http://localhost:8000/api/profile \
-H "Authorization: Bearer YOUR_TOKEN"
```
**Expected Response:**
```json
{
"success": true,
"data": {
"id_santri": "S001",
"nis": "2024001",
"nama_lengkap": "Ahmad Santoso",
"jenis_kelamin": "Laki-laki",
"status": "Aktif",
"kelas": "Lambatan B",
"kelas_list": [
{
"kelompok_id": "KLMPK001",
"kelompok_name": "PB",
"kelas": [
{
"id_kelas": 1,
"kode_kelas": "KLS001",
"nama_kelas": "PB Putra A",
"is_primary": false
}
]
},
{
"kelompok_id": "KLMPK002",
"kelompok_name": "Lambatan",
"kelas": [
{
"id_kelas": 5,
"kode_kelas": "KLS005",
"nama_kelas": "Lambatan B",
"is_primary": true
}
]
},
{
"kelompok_id": "KLMPK003",
"kelompok_name": "Cepatan",
"kelas": [
{
"id_kelas": 8,
"kode_kelas": "KLS008",
"nama_kelas": "Cepatan A",
"is_primary": false
}
]
},
{
"kelompok_id": "KLMPK004",
"kelompok_name": "Hadist",
"kelas": [
{
"id_kelas": 15,
"kode_kelas": "KLS015",
"nama_kelas": "Hadist Pemula",
"is_primary": false
}
]
}
],
...
}
}
```
**Checklist:**
- [ ] Response status: 200 OK
- [ ] Field `kelas` ada (string)
- [ ] Field `kelas_list` ada (array)
- [ ] Setiap kelompok punya struktur benar
- [ ] Primary kelas punya `is_primary: true`
- [ ] Kelompok di-group dengan benar
#### ✅ Test 4: Get Profile (Without Token)
```bash
curl -X GET http://localhost:8000/api/profile
```
**Expected Response:**
```json
{
"message": "Unauthenticated."
}
```
**Checklist:**
- [ ] Response status: 401 Unauthorized
---
### D. Edge Case Testing
#### ✅ Test 5: Santri Tanpa Kelas
```sql
-- Hapus semua kelas santri S002 (untuk testing)
DELETE FROM santri_kelas WHERE id_santri = 'S002';
```
```bash
# Login sebagai S002
curl -X POST http://localhost:8000/api/login \
-H "Content-Type: application/json" \
-d '{"id_santri": "S002", "password": "password123"}'
```
**Expected:**
```json
{
"santri": {
"kelas": "Belum Ada Kelas",
"kelas_list": []
}
}
```
**Checklist:**
- [ ] Field `kelas` = "Belum Ada Kelas"
- [ ] Field `kelas_list` = [] (empty array)
- [ ] No error/crash
#### ✅ Test 6: Santri dengan 1 Kelas Saja
```sql
-- Insert 1 kelas untuk S003
INSERT INTO santri_kelas (id_santri, id_kelas, tahun_ajaran, is_primary)
VALUES ('S003', 1, '2025/2026', 1);
```
```bash
# Login sebagai S003
curl -X POST http://localhost:8000/api/login \
-H "Content-Type: application/json" \
-d '{"id_santri": "S003", "password": "password123"}'
```
**Expected:**
```json
{
"santri": {
"kelas": "PB Putra A",
"kelas_list": [
{
"kelompok_id": "KLMPK001",
"kelompok_name": "PB",
"kelas": [
{
"id_kelas": 1,
"nama_kelas": "PB Putra A",
"is_primary": true
}
]
}
]
}
}
```
**Checklist:**
- [ ] Field `kelas` = nama kelas
- [ ] `kelas_list` punya 1 item
- [ ] `is_primary` = true
#### ✅ Test 7: Santri Tanpa Primary (Semua is_primary = false)
```sql
-- Update S004: semua kelas jadi non-primary
UPDATE santri_kelas SET is_primary = 0 WHERE id_santri = 'S004';
```
```bash
# Login sebagai S004
curl -X POST http://localhost:8000/api/login \
-H "Content-Type: application/json" \
-d '{"id_santri": "S004", "password": "password123"}'
```
**Expected:**
```json
{
"santri": {
"kelas": "PB Putra A", // Fallback ke kelas pertama
"kelas_list": [ ... ], // Semua is_primary: false
}
}
```
**Checklist:**
- [ ] Field `kelas` = nama kelas pertama (fallback)
- [ ] Semua item di `kelas_list` punya `is_primary: false`
- [ ] No error/crash
---
### E. Performance Testing
#### ✅ Test 8: Query Count (N+1 Problem Check)
```php
// Tambahkan di ApiAuthController.php (temporary)
\DB::enableQueryLog();
// ... existing code ...
\Log::info('Query count: ' . count(\DB::getQueryLog()));
\Log::info('Queries:', \DB::getQueryLog());
```
**Test:**
1. Login sebagai santri dengan 5 kelas
2. Cek `storage/logs/laravel.log`
**Expected:**
- Query count: 2-3 queries (optimal)
- **NO** N+1 problem (banyak query loop)
**Checklist:**
- [ ] Query count < 5
- [ ] Eager loading bekerja (`with()` clause)
#### ✅ Test 9: Response Time
```bash
# Test response time (run 5x)
time curl -X POST http://localhost:8000/api/login \
-H "Content-Type: application/json" \
-d '{"id_santri": "S001", "password": "password123"}'
```
**Expected:**
- Average response time: < 500ms
- Max response time: < 1000ms
**Checklist:**
- [ ] Response time acceptable
- [ ] No timeout
#### ✅ Test 10: Response Size
```bash
curl -X POST http://localhost:8000/api/login \
-H "Content-Type: application/json" \
-d '{"id_santri": "S001", "password": "password123"}' \
--compressed | wc -c
```
**Expected:**
- Response size: < 10KB
**Checklist:**
- [ ] Response size reasonable
- [ ] No unnecessary data
---
## 📱 PHASE 2: Flutter Mobile App Testing
### F. Setup Testing Environment
#### ✅ Step 1: Update API Base URL
```dart
// lib/core/api/api_service.dart
static const String _baseUrl = 'http://YOUR_LOCAL_IP:8000/api';
// Contoh:
// Windows: 'http://192.168.1.100:8000/api'
// Mac: 'http://192.168.1.100:8000/api'
```
#### ✅ Step 2: Build & Run App
```bash
cd sim_mobile
flutter clean
flutter pub get
flutter run
```
**Checklist:**
- [ ] App build success
- [ ] No compile errors
- [ ] App launched on device/emulator
---
### G. UI Display Testing
#### ✅ Test 11: Login & Display Profile
1. Launch app
2. Login dengan S001 (yang punya 4 kelas)
3. Tap tab "Profil"
**Expected:**
- Header:
- [ ] Avatar displayed
- [ ] Nama lengkap displayed
- [ ] ID Santri displayed
- [ ] **Primary kelas badge** displayed ("📚 Lambatan B")
- [ ] **Hint "+3 kelas lainnya"** displayed
- [ ] Status badge displayed ("Aktif")
- Informasi Dasar Card:
- [ ] No "Kelas" row (removed)
- [ ] All other fields displayed
- **Kelas yang Diikuti Section:**
- [ ] Section card displayed
- [ ] 4 kelompok displayed (PB, Lambatan, Cepatan, Hadist)
- [ ] Each kelompok has correct color & icon
#### ✅ Test 12: ExpansionTile Interaction
1. Tap "PB" kelompok (saat collapsed)
2. **Expected:** ExpansionTile expands, show "PB Putra A"
3. Tap "PB" lagi
4. **Expected:** ExpansionTile collapses
**Checklist:**
- [ ] Expand animation smooth
- [ ] Collapse animation smooth
- [ ] No lag/jank
#### ✅ Test 13: Primary Badge Display
1. Expand "Lambatan" kelompok
2. **Expected:** "Lambatan B" punya badge "⭐ Utama"
3. Expand "PB" kelompok
4. **Expected:** "PB Putra A" TIDAK punya badge (is_primary: false)
**Checklist:**
- [ ] Badge "⭐ Utama" muncul HANYA di primary kelas
- [ ] Badge styling correct (gold background, white icon/text)
#### ✅ Test 14: Color Coding
1. Cek warna per kelompok:
- [ ] PB → Blue (#3b82f6)
- [ ] Lambatan → Orange (#fb923c)
- [ ] Cepatan → Green (#10b981)
- [ ] Hadist → Teal (#14b8a6)
**Checklist:**
- [ ] Icon badge color match
- [ ] Border color match
- [ ] Primary kelas highlight match
---
### H. Edge Case UI Testing
#### ✅ Test 15: Santri Tanpa Kelas
1. Login sebagai S002 (tanpa kelas)
2. Tap tab "Profil"
**Expected:**
- [ ] Header: Primary badge show "-" atau "Belum Ada Kelas"
- [ ] NO hint "+X kelas lainnya"
- [ ] Section "Kelas yang Diikuti" **TIDAK MUNCUL**
- [ ] Informasi Dasar, Alamat, Orang Tua tetap displayed
#### ✅ Test 16: Santri dengan 1 Kelas
1. Login sebagai S003 (1 kelas: PB Putra A)
2. Tap tab "Profil"
**Expected:**
- [ ] Header: Primary badge show "PB Putra A"
- [ ] NO hint "+X kelas lainnya" (karena cuma 1)
- [ ] Section "Kelas yang Diikuti" displayed
- [ ] 1 kelompok saja (PB)
- [ ] Badge "⭐ Utama" muncul
#### ✅ Test 17: Network Error Handling
1. Disconnect internet/WiFi
2. Login atau pull-to-refresh
**Expected:**
- [ ] Error message displayed
- [ ] App tidak crash
- [ ] Cached data (jika ada) tetap displayed
---
### I. Responsive & Performance Testing
#### ✅ Test 18: Small Screen (iPhone SE, 320px)
1. Test di iPhone SE atau emulator 320px width
2. Scroll semua section
**Expected:**
- [ ] No horizontal overflow
- [ ] Text tidak terpotong (ellipsis bekerja)
- [ ] Padding proporsional
- [ ] Badge "Utama" tidak keluar container
#### ✅ Test 19: Large Screen (iPad, 800px)
1. Test di iPad atau emulator tablet
2. Scroll semua section
**Expected:**
- [ ] Layout rapi
- [ ] Padding tidak terlalu besar/kecil
- [ ] ExpansionTile width reasonable
#### ✅ Test 20: Pull-to-Refresh
1. Di profil page, swipe down
2. **Expected:** Loading indicator muncul
3. Wait 1-2 detik
4. **Expected:** Data refresh dari API
**Checklist:**
- [ ] Loading indicator displayed
- [ ] Data updated
- [ ] No crash
#### ✅ Test 21: Memory & Performance
1. Open Developer Tools / Profiler
2. Navigate antara tab (Beranda → Profil → dll)
3. Repeat 5x
**Expected:**
- [ ] Memory usage stable (< 100MB)
- [ ] No memory leak
- [ ] Frame rate: 60 FPS
- [ ] No dropped frames
---
## 📊 PHASE 3: Integration Testing
### J. End-to-End Scenario
#### ✅ Test 22: Complete User Flow
**Scenario:** Ahmad seorang santri baru, didaftarkan oleh admin, lalu login pertama kali.
1. **Admin:** Create santri S010
2. **Admin:** Assign 3 kelas (PB, Lambatan primary, Cepatan)
3. **Admin:** Create user account untuk S010
4. **Mobile:** Login dengan S010
5. **Mobile:** Tap tab Profil
**Expected:**
- [ ] Login berhasil
- [ ] Profil displayed dengan 3 kelas
- [ ] Lambatan sebagai primary (badge "Utama")
- [ ] Primary kelas badge di header
- [ ] Hint "+2 kelas lainnya"
#### ✅ Test 23: Admin Update Kelas → Mobile Refresh
**Scenario:** Admin menambah kelas baru untuk santri.
1. **Mobile:** Login S001, lihat profil (4 kelas)
2. **Admin/Web:** Add kelas baru untuk S001 (Tahfidz)
3. **Mobile:** Pull-to-refresh di profil page
**Expected:**
- [ ] Data refresh dari API
- [ ] 5 kelas sekarang displayed
- [ ] Hint "+4 kelas lainnya"
- [ ] Primary kelas tetap sama
#### ✅ Test 24: Change Primary Kelas
**Scenario:** Admin mengubah primary kelas santri.
1. **Mobile:** Login S001, primary = "Lambatan B"
2. **Admin/Web:** Update primary ke "Cepatan A"
3. **Mobile:** Pull-to-refresh
**Expected:**
- [ ] Primary kelas badge di header = "Cepatan A"
- [ ] Badge "⭐ Utama" pindah ke "Cepatan A"
- [ ] "Lambatan B" tidak punya badge lagi
---
## ✅ PHASE 4: Backward Compatibility Testing
### K. Compatibility Testing
#### ✅ Test 25: Old App + New Backend
**Scenario:** User belum update app, tapi backend sudah update.
**Setup:**
1. Deploy backend baru (dengan kelas_list)
2. Use app versi lama (hanya baca field 'kelas')
**Expected:**
- [ ] Login berhasil
- [ ] Field 'kelas' masih ada di response
- [ ] App lama display kelas primary (single)
- [ ] No crash on app lama
#### ✅ Test 26: New App + Old Backend
**Scenario:** User update app, tapi backend belum update.
**Setup:**
1. Use backend lama (belum ada kelas_list)
2. Use app versi baru
**Expected:**
- [ ] Login berhasil
- [ ] `kelas_list` = null atau tidak ada
- [ ] App baru fallback ke field 'kelas'
- [ ] Section "Kelas yang Diikuti" tidak muncul
- [ ] No crash
---
## 📈 Results Summary
### Backend Testing Results
| Test | Status | Notes |
|------|--------|-------|
| Login Endpoint | ☐ Pass ☐ Fail | |
| Profile Endpoint | ☐ Pass ☐ Fail | |
| Empty Kelas | ☐ Pass ☐ Fail | |
| Single Kelas | ☐ Pass ☐ Fail | |
| No Primary | ☐ Pass ☐ Fail | |
| Query Performance | ☐ Pass ☐ Fail | |
| Response Time | ☐ Pass ☐ Fail | |
### Frontend Testing Results
| Test | Status | Notes |
|------|--------|-------|
| UI Display | ☐ Pass ☐ Fail | |
| Expansion Tile | ☐ Pass ☐ Fail | |
| Primary Badge | ☐ Pass ☐ Fail | |
| Color Coding | ☐ Pass ☐ Fail | |
| Empty State | ☐ Pass ☐ Fail | |
| Pull-to-Refresh | ☐ Pass ☐ Fail | |
| Responsive | ☐ Pass ☐ Fail | |
### Integration Testing Results
| Test | Status | Notes |
|------|--------|-------|
| End-to-End Flow | ☐ Pass ☐ Fail | |
| Admin Update | ☐ Pass ☐ Fail | |
| Change Primary | ☐ Pass ☐ Fail | |
| Backward Compat | ☐ Pass ☐ Fail | |
---
## 🐛 Bug Report Template
```markdown
### Bug Title
[Singkat dan jelas]
### Environment
- OS: [Windows/Mac/Linux]
- Backend: Laravel 10.x
- Frontend: Flutter 3.x
- Device: [iPhone 13, Android Pixel, etc.]
### Steps to Reproduce
1. Login sebagai S001
2. Tap tab Profil
3. Expand kelompok "PB"
4. ...
### Expected Behavior
[Apa yang seharusnya terjadi]
### Actual Behavior
[Apa yang benar-benar terjadi]
### Screenshots
[Attach screenshots jika ada]
### Logs
[Laravel log, Flutter console log]
```
---
## ✅ Sign-off
### Backend Testing
- Tester: _______________
- Date: _______________
- Signature: _______________
### Frontend Testing
- Tester: _______________
- Date: _______________
- Signature: _______________
### Integration Testing
- Tester: _______________
- Date: _______________
- Signature: _______________
---
## 📞 Contact
Jika ada masalah, refer ke:
- `MULTIPLE_KELAS_API_RESPONSE.md` - API documentation
- `MULTIPLE_KELAS_UI_FLUTTER.md` - UI documentation
- `storage/logs/laravel.log` - Backend errors
- Flutter DevTools - Frontend debugging

View File

@ -1,86 +0,0 @@
<?php
// add_capaian_test_data.php - Add sample capaian data with progress
require __DIR__ . '/sim-pkpps/vendor/autoload.php';
$app = require __DIR__ . '/sim-pkpps/bootstrap/app.php';
$app->make('Illuminate\Contracts\Console\Kernel')->bootstrap();
echo "=== ADDING CAPAIAN TEST DATA ===\n\n";
$santri = \App\Models\Santri::where('id_santri', 'S001')->first();
$semester = \App\Models\Semester::aktif()->first();
if (!$santri || !$semester) {
echo "❌ Missing santri or semester\n";
exit;
}
echo "Santri: {$santri->nama_lengkap}\n";
echo "Semester: {$semester->nama_semester}\n\n";
// Get or create materis
$materis = \App\Models\Materi::where('kelas', $santri->kelas)->get();
if ($materis->isEmpty()) {
echo "Creating sample materi...\n";
$materiData = [
[
'nama_kitab' => 'Al-Baqarah',
'kategori' => 'Al-Qur\'an',
'kelas' => 'Lambatan',
'halaman_mulai' => 1,
'halaman_akhir' => 100,
],
[
'nama_kitab' => 'Shahih Bukhari Juz 1',
'kategori' => 'Hadist',
'kelas' => 'Lambatan',
'halaman_mulai' => 1,
'halaman_akhir' => 150,
],
[
'nama_kitab' => 'Tafsir Jalalain',
'kategori' => 'Materi Tambahan',
'kelas' => 'Lambatan',
'halaman_mulai' => 1,
'halaman_akhir' => 200,
],
];
foreach ($materiData as $data) {
$m = \App\Models\Materi::create($data);
echo " ✅ Created: {$m->nama_kitab}\n";
}
$materis = \App\Models\Materi::where('kelas', $santri->kelas)->get();
}
echo "\nAdding capaian with progress...\n";
// Delete existing capaians
\App\Models\Capaian::where('id_santri', $santri->id_santri)->delete();
foreach ($materis as $index => $materi) {
// Create different progress levels
$progressLevels = [
['halaman' => '1-15', 'persentase' => 15],
['halaman' => '1-25,30-40', 'persentase' => 40],
['halaman' => '1-50,60-80', 'persentase' => 70],
];
$progress = $progressLevels[$index % 3];
$capaian = \App\Models\Capaian::create([
'id_santri' => $santri->id_santri,
'id_materi' => $materi->id_materi,
'id_semester' => $semester->id_semester,
'halaman_selesai' => $progress['halaman'],
'tanggal_input' => now(),
]);
echo "{$materi->nama_kitab}: {$capaian->persentase}%\n";
}
echo "\n=== DONE ===\n";
echo "Now try the API again!\n";

View File

@ -1,45 +0,0 @@
<?php
// Quick check: apakah file gambar benar-benar ada?
$imagePath = __DIR__ . '/sim-pkpps/storage/app/public/berita/cxPh4dGaR6qpyPeshxvSQAjgaY7xef9t180dgShU.jpg';
$publicPath = __DIR__ . '/sim-pkpps/public/storage/berita/cxPh4dGaR6qpyPeshxvSQAjgaY7xef9t180dgShU.jpg';
echo "<h1>🔍 Check Gambar Berita</h1>";
echo "<h2>1. File di storage/app/public/berita/</h2>";
if (file_exists($imagePath)) {
echo "✅ File ADA di: <code>$imagePath</code><br>";
echo "Ukuran: " . filesize($imagePath) . " bytes<br>";
} else {
echo "❌ File TIDAK ADA di: <code>$imagePath</code><br>";
}
echo "<h2>2. File di public/storage/berita/ (symlink)</h2>";
if (file_exists($publicPath)) {
echo "✅ File ACCESSIBLE di: <code>$publicPath</code><br>";
echo "Ukuran: " . filesize($publicPath) . " bytes<br>";
} else {
echo "❌ File TIDAK ACCESSIBLE di: <code>$publicPath</code><br>";
}
echo "<h2>3. Test URL</h2>";
$url = 'http://localhost/TugasAkhir/sim-pkpps/public/storage/berita/cxPh4dGaR6qpyPeshxvSQAjgaY7xef9t180dgShU.jpg';
echo "URL: <a href='$url' target='_blank'>$url</a><br><br>";
if (file_exists($publicPath)) {
echo "<img src='$url' style='max-width: 400px; border: 2px solid green;' onerror='this.style.border=\"2px solid red\";'><br>";
echo "<p>Jika gambar di atas tidak muncul, berarti ada masalah CORS atau server config.</p>";
}
echo "<h2>4. Symlink Check</h2>";
$symlinkPath = __DIR__ . '/sim-pkpps/public/storage';
if (is_link($symlinkPath)) {
echo "✅ Symlink EXISTS<br>";
echo "Target: " . readlink($symlinkPath) . "<br>";
} else if (is_dir($symlinkPath)) {
echo "✅ Directory EXISTS (bukan symlink)<br>";
} else {
echo "❌ Storage link TIDAK ADA!<br>";
echo "<p><strong>Solusi:</strong> Jalankan <code>php artisan storage:link</code></p>";
}
?>

View File

@ -1,27 +0,0 @@
<?php
require __DIR__ . '/sim-pkpps/vendor/autoload.php';
$app = require __DIR__ . '/sim-pkpps/bootstrap/app.php';
$app->make('Illuminate\Contracts\Console\Kernel')->bootstrap();
$santri = \App\Models\Santri::where('id_santri', 'S001')->first();
if ($santri) {
echo "Santri: {$santri->nama_lengkap}\n";
echo "ID: {$santri->id_santri}\n";
echo "NIS: {$santri->nis}\n";
// Check users
$users = \App\Models\User::where('role_id', 'S001')->get();
echo "\nUsers for this santri:\n";
foreach ($users as $user) {
echo " - Username: {$user->username}, Role: {$user->role}\n";
// Test password
$testPasswords = ['S001', $santri->nis, '123456', 'password'];
foreach ($testPasswords as $pass) {
if (\Illuminate\Support\Facades\Hash::check($pass, $user->password)) {
echo " ✅ Password: $pass\n";
break;
}
}
}
}

View File

@ -1,52 +0,0 @@
@echo off
echo ============================================
echo SIM-PKPPS Login Test Script
echo ============================================
echo.
echo [1/4] Checking Laravel server...
curl -s http://localhost:8000 > nul 2>&1
if %errorlevel% neq 0 (
echo ❌ Laravel server NOT running!
echo Please run: cd sim-pkpps ^&^& php artisan serve
pause
exit /b 1
)
echo ✅ Laravel server is running
echo.
echo [2/4] Testing API health...
curl -s http://localhost:8000/api/v1/login > nul 2>&1
if %errorlevel% neq 0 (
echo ❌ API endpoint not accessible
pause
exit /b 1
)
echo ✅ API endpoint accessible
echo.
echo [3/4] Checking database connection...
cd sim-pkpps
php artisan tinker --execute="echo 'DB Connected: ' . (DB::connection()->getPdo() ? 'Yes' : 'No');"
if %errorlevel% neq 0 (
echo ❌ Database connection failed
echo Check .env file configuration
pause
exit /b 1
)
echo ✅ Database connected
echo.
echo [4/4] Listing wali accounts...
php artisan tinker --execute="$users = App\Models\User::where('role', 'wali')->with('santri')->get(); foreach($users as $u) { echo 'Username: ' . $u->username . ' | Santri: ' . ($u->santri ? $u->santri->nama_lengkap : 'N/A') . ' | NIS: ' . ($u->santri ? $u->santri->nis : 'N/A') . PHP_EOL; }"
echo.
echo ============================================
echo All checks passed! ✅
echo ============================================
echo.
echo Now you can test login with:
echo - Username: [nama_lengkap_santri]
echo - Password: [nis_santri]
echo.
pause

View File

@ -1,36 +0,0 @@
<?php
require __DIR__ . '/sim-pkpps/vendor/autoload.php';
$app = require __DIR__ . '/sim-pkpps/bootstrap/app.php';
$app->make('Illuminate\Contracts\Console\Kernel')->bootstrap();
echo "=== USERS IN DATABASE ===\n\n";
$users = \App\Models\User::whereIn('role', ['santri', 'wali'])->get();
if ($users->isEmpty()) {
echo "❌ No santri/wali users found\n";
echo "\nCreating test user...\n";
// Create a test wali user
$santri = \App\Models\Santri::first();
if ($santri) {
$user = \App\Models\User::create([
'username' => 'wali001',
'name' => 'Wali ' . $santri->nama_lengkap,
'email' => 'wali001@test.com',
'password' => bcrypt('S001'),
'role' => 'wali',
'role_id' => $santri->id_santri,
]);
echo "✅ Created user: {$user->username} (password: S001)\n";
echo " Role: {$user->role}\n";
echo " Role ID: {$user->role_id}\n";
}
} else {
foreach ($users as $user) {
echo "Username: {$user->username}\n";
echo "Role: {$user->role}\n";
echo "Role ID: {$user->role_id}\n";
echo "---\n";
}
}

View File

@ -1,193 +0,0 @@
<?php
/**
* Comprehensive Debug Script
* Akses: http://localhost/TugasAkhir/debug_comprehensive.php
*/
echo "<html><head><title>Debug Comprehensive</title>";
echo "<style>body{font-family:Arial;padding:20px;} .ok{color:green;} .error{color:red;} .section{border:1px solid #ddd;padding:15px;margin:10px 0;} pre{background:#f4f4f4;padding:10px;}</style>";
echo "</head><body>";
echo "<h1>🔍 Comprehensive Debug - SIM-PKPPS</h1>";
echo "<p>Waktu: " . date('Y-m-d H:i:s') . "</p><hr>";
// Test 1: Laravel Files
echo "<div class='section'>";
echo "<h2>1. File Existence Check</h2>";
$files = [
'Controller' => __DIR__ . '/sim-pkpps/app/Http/Controllers/Admin/UserController.php',
'Routes' => __DIR__ . '/sim-pkpps/routes/web.php',
'View Wali' => __DIR__ . '/sim-pkpps/resources/views/admin/users/wali_accounts.blade.php',
'API Controller' => __DIR__ . '/sim-pkpps/app/Http/Controllers/Api/ApiAuthController.php',
'Flutter Config' => __DIR__ . '/sim_mobile/lib/core/config/app_config.dart',
];
foreach ($files as $name => $path) {
if (file_exists($path)) {
echo "✅ <span class='ok'>{$name}: EXISTS</span> - Modified: " . date('Y-m-d H:i:s', filemtime($path)) . "<br>";
} else {
echo "❌ <span class='error'>{$name}: NOT FOUND</span><br>";
}
}
echo "</div>";
// Test 2: Routes Content
echo "<div class='section'>";
echo "<h2>2. Routes Check</h2>";
$routesFile = __DIR__ . '/sim-pkpps/routes/web.php';
if (file_exists($routesFile)) {
$content = file_get_contents($routesFile);
$checks = [
'wali_destroy route' => "name('wali_destroy')",
'wali_reset_password route' => "name('wali_reset_password')",
'POST delete method' => "post('wali/{userId}/delete'",
'POST reset method' => "post('wali/{userId}/reset-password'"
];
foreach ($checks as $desc => $needle) {
if (strpos($content, $needle) !== false) {
echo "✅ <span class='ok'>{$desc}: FOUND</span><br>";
} else {
echo "❌ <span class='error'>{$desc}: NOT FOUND</span><br>";
}
}
}
echo "</div>";
// Test 3: View File Check
echo "<div class='section'>";
echo "<h2>3. View File Check (wali_accounts.blade.php)</h2>";
$viewFile = __DIR__ . '/sim-pkpps/resources/views/admin/users/wali_accounts.blade.php';
if (file_exists($viewFile)) {
$content = file_get_contents($viewFile);
$checks = [
'Delete button' => "route('admin.users.wali_destroy'",
'Reset button' => "route('admin.users.wali_reset_password'",
'CSRF token' => '@csrf',
'User ID parameter' => '$user->id',
];
foreach ($checks as $desc => $needle) {
if (strpos($content, $needle) !== false) {
echo "✅ <span class='ok'>{$desc}: FOUND</span><br>";
} else {
echo "❌ <span class='error'>{$desc}: NOT FOUND</span><br>";
}
}
echo "<br><strong>Last modified:</strong> " . date('Y-m-d H:i:s', filemtime($viewFile));
}
echo "</div>";
// Test 4: Controller Methods
echo "<div class='section'>";
echo "<h2>4. Controller Methods Check</h2>";
$controllerFile = __DIR__ . '/sim-pkpps/app/Http/Controllers/Admin/UserController.php';
if (file_exists($controllerFile)) {
$content = file_get_contents($controllerFile);
$checks = [
'destroyAccount method' => 'public function destroyAccount(string $role, string $userId)',
'resetPassword method' => 'public function resetPassword(string $role, string $userId)',
'User::findOrFail in destroy' => 'User::findOrFail($userId)',
];
foreach ($checks as $desc => $needle) {
if (strpos($content, $needle) !== false) {
echo "✅ <span class='ok'>{$desc}: FOUND</span><br>";
} else {
echo "❌ <span class='error'>{$desc}: NOT FOUND</span><br>";
}
}
}
echo "</div>";
// Test 5: Flutter Config
echo "<div class='section'>";
echo "<h2>5. Flutter Configuration</h2>";
$flutterConfig = __DIR__ . '/sim_mobile/lib/core/config/app_config.dart';
if (file_exists($flutterConfig)) {
$content = file_get_contents($flutterConfig);
if (strpos($content, 'TugasAkhir/sim-pkpps/public/api/v1') !== false) {
echo "✅ <span class='ok'>Base URL: CORRECT (includes TugasAkhir path)</span><br>";
} else {
echo "❌ <span class='error'>Base URL: INCORRECT (missing TugasAkhir path)</span><br>";
}
echo "<br><strong>Current URL in file:</strong><br>";
preg_match('/baseUrl = \'(.+?)\'/s', $content, $matches);
if (isset($matches[1])) {
echo "<pre>" . htmlspecialchars($matches[1]) . "</pre>";
}
}
echo "</div>";
// Test 6: API Test
echo "<div class='section'>";
echo "<h2>6. API Login Test</h2>";
$apiUrl = 'http://localhost/TugasAkhir/sim-pkpps/public/api/v1/login';
$data = json_encode([
'id_santri' => 'Aydin Fauzan',
'password' => 's002'
]);
$options = [
'http' => [
'header' => "Content-Type: application/json\r\nAccept: application/json\r\n",
'method' => 'POST',
'content' => $data,
'ignore_errors' => true
],
];
$context = stream_context_create($options);
$result = @file_get_contents($apiUrl, false, $context);
if ($result !== false) {
$response = json_decode($result, true);
if (isset($response['success']) && $response['success']) {
echo "✅ <span class='ok'>API Login: SUCCESS</span><br>";
echo "<strong>Token:</strong> " . substr($response['token'], 0, 20) . "...<br>";
echo "<strong>User:</strong> " . $response['user']['name'] . "<br>";
echo "<strong>Role:</strong> " . $response['user']['role'] . "<br>";
} else {
echo "❌ <span class='error'>API Login: FAILED</span><br>";
echo "<pre>" . htmlspecialchars(print_r($response, true)) . "</pre>";
}
} else {
echo "❌ <span class='error'>API: CANNOT CONNECT</span><br>";
}
echo "</div>";
// Test 7: Database Check
echo "<div class='section'>";
echo "<h2>7. Database Wali Accounts</h2>";
$dbFile = __DIR__ . '/sim-pkpps/.env';
if (file_exists($dbFile)) {
echo "✅ <span class='ok'>.env file exists</span><br>";
echo "<p>⚠️ Untuk cek database, gunakan phpMyAdmin atau Tinker</p>";
echo "<pre>php artisan tinker --execute=\"echo App\\Models\\User::where('role','wali')->count();\"</pre>";
} else {
echo "❌ <span class='error'>.env file not found</span><br>";
}
echo "</div>";
// Summary
echo "<hr><div class='section'>";
echo "<h2>📋 Summary & Next Steps</h2>";
echo "<ol>";
echo "<li><strong>Clear Browser Cache:</strong> Ctrl+Shift+R atau Ctrl+F5</li>";
echo "<li><strong>Login ke Admin:</strong> <a href='http://localhost/TugasAkhir/sim-pkpps/public/admin/login' target='_blank'>Login Admin</a></li>";
echo "<li><strong>Test Wali Accounts:</strong> <a href='http://localhost/TugasAkhir/sim-pkpps/public/admin/users/wali' target='_blank'>Wali Accounts</a></li>";
echo "<li><strong>Flutter:</strong> Hot Restart (bukan Hot Reload)</li>";
echo "<li><strong>Test Login Mobile:</strong> Username=<code>Aydin Fauzan</code>, Password=<code>s002</code></li>";
echo "</ol>";
echo "</div>";
echo "<hr><p><em>Generated at " . date('Y-m-d H:i:s') . "</em></p>";
echo "</body></html>";
?>

View File

@ -1,202 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Debug - Test Semua Fungsi</title>
<style>
body {
font-family: Arial;
padding: 20px;
background: #f5f5f5;
}
.container {
max-width: 900px;
margin: 0 auto;
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 { color: #333; border-bottom: 3px solid #007bff; padding-bottom: 10px; }
h2 { color: #555; margin-top: 30px; }
.test-box {
background: #f8f9fa;
border-left: 4px solid #007bff;
padding: 15px;
margin: 15px 0;
}
.status {
display: inline-block;
padding: 5px 10px;
border-radius: 4px;
font-weight: bold;
margin-left: 10px;
}
.status.ok { background: #28a745; color: white; }
.status.error { background: #dc3545; color: white; }
button {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
margin: 5px;
}
.btn-primary { background: #007bff; color: white; }
.btn-danger { background: #dc3545; color: white; }
.btn-warning { background: #ffc107; color: black; }
.btn-info { background: #17a2b8; color: white; }
pre { background: #f4f4f4; padding: 10px; border-radius: 4px; overflow-x: auto; }
.result { margin: 10px 0; padding: 10px; border-radius: 4px; }
.result.success { background: #d4edda; color: #155724; }
.result.error { background: #f8d7da; color: #721c24; }
</style>
</head>
<body>
<div class="container">
<h1>🔍 Debug Test - SIM-PKPPS</h1>
<p><strong>Waktu Test:</strong> <?php echo date('Y-m-d H:i:s'); ?></p>
<!-- Test 1: Laravel Routes -->
<div class="test-box">
<h2>1⃣ Test Laravel Routes</h2>
<button class="btn-primary" onclick="testRoutes()">Test Routes</button>
<div id="routes-result"></div>
</div>
<!-- Test 2: Database Connection -->
<div class="test-box">
<h2>2⃣ Test Database</h2>
<button class="btn-primary" onclick="testDatabase()">Test Database</button>
<div id="db-result"></div>
</div>
<!-- Test 3: API Login -->
<div class="test-box">
<h2>3⃣ Test API Login</h2>
<p>Username: <strong>Aydin Fauzan</strong>, Password: <strong>s002</strong></p>
<button class="btn-info" onclick="testApiLogin()">Test Login</button>
<div id="api-result"></div>
</div>
<!-- Test 4: Flutter Config -->
<div class="test-box">
<h2>4⃣ Flutter Configuration</h2>
<pre>Base URL: http://10.0.2.2/TugasAkhir/sim-pkpps/public/api/v1</pre>
<p>✅ URL sudah benar untuk emulator Android</p>
<p>⚠️ Pastikan:</p>
<ul>
<li>Apache/XAMPP sudah running</li>
<li>Buka dari emulator (bukan real device)</li>
<li>Hot reload Flutter setelah ubah config</li>
</ul>
</div>
<!-- Test 5: Manual Action -->
<div class="test-box">
<h2>5⃣ Manual Test Delete & Reset</h2>
<p><strong>Akun tersedia:</strong></p>
<ul>
<li>ID 6: Aydin Fauzan</li>
<li>ID 7: HELGA FAISA_1</li>
<li>ID 9: Leni Yulia</li>
<li>ID 10: Mifta Okta Yanti</li>
</ul>
<form action="/TugasAkhir/sim-pkpps/public/admin/users/wali/9/reset-password" method="POST" style="display:inline;">
<button type="submit" class="btn-warning">🔑 Reset Password ID 9</button>
</form>
<form action="/TugasAkhir/sim-pkpps/public/admin/users/wali/10/delete" method="POST" style="display:inline;">
<button type="submit" class="btn-danger" onclick="return confirm('Hapus ID 10?')">🗑️ Delete ID 10</button>
</form>
</div>
<!-- Instructions -->
<div class="test-box">
<h2>📋 Troubleshooting Checklist</h2>
<ul>
<li>✅ Clear cache Laravel (sudah dilakukan)</li>
<li>❓ Refresh browser dengan Ctrl+Shift+R (hard refresh)</li>
<li>❓ Login dulu ke admin panel</li>
<li>❓ Cek console browser (F12) untuk error JavaScript</li>
<li>❓ Flutter: Hot restart (bukan hot reload)</li>
</ul>
</div>
</div>
<script>
function testRoutes() {
const result = document.getElementById('routes-result');
result.innerHTML = '<p>Testing...</p>';
fetch('/TugasAkhir/sim-pkpps/public/api/v1/login', {
method: 'HEAD'
})
.then(response => {
result.innerHTML = `<div class="result success">✅ Route API accessible (Status: ${response.status})</div>`;
})
.catch(error => {
result.innerHTML = `<div class="result error">❌ Error: ${error.message}</div>`;
});
}
function testDatabase() {
const result = document.getElementById('db-result');
result.innerHTML = '<p>Testing database...</p>';
// Simple check via API
fetch('/TugasAkhir/sim-pkpps/public/api/v1/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
id_santri: 'test',
password: 'test'
})
})
.then(response => response.json())
.then(data => {
result.innerHTML = '<div class="result success">✅ Database connection OK (API responding)</div>';
})
.catch(error => {
result.innerHTML = `<div class="result error">❌ Database error: ${error.message}</div>`;
});
}
function testApiLogin() {
const result = document.getElementById('api-result');
result.innerHTML = '<p>Testing login...</p>';
fetch('/TugasAkhir/sim-pkpps/public/api/v1/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
id_santri: 'Aydin Fauzan',
password: 's002'
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
result.innerHTML = `
<div class="result success">
<strong>✅ LOGIN BERHASIL!</strong><br>
Token: ${data.token}<br>
User: ${data.user.name}<br>
Role: ${data.user.role}<br>
Santri: ${data.santri.nama_lengkap}
</div>
`;
} else {
result.innerHTML = `<div class="result error">❌ Login gagal: ${data.message}</div>`;
}
})
.catch(error => {
result.innerHTML = `<div class="result error">❌ Error: ${error.message}</div>`;
});
}
</script>
</body>
</html>

View File

@ -1,151 +0,0 @@
<?php
/**
* INSERT SAMPLE BERITA - Quick Setup
*/
error_reporting(E_ALL);
ini_set('display_errors', 1);
$host = 'localhost';
$db = 'db_sim_pkpps';
$user = 'root';
$pass = '';
try {
$pdo = new PDO("mysql:host=$host;dbname=$db;charset=utf8mb4", $user, $pass);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
echo "<h1>📝 INSERT SAMPLE BERITA</h1>";
echo "<hr>";
// Cek apakah sudah ada berita
$stmt = $pdo->query("SELECT COUNT(*) as total FROM berita");
$count = $stmt->fetch(PDO::FETCH_ASSOC)['total'];
if ($count > 0) {
echo "<p style='color: orange;'>⚠️ Sudah ada {$count} berita di database.</p>";
echo "<p>Apakah Anda ingin menambah berita sample lagi?</p>";
echo "<form method='post'>";
echo "<button type='submit' name='tambah' style='padding: 10px 20px; font-size: 16px;'>Ya, Tambah Sample Berita</button>";
echo "</form>";
if (!isset($_POST['tambah'])) {
exit;
}
}
echo "<h2>🚀 Menambahkan sample berita...</h2>";
// Ambil santri untuk pivot table
$stmt = $pdo->query("SELECT id_santri FROM santris WHERE status = 'Aktif' LIMIT 3");
$santriList = $stmt->fetchAll(PDO::FETCH_COLUMN);
if (count($santriList) < 1) {
die("<p style='color: red;'>❌ Tidak ada santri aktif! Tidak bisa buat sample berita 'santri_tertentu'</p>");
}
// 1. Berita untuk SEMUA
$sql = "INSERT INTO berita (id_berita, judul, konten, penulis, status, target_berita, created_at, updated_at)
VALUES (?, ?, ?, ?, 'published', 'semua', NOW(), NOW())";
$beritaSemua = [
['B101', 'Pengumuman Libur Pondok', 'Assalamualaikum. Pondok akan libur tanggal 10-15 Februari 2026. Harap kembali tanggal 16 Februari jam 07:00. Barakallah.', 'Admin Pondok'],
['B102', 'Jadwal Ujian Semester', 'Kepada seluruh santri, ujian semester akan dimulai 20 Februari 2026. Silakan persiapkan diri dengan baik. Semoga sukses!', 'Bagian Pendidikan'],
];
foreach ($beritaSemua as $data) {
try {
$stmt = $pdo->prepare($sql);
$stmt->execute($data);
echo "✅ Berita {$data[0]} (target: SEMUA) berhasil ditambahkan<br>";
} catch (PDOException $e) {
if (strpos($e->getMessage(), 'Duplicate entry') !== false) {
echo "⚠️ Berita {$data[0]} sudah ada (skip)<br>";
} else {
echo "❌ Error: {$e->getMessage()}<br>";
}
}
}
echo "<br>";
// 2. Berita untuk KELAS TERTENTU
$sql = "INSERT INTO berita (id_berita, judul, konten, penulis, status, target_berita, target_kelas, created_at, updated_at)
VALUES (?, ?, ?, ?, 'published', 'kelas_tertentu', ?, NOW(), NOW())";
$beritaKelas = [
['B103', 'Info Kelas PB', 'Kepada santri kelas PB, akan ada kelas tambahan setiap Kamis jam 15:00. Mohon hadir tepat waktu.', 'Ustadz Nahwu', '["PB"]'],
['B104', 'Ujian Kelas Lambatan', 'Santri kelas Lambatan akan ujian kenaikan tingkat tanggal 5 Maret 2026. Harap persiapkan diri!', 'Bagian Pendidikan', '["Lambatan"]'],
['B105', 'Kegiatan Kelas Cepatan', 'Kelas Cepatan akan muhadhoroh setiap Jumat malam. Jadwal akan dibagikan minggu depan.', 'Ustadz Pembimbing', '["Cepatan"]'],
];
foreach ($beritaKelas as $data) {
try {
$stmt = $pdo->prepare($sql);
$stmt->execute($data);
echo "✅ Berita {$data[0]} (target: KELAS) berhasil ditambahkan<br>";
} catch (PDOException $e) {
if (strpos($e->getMessage(), 'Duplicate entry') !== false) {
echo "⚠️ Berita {$data[0]} sudah ada (skip)<br>";
} else {
echo "❌ Error: {$e->getMessage()}<br>";
}
}
}
echo "<br>";
// 3. Berita untuk SANTRI TERTENTU
$sql = "INSERT INTO berita (id_berita, judul, konten, penulis, status, target_berita, created_at, updated_at)
VALUES (?, ?, ?, ?, 'published', 'santri_tertentu', NOW(), NOW())";
$beritaSantri = [
['B106', 'Pesan Khusus - Menemui Admin', 'Assalamualaikum. Saudara diminta menemui bagian administrasi hari Senin jam 10:00. Terima kasih.', 'Admin'],
['B107', 'Reminder Uang Saku', 'Saldo uang saku Anda menipis (di bawah Rp 50.000). Harap segera top up. Barakallah.', 'Bagian Keuangan'],
];
foreach ($beritaSantri as $data) {
try {
$stmt = $pdo->prepare($sql);
$stmt->execute($data);
echo "✅ Berita {$data[0]} (target: SANTRI TERTENTU) berhasil ditambahkan<br>";
// Insert ke pivot table untuk santri pertama
if (count($santriList) > 0) {
$sqlPivot = "INSERT INTO berita_santri (id_berita, id_santri, sudah_dibaca, created_at, updated_at)
VALUES (?, ?, FALSE, NOW(), NOW())";
$stmtPivot = $pdo->prepare($sqlPivot);
$stmtPivot->execute([$data[0], $santriList[0]]);
echo " └─ Ditambahkan untuk santri {$santriList[0]}<br>";
}
} catch (PDOException $e) {
if (strpos($e->getMessage(), 'Duplicate entry') !== false) {
echo "⚠️ Berita {$data[0]} sudah ada (skip)<br>";
} else {
echo "❌ Error: {$e->getMessage()}<br>";
}
}
}
echo "<hr>";
echo "<h2>✅ SELESAI!</h2>";
// Tampilkan ringkasan
$stmt = $pdo->query("SELECT target_berita, COUNT(*) as jumlah FROM berita GROUP BY target_berita");
$summary = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo "<h3>📊 Ringkasan Berita:</h3>";
foreach ($summary as $row) {
echo "- <strong>{$row['target_berita']}</strong>: {$row['jumlah']} berita<br>";
}
echo "<br><br>";
echo "<a href='test_query_berita.php' style='padding: 10px 20px; background: #7C3AED; color: white; text-decoration: none; border-radius: 5px;'>Test Query Berita</a> ";
echo "<a href='test_api_berita.php' style='padding: 10px 20px; background: #059669; color: white; text-decoration: none; border-radius: 5px;'>Debug API Berita</a>";
} catch (PDOException $e) {
echo "<h1 style='color: red;'>❌ ERROR</h1>";
echo "<p>{$e->getMessage()}</p>";
}
?>

View File

@ -1,202 +0,0 @@
# Migration Helper Script for Windows PowerShell
# Usage: .\migrate_helper.ps1 [action]
param(
[string]$action = "help"
)
$simdDir = "sim-pkpps"
function Show-Header {
Write-Host "╔══════════════════════════════════════════════════════╗" -ForegroundColor Cyan
Write-Host "║ SIM Pondok Pesantren - Kelas Migration ║" -ForegroundColor Cyan
Write-Host "╚══════════════════════════════════════════════════════╝" -ForegroundColor Cyan
Write-Host ""
}
function Show-Help {
Show-Header
Write-Host "Available actions:" -ForegroundColor Yellow
Write-Host ""
Write-Host " install - Run migrations and seeders" -ForegroundColor Green
Write-Host " migrate-test - Test data migration (dry-run)" -ForegroundColor Green
Write-Host " migrate - Run actual data migration" -ForegroundColor Green
Write-Host " scan - Scan codebase for kelas usage" -ForegroundColor Green
Write-Host " report - Open refactoring report" -ForegroundColor Green
Write-Host " verify - Verify migration status" -ForegroundColor Green
Write-Host " help - Show this help message" -ForegroundColor Green
Write-Host ""
Write-Host "Examples:" -ForegroundColor Yellow
Write-Host " .\migrate_helper.ps1 install" -ForegroundColor Gray
Write-Host " .\migrate_helper.ps1 migrate-test" -ForegroundColor Gray
Write-Host " .\migrate_helper.ps1 scan" -ForegroundColor Gray
Write-Host ""
}
function Run-Install {
Show-Header
Write-Host "📦 Installing new kelas system..." -ForegroundColor Yellow
Write-Host ""
Write-Host "Step 1: Running migrations..." -ForegroundColor Cyan
Set-Location $simdDir
php artisan migrate
Write-Host "✓ Migrations completed" -ForegroundColor Green
Write-Host ""
Write-Host "Step 2: Seeding kelompok kelas..." -ForegroundColor Cyan
php artisan db:seed --class=KelompokKelasSeeder
Write-Host "✓ Kelompok kelas seeded" -ForegroundColor Green
Write-Host ""
Write-Host "Step 3: Seeding kelas..." -ForegroundColor Cyan
php artisan db:seed --class=KelasSeeder
Write-Host "✓ Kelas seeded" -ForegroundColor Green
Write-Host ""
Set-Location ..
Write-Host "✓ Installation completed!" -ForegroundColor Green
Write-Host ""
Write-Host "Next steps:" -ForegroundColor Yellow
Write-Host " 1. Run: .\migrate_helper.ps1 migrate-test" -ForegroundColor Gray
Write-Host " 2. If OK, run: .\migrate_helper.ps1 migrate" -ForegroundColor Gray
Write-Host ""
}
function Run-MigrateTest {
Show-Header
Write-Host "🔍 Testing data migration (dry-run)..." -ForegroundColor Yellow
Write-Host ""
Set-Location $simdDir
php artisan migrate:santri-kelas --dry-run
Set-Location ..
Write-Host ""
Write-Host "If everything looks good:" -ForegroundColor Yellow
Write-Host " Run: .\migrate_helper.ps1 migrate" -ForegroundColor Gray
Write-Host ""
}
function Run-Migrate {
Show-Header
Write-Host "⚠️ This will migrate santri kelas data to new system" -ForegroundColor Yellow
Write-Host ""
$confirm = Read-Host "Are you sure? (yes/no)"
if ($confirm -eq "yes") {
Write-Host ""
Write-Host "🚀 Running migration..." -ForegroundColor Cyan
Set-Location $simdDir
php artisan migrate:santri-kelas
Set-Location ..
Write-Host ""
Write-Host "✓ Migration completed!" -ForegroundColor Green
Write-Host ""
Write-Host "Next steps:" -ForegroundColor Yellow
Write-Host " 1. Run: .\migrate_helper.ps1 verify" -ForegroundColor Gray
Write-Host " 2. Run: .\migrate_helper.ps1 scan" -ForegroundColor Gray
Write-Host ""
} else {
Write-Host "Migration cancelled" -ForegroundColor Yellow
}
}
function Run-Scan {
Show-Header
Write-Host "🔍 Scanning codebase for kelas usage..." -ForegroundColor Yellow
Write-Host ""
php scan_kelas_usage.php
Write-Host ""
Write-Host "✓ Scan completed!" -ForegroundColor Green
Write-Host ""
Write-Host "Review the reports:" -ForegroundColor Yellow
Write-Host " - KELAS_USAGE_MAP.md (detailed map)" -ForegroundColor Gray
Write-Host " - REFACTORING_GUIDE.md (quick reference)" -ForegroundColor Gray
Write-Host ""
Write-Host "Open report? (yes/no)" -ForegroundColor Yellow
$openReport = Read-Host
if ($openReport -eq "yes") {
code KELAS_USAGE_MAP.md
}
}
function Run-Verify {
Show-Header
Write-Host "📊 Verifying migration status..." -ForegroundColor Yellow
Write-Host ""
Set-Location $simdDir
Write-Host "Migration status:" -ForegroundColor Cyan
php artisan migrate:status | Select-String "kelompok_kelas|kelas|santri_kelas|kegiatan_kelas"
Write-Host ""
Write-Host "Checking data counts:" -ForegroundColor Cyan
php artisan tinker --execute="echo 'Kelompok Kelas: ' . App\Models\KelompokKelas::count() . PHP_EOL;"
php artisan tinker --execute="echo 'Kelas: ' . App\Models\Kelas::count() . PHP_EOL;"
php artisan tinker --execute="echo 'Santri Kelas: ' . App\Models\SantriKelas::count() . PHP_EOL;"
php artisan tinker --execute="echo 'Santri with old kelas: ' . App\Models\Santri::whereNotNull('kelas')->count() . PHP_EOL;"
Write-Host ""
Set-Location ..
Write-Host "✓ Verification completed!" -ForegroundColor Green
Write-Host ""
}
function Open-Report {
Show-Header
Write-Host "📖 Opening refactoring reports..." -ForegroundColor Yellow
Write-Host ""
if (Test-Path "KELAS_USAGE_MAP.md") {
code KELAS_USAGE_MAP.md
Write-Host "✓ Opened KELAS_USAGE_MAP.md" -ForegroundColor Green
} else {
Write-Host "⚠️ KELAS_USAGE_MAP.md not found. Run scan first." -ForegroundColor Yellow
}
if (Test-Path "REFACTORING_GUIDE.md") {
code REFACTORING_GUIDE.md
Write-Host "✓ Opened REFACTORING_GUIDE.md" -ForegroundColor Green
}
Write-Host ""
}
# Main script execution
switch ($action.ToLower()) {
"install" {
Run-Install
}
"migrate-test" {
Run-MigrateTest
}
"migrate" {
Run-Migrate
}
"scan" {
Run-Scan
}
"verify" {
Run-Verify
}
"report" {
Open-Report
}
"help" {
Show-Help
}
default {
Write-Host "Unknown action: $action" -ForegroundColor Red
Write-Host ""
Show-Help
}
}

View File

@ -1,135 +0,0 @@
-- ============================================
-- SAMPLE DATA BERITA - 3 KATEGORI
-- ============================================
-- KATEGORI 1: BERITA UNTUK SEMUA SANTRI
-- ======================================
INSERT INTO berita (id_berita, judul, konten, penulis, status, target_berita, created_at, updated_at)
VALUES
('B001', 'Pengumuman Libur Pondok',
'Assalamualaikum wr. wb. Diberitahukan kepada seluruh santri bahwa pondok akan libur pada tanggal 10-15 Februari 2026 dalam rangka peringatan Maulid Nabi Muhammad SAW. Mohon untuk kembali ke pondok pada tanggal 16 Februari 2026 pukul 07:00 pagi. Jazakumullah khairan.',
'Admin Pondok', 'published', 'semua', NOW(), NOW()),
('B002', 'Jadwal Ujian Semester Genap',
'Kepada seluruh santri, jadwal ujian semester genap akan dimulai tanggal 20 Februari 2026. Harap mempersiapkan diri dengan baik. Jadwal lengkap akan diumumkan kemudian. Semoga Allah memudahkan.',
'Bagian Pendidikan', 'published', 'semua', NOW(), NOW()),
('B003', 'Pengumuman Kegiatan Haul Akbar',
'Bismillah. Dalam rangka memperingati Haul Kyai Pendiri Pondok yang ke-50, akan diadakan kegiatan haul akbar pada tanggal 25 Februari 2026. Seluruh santri diwajibkan mengikuti acara. Mohon kehadiran dan partisipasinya.',
'Pengurus Pondok', 'published', 'semua', NOW(), NOW());
-- KATEGORI 2: BERITA UNTUK KELAS TERTENTU
-- ========================================
-- Berita untuk Kelas PB saja
INSERT INTO berita (id_berita, judul, konten, penulis, status, target_berita, target_kelas, created_at, updated_at)
VALUES
('B004', 'Jadwal Tambahan Kelas PB',
'Kepada santri kelas PB, mulai minggu depan akan ada kelas tambahan setiap hari Kamis jam 15:00-16:30 untuk pendalaman materi Nahwu. Mohon kehadirannya tepat waktu. Barakallah.',
'Ustadz Nahwu', 'published', 'kelas_tertentu', '["PB"]', NOW(), NOW());
-- Berita untuk Kelas Lambatan saja
INSERT INTO berita (id_berita, judul, konten, penulis, status, target_berita, target_kelas, created_at, updated_at)
VALUES
('B005', 'Ujian Kenaikan Kelas Lambatan',
'Santri kelas Lambatan akan mengikuti ujian kenaikan tingkat pada tanggal 5 Maret 2026. Materi ujian meliputi Nahwu, Shorof, Fiqih, dan Tajwid. Harap mempersiapkan diri dengan sungguh-sungguh. Semoga sukses!',
'Bagian Pendidikan', 'published', 'kelas_tertentu', '["Lambatan"]', NOW(), NOW());
-- Berita untuk Kelas Cepatan saja
INSERT INTO berita (id_berita, judul, konten, penulis, status, target_berita, target_kelas, created_at, updated_at)
VALUES
('B006', 'Kegiatan Muhadhoroh Kelas Cepatan',
'Kepada santri kelas Cepatan, akan diadakan kegiatan muhadhoroh (latihan pidato) setiap hari Jumat malam. Setiap santri akan mendapat giliran. Jadwal akan dibagikan minggu depan. Siapkan materi pidato dengan baik.',
'Ustadz Pembimbing', 'published', 'kelas_tertentu', '["Cepatan"]', NOW(), NOW());
-- Berita untuk PB dan Lambatan (2 kelas sekaligus)
INSERT INTO berita (id_berita, judul, konten, penulis, status, target_berita, target_kelas, created_at, updated_at)
VALUES
('B007', 'Info Kegiatan Ekstrakurikuler',
'Kepada santri kelas PB dan Lambatan, dibuka pendaftaran ekstrakurikuler Tahfidz dan Kaligrafi. Pendaftaran dilakukan di kantor pondok mulai Senin-Rabu jam 16:00-17:00. Kuota terbatas, siapa cepat dia dapat!',
'Bagian Kegiatan', 'published', 'kelas_tertentu', '["PB", "Lambatan"]', NOW(), NOW());
-- KATEGORI 3: BERITA UNTUK SANTRI TERTENTU
-- =========================================
-- Berita pribadi untuk santri tertentu
INSERT INTO berita (id_berita, judul, konten, penulis, status, target_berita, created_at, updated_at)
VALUES
('B008', 'Pesan Khusus - Harap Menemui Admin',
'Assalamualaikum. Saudara diminta untuk menemui bagian administrasi pondok pada hari Senin jam 10:00 untuk pengecekan data dan pemberkasan. Mohon datang tepat waktu. Terima kasih.',
'Admin', 'published', 'santri_tertentu', NOW(), NOW()),
('B009', 'Reminder Pembayaran Uang Saku',
'Assalamualaikum. Kami informasikan bahwa saldo uang saku Anda sudah menipis (di bawah Rp 50.000). Harap segera melakukan top up agar tidak terkendala dalam pembelian kebutuhan sehari-hari. Barakallah.',
'Bagian Keuangan', 'published', 'santri_tertentu', NOW(), NOW()),
('B010', 'Undangan Pertemuan Wali',
'Kepada Bapak/Ibu Wali Santri, kami mengundang untuk hadir dalam pertemuan membahas perkembangan santri pada hari Sabtu, 22 Februari 2026 pukul 09:00 di aula pondok. Kehadiran sangat kami harapkan. Jazakumullah khairan.',
'Pengurus Pondok', 'published', 'santri_tertentu', NOW(), NOW());
-- ============================================
-- PIVOT TABLE untuk SANTRI TERTENTU (B008, B009, B010)
-- ============================================
--
-- CATATAN: Sesuaikan id_santri dengan data di database Anda!
--
-- Contoh: Jika di database ada santri dengan id_santri 'S001', 'S002', 'S003'
-- maka insert seperti ini:
-- Berita B008 untuk S001 dan S002
INSERT INTO berita_santri (id_berita, id_santri, sudah_dibaca, created_at, updated_at)
VALUES
('B008', 'S001', FALSE, NOW(), NOW()),
('B008', 'S002', FALSE, NOW(), NOW());
-- Berita B009 untuk S001 saja
INSERT INTO berita_santri (id_berita, id_santri, sudah_dibaca, created_at, updated_at)
VALUES
('B009', 'S001', FALSE, NOW(), NOW());
-- Berita B010 untuk S001, S002, dan S003
INSERT INTO berita_santri (id_berita, id_santri, sudah_dibaca, created_at, updated_at)
VALUES
('B010', 'S001', FALSE, NOW(), NOW()),
('B010', 'S002', FALSE, NOW(), NOW()),
('B010', 'S003', FALSE, NOW(), NOW());
-- ============================================
-- CARA CEK ID SANTRI YANG ADA
-- ============================================
-- Jalankan query ini dulu untuk lihat id_santri yang tersedia:
--
-- SELECT id_santri, nama_lengkap, kelas, status
-- FROM santris
-- WHERE status = 'Aktif'
-- ORDER BY nama_lengkap;
--
-- Kemudian sesuaikan INSERT berita_santri di atas dengan id_santri yang sesuai
-- ============================================
-- HASIL YANG DIHARAPKAN
-- ============================================
--
-- 1. Berita B001-B003 (target: semua)
-- → Muncul untuk SEMUA santri yang login
--
-- 2. Berita B004 (target: kelas PB)
-- → Hanya muncul untuk santri kelas PB
--
-- 3. Berita B005 (target: kelas Lambatan)
-- → Hanya muncul untuk santri kelas Lambatan
--
-- 4. Berita B006 (target: kelas Cepatan)
-- → Hanya muncul untuk santri kelas Cepatan
--
-- 5. Berita B007 (target: kelas PB dan Lambatan)
-- → Muncul untuk santri kelas PB DAN Lambatan
--
-- 6. Berita B008-B010 (target: santri tertentu)
-- → Hanya muncul untuk santri yang ada di pivot table berita_santri
-- → Akan ada badge "BARU" sampai mereka buka beritanya

View File

@ -1,428 +0,0 @@
<?php
/**
* Scan Kelas Usage Script
*
* Script untuk scan semua penggunaan $santri->kelas di codebase
* dan generate laporan markdown untuk refactoring guidance
*
* Usage:
* php scan_kelas_usage.php
*
* Output:
* KELAS_USAGE_MAP.md
*/
// Configuration
$baseDir = __DIR__ . '/sim-pkpps';
$outputFile = __DIR__ . '/KELAS_USAGE_MAP.md';
// Check if base directory exists
if (!is_dir($baseDir)) {
echo "❌ Error: Base directory not found: {$baseDir}\n";
echo "Current directory: " . __DIR__ . "\n";
exit(1);
}
// Directories to scan
$scanDirs = [
'app/Http/Controllers',
'app/Models',
'resources/views',
'database/migrations',
'database/seeders',
'routes',
];
// Patterns to search (regex)
$patterns = [
'property_access' => '/\$santri\s*->\s*kelas(?!\w)/',
'array_access' => '/\$santri\[[\'"]kelas[\'"]\]/',
'blade_kelas' => '/\{\{\s*\$santri\s*->\s*kelas\s*\}\}/',
'where_kelas' => '/->where\([\'"]kelas[\'"]\s*,/',
'wherein_kelas' => '/->whereIn\([\'"]kelas[\'"]\s*,/',
'select_kelas' => '/SELECT.*santris\.kelas/i',
'enum_values' => '/(\'PB\'|\'Lambatan\'|\'Cepatan\')\s*(,|\]|\))/i',
'kelas_column' => '/[\'"]kelas[\'"]\s*=>/i',
];
echo "╔══════════════════════════════════════════════════════╗\n";
echo "║ Scanning Santri.kelas Usage in Codebase ║\n";
echo "╚══════════════════════════════════════════════════════╝\n\n";
// Initialize results
$results = [];
$totalFiles = 0;
$totalMatches = 0;
// Scan each directory
foreach ($scanDirs as $dir) {
$fullPath = $baseDir . '/' . $dir;
if (!is_dir($fullPath)) {
echo "⚠️ Directory not found: {$dir}\n";
continue;
}
echo "📁 Scanning: {$dir}\n";
$files = scanDirectory($fullPath, $dir);
foreach ($files as $file) {
$matches = scanFile($file['full_path'], $patterns);
if (!empty($matches)) {
$totalFiles++;
$totalMatches += count($matches);
$results[$dir][] = [
'file' => $file['relative_path'],
'full_path' => $file['full_path'],
'matches' => $matches,
];
echo " ✓ Found " . count($matches) . " match(es) in: " . basename($file['relative_path']) . "\n";
}
}
}
echo "\n";
echo "Summary:\n";
echo " 📊 Files scanned: " . countAllFiles($scanDirs, $baseDir) . "\n";
echo " ✓ Files with matches: {$totalFiles}\n";
echo " 🔍 Total matches: {$totalMatches}\n";
echo "\n";
// Generate markdown report
echo "📝 Generating report: KELAS_USAGE_MAP.md\n";
generateMarkdownReport($results, $outputFile);
echo "✓ Report generated successfully!\n";
echo "\nNext steps:\n";
echo " 1. Review KELAS_USAGE_MAP.md\n";
echo " 2. Prioritize refactoring (HIGH -> MEDIUM -> LOW)\n";
echo " 3. Test each change thoroughly\n";
echo " 4. Use \$santri->kelas_name for backward compatibility\n\n";
// ============================================
// HELPER FUNCTIONS
// ============================================
/**
* Recursively scan directory for PHP and Blade files
*/
function scanDirectory($dir, $relativePath)
{
$files = [];
$items = scandir($dir);
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$fullPath = $dir . '/' . $item;
$relPath = $relativePath . '/' . $item;
if (is_dir($fullPath)) {
$files = array_merge($files, scanDirectory($fullPath, $relPath));
} elseif (preg_match('/\.(php|blade\.php)$/', $item)) {
$files[] = [
'full_path' => $fullPath,
'relative_path' => $relPath,
];
}
}
return $files;
}
/**
* Scan file for patterns
*/
function scanFile($filePath, $patterns)
{
$content = file_get_contents($filePath);
$lines = explode("\n", $content);
$matches = [];
foreach ($lines as $lineNum => $line) {
foreach ($patterns as $type => $pattern) {
if (preg_match($pattern, $line)) {
$matches[] = [
'line' => $lineNum + 1,
'type' => $type,
'content' => trim($line),
];
}
}
}
return $matches;
}
/**
* Count all files in directories
*/
function countAllFiles($dirs, $baseDir)
{
$count = 0;
foreach ($dirs as $dir) {
$fullPath = $baseDir . '/' . $dir;
if (is_dir($fullPath)) {
$count += count(scanDirectory($fullPath, $dir));
}
}
return $count;
}
/**
* Generate markdown report
*/
function generateMarkdownReport($results, $outputFile)
{
$md = "# Santri.kelas Usage Mapping\n\n";
$md .= "_Generated: " . date('Y-m-d H:i:s') . "_\n\n";
$md .= "This document maps all usage of `\$santri->kelas` and related patterns in the codebase ";
$md .= "to guide refactoring to the new kelas system.\n\n";
$md .= "---\n\n";
$md .= "## 📊 Summary\n\n";
$totalFiles = 0;
$totalMatches = 0;
foreach ($results as $dir => $files) {
$totalFiles += count($files);
foreach ($files as $file) {
$totalMatches += count($file['matches']);
}
}
$md .= "- **Total files with kelas usage:** {$totalFiles}\n";
$md .= "- **Total matches found:** {$totalMatches}\n\n";
$md .= "---\n\n";
// Priority mapping
$priorities = categorizePriority($results);
$md .= "## 🎯 Priority Levels\n\n";
$md .= "### 🔴 HIGH Priority (Break functionality)\n\n";
if (!empty($priorities['high'])) {
foreach ($priorities['high'] as $item) {
$md .= "- **{$item['file']}**\n";
$md .= " - Issue: {$item['reason']}\n";
$md .= " - Action Required: {$item['action']}\n\n";
}
} else {
$md .= "_No high priority items found_\n\n";
}
$md .= "### 🟡 MEDIUM Priority (UI/Display)\n\n";
if (!empty($priorities['medium'])) {
foreach ($priorities['medium'] as $item) {
$md .= "- **{$item['file']}**\n";
$md .= " - Issue: {$item['reason']}\n";
$md .= " - Action Required: {$item['action']}\n\n";
}
} else {
$md .= "_No medium priority items found_\n\n";
}
$md .= "### 🟢 LOW Priority (Backward compatible)\n\n";
if (!empty($priorities['low'])) {
foreach ($priorities['low'] as $item) {
$md .= "- **{$item['file']}**\n";
$md .= " - Note: {$item['reason']}\n\n";
}
} else {
$md .= "_No low priority items found_\n\n";
}
$md .= "---\n\n";
// Detailed listing by directory
$md .= "## 📂 Detailed Listing by Directory\n\n";
foreach ($results as $dir => $files) {
$md .= "### " . ucfirst(str_replace('/', ' / ', $dir)) . "\n\n";
foreach ($files as $file) {
$md .= "#### 📄 `{$file['file']}`\n\n";
// Group matches by type
$byType = [];
foreach ($file['matches'] as $match) {
$byType[$match['type']][] = $match;
}
foreach ($byType as $type => $matches) {
$md .= "**Pattern: `{$type}`**\n\n";
foreach ($matches as $match) {
$md .= "- **Line {$match['line']}:** `{$match['content']}`\n";
}
$md .= "\n";
}
// Suggested action
$action = getRefactoringAction($file['file'], $byType);
$md .= "**💡 Suggested Action:**\n";
$md .= $action . "\n\n";
$md .= "---\n\n";
}
}
// Migration guide
$md .= "## 📖 Refactoring Guide\n\n";
$md .= "### General Patterns\n\n";
$md .= "#### 1. Display in Views (Blade)\n";
$md .= "```php\n";
$md .= "// OLD:\n";
$md .= "{{ \$santri->kelas }}\n\n";
$md .= "// NEW (backward compatible):\n";
$md .= "{{ \$santri->kelas_name }}\n";
$md .= "```\n\n";
$md .= "#### 2. Filter in Controllers\n";
$md .= "```php\n";
$md .= "// OLD:\n";
$md .= "\$santris = Santri::where('kelas', 'PB')->get();\n\n";
$md .= "// NEW:\n";
$md .= "\$santris = Santri::whereHas('kelasSantri', function(\$q) {\n";
$md .= " \$q->where('id_kelas', 1); // PB = 1\n";
$md .= "})->get();\n";
$md .= "```\n\n";
$md .= "#### 3. Kegiatan-Kelas Relation\n";
$md .= "```php\n";
$md .= "// OLD: Filter santri by kelas for kegiatan\n";
$md .= "\$santris = Santri::whereIn('kelas', ['PB', 'Lambatan'])->get();\n\n";
$md .= "// NEW: Use kegiatan relation\n";
$md .= "\$santris = \$kegiatan->getEligibleSantris();\n";
$md .= "```\n\n";
$md .= "### Testing Checklist\n\n";
$md .= "- [ ] Santri detail page displays correct kelas\n";
$md .= "- [ ] Santri list filter by kelas works\n";
$md .= "- [ ] Dashboard statistics by kelas accurate\n";
$md .= "- [ ] Kegiatan filtering by kelas works\n";
$md .= "- [ ] Absensi shows correct santri per kegiatan\n";
$md .= "- [ ] Reports include correct kelas information\n";
$md .= "- [ ] Mobile API returns kelas data correctly\n\n";
// Write to file
file_put_contents($outputFile, $md);
}
/**
* Categorize by priority
*/
function categorizePriority($results)
{
$priorities = [
'high' => [],
'medium' => [],
'low' => [],
];
foreach ($results as $dir => $files) {
foreach ($files as $file) {
$fileName = basename($file['file']);
$priority = determinePriority($file['file'], $file['matches']);
$priorities[$priority['level']][] = [
'file' => $file['file'],
'reason' => $priority['reason'],
'action' => $priority['action'] ?? 'Review and update',
];
}
}
return $priorities;
}
/**
* Determine priority level
*/
function determinePriority($filePath, $matches)
{
$fileName = basename($filePath);
// HIGH: Controllers with where/whereIn
if (strpos($filePath, 'Controller') !== false) {
foreach ($matches as $match) {
if (in_array($match['type'], ['where_kelas', 'wherein_kelas'])) {
return [
'level' => 'high',
'reason' => 'Query filtering by kelas column',
'action' => 'Update to use kelasSantri relationship',
];
}
}
}
// HIGH: Migration files
if (strpos($filePath, 'migration') !== false) {
return [
'level' => 'high',
'reason' => 'Database schema definition',
'action' => 'Review but DO NOT modify old migrations',
];
}
// MEDIUM: Views
if (strpos($filePath, 'views') !== false || strpos($filePath, '.blade.php') !== false) {
return [
'level' => 'medium',
'reason' => 'Display kelas in UI',
'action' => 'Change to use $santri->kelas_name accessor',
];
}
// MEDIUM: Models
if (strpos($filePath, 'Models') !== false) {
return [
'level' => 'medium',
'reason' => 'Model attribute or accessor',
'action' => 'Review accessor implementation',
];
}
// LOW: Everything else
return [
'level' => 'low',
'reason' => 'Other usage',
'action' => 'Review as needed',
];
}
/**
* Get refactoring action suggestion
*/
function getRefactoringAction($filePath, $matchesByType)
{
$action = "";
if (strpos($filePath, 'Controller') !== false) {
if (isset($matchesByType['where_kelas']) || isset($matchesByType['wherein_kelas'])) {
$action .= "1. Replace `where('kelas')` with `whereHas('kelasSantri')`\n";
$action .= "2. Update query to use kelas ID instead of name\n";
$action .= "3. Test filter functionality thoroughly\n";
}
}
if (strpos($filePath, '.blade.php') !== false) {
if (isset($matchesByType['blade_kelas']) || isset($matchesByType['property_access'])) {
$action .= "1. Replace `{{ \$santri->kelas }}` with `{{ \$santri->kelas_name }}`\n";
$action .= "2. Test display in browser\n";
}
}
if (strpos($filePath, 'Model') !== false) {
$action .= "1. Review model methods and accessors\n";
$action .= "2. Ensure backward compatibility\n";
$action .= "3. Add tests for new relations\n";
}
if (empty($action)) {
$action = "Review usage and update as needed based on context.";
}
return $action;
}

View File

@ -1,119 +0,0 @@
# PANDUAN MIGRASI SISTEM KELAS BARU
## Ringkasan
Migrasi dari kolom `kelas` hardcoded (PB, Lambatan, Cepatan) di tabel `santris` ke sistem relasional baru menggunakan tabel `santri_kelas`, `kelas`, dan `kelompok_kelas`.
## Prasyarat
Pastikan tabel berikut sudah ada dan terisi data:
- `kelompok_kelas` — minimal 3 kelompok (PB, Lambatan, Cepatan)
- `kelas` — minimal 1 kelas aktif per kelompok
Cek via tinker:
```bash
cd sim-pkpps
php artisan tinker
>>> App\Models\KelompokKelas::active()->count() # harus >= 3
>>> App\Models\Kelas::active()->count() # harus >= 3
```
---
## Urutan Eksekusi (Step-by-Step)
### TAHAP 1: Migrasi Data (Kolom Lama → Tabel Baru)
```bash
cd sim-pkpps
# 1. Preview dulu (dry-run) — TIDAK mengubah database
php artisan migrate:santri-kelas-full --dry-run
# 2. Periksa output, pastikan mapping benar:
# PB → KLS00x (...)
# Lambatan → KLS00x (...)
# Cepatan → KLS00x (...)
# 3. Execute migrasi data (real)
php artisan migrate:santri-kelas-full
# 4. Validasi: Periksa tabel santri_kelas sudah terisi
php artisan tinker
>>> App\Models\SantriKelas::where('is_primary', true)->count()
```
### TAHAP 2: Test Aplikasi
Setelah TAHAP 1, kode sudah diupdate untuk pakai relasi baru.
Buka browser dan test:
- [ ] **Index**: Buka halaman Data Santri → Filter kelompok kelas berfungsi
- [ ] **Create**: Tambah santri baru → Pilih kelompok → Pilih kelas → Simpan
- [ ] **Edit**: Edit santri existing → Kelas otomatis terseleksi → Update
- [ ] **Show**: Detail santri → Kelompok & Kelas tampil benar
- [ ] **Delete**: Hapus santri → Tidak error
- [ ] **Foto**: Upload foto masih berfungsi normal
### TAHAP 3: Drop Kolom Lama
**SETELAH semua test di TAHAP 2 pass:**
```bash
# Backup database dulu!
mysqldump -u root sim_pkpps > backup_before_drop_kelas.sql
# Jalankan migration drop kolom
php artisan migrate
# Test lagi semua fitur
```
Jika perlu rollback:
```bash
php artisan migrate:rollback --step=1
```
---
## File yang Diubah
| File | Perubahan |
|------|-----------|
| `app/Console/Commands/MigrateSantriToNewKelas.php` | **BARU** — Command migrasi data |
| `app/Models/Santri.php` | Hapus `kelas` dari fillable, simplify accessor, tambah scope |
| `app/Http/Controllers/Admin/SantriController.php` | Semua method: pakai relasi baru + eager loading |
| `resources/views/admin/santri/form.blade.php` | Dropdown bertingkat Kelompok → Kelas (vanilla JS) |
| `resources/views/admin/santri/index.blade.php` | Filter kelompok + kelas dari relasi di tabel |
| `resources/views/admin/santri/show.blade.php` | Tampil kelompok + kelas dari relasi |
| `database/migrations/2026_02_14_..._drop_kelas.php` | **BARU** — Drop kolom `kelas` dari `santris` |
---
## Troubleshooting
### Error: "Kelas wajib dipilih" saat create/edit
- Pastikan tabel `kelompok_kelas` dan `kelas` sudah ada data
- Pastikan `is_active = true` pada kelompok & kelas
### Dropdown kelas tidak muncul saat edit
- Pastikan relasi `kelasPrimary` sudah ter-load: controller harus `$santri->load('kelasPrimary.kelas.kelompok')`
### Kolom kelas masih ada di database
- Jalankan `php artisan migrate` untuk menjalankan migration drop kolom
- Atau jalankan manual: `ALTER TABLE santris DROP COLUMN kelas;`
### Rollback penuh
```bash
# 1. Rollback drop kolom
php artisan migrate:rollback --step=1
# 2. Restore kode lama dari git
git checkout -- app/Models/Santri.php
git checkout -- app/Http/Controllers/Admin/SantriController.php
git checkout -- resources/views/admin/santri/
# 3. Bersihkan santri_kelas jika perlu
php artisan tinker
>>> App\Models\SantriKelas::truncate()
```

View File

@ -33,34 +33,38 @@ public function inputAbsensi($kegiatan_id)
$tanggal = request('tanggal', now()->format('Y-m-d'));
// Build santri grouped by kegiatan kelas
// Build santri grouped by kelas
$santriGrouped = collect();
if ($kegiatan->isForAllClasses()) {
// Kegiatan umum: ambil SEMUA santri aktif, group by primary kelas
$allSantris = Santri::where('status', 'Aktif')
->with(['kelasSantri.kelas', 'kelasPrimary.kelas'])
->orderBy('nama_lengkap')
->get();
if ($kegiatan->isForAllClasses()) {
// Kegiatan umum: group by primary kelas
$santriGrouped = $allSantris->groupBy(function($s) {
$primary = $s->kelasPrimary;
return $primary && $primary->kelas ? $primary->kelas->nama_kelas : 'Tanpa Kelas';
})->sortKeys();
} else {
// Kegiatan khusus: group by kegiatan kelas
// Kegiatan khusus: group by kelas yang di-assign ke kegiatan
$placedIds = [];
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', 'kelasPrimary.kelas'])
->orderBy('nama_lengkap')
->get();
if ($santriInKelas->count() > 0) {
$santriGrouped[$kelas->nama_kelas] = $santriInKelas;
$santriForKelas = $allSantris->filter(function($s) use ($kelas, &$placedIds) {
if (in_array($s->id_santri, $placedIds)) return false;
return $s->kelasSantri->contains('id_kelas', $kelas->id);
});
foreach ($santriForKelas as $s) {
$placedIds[] = $s->id_santri;
}
if ($santriForKelas->count() > 0) {
$santriGrouped[$kelas->nama_kelas] = $santriForKelas;
}
}
// Santri yang tidak termasuk kelas kegiatan manapun
$santriLainnya = $allSantris->whereNotIn('id_santri', $placedIds);
if ($santriLainnya->count() > 0) {
$santriGrouped['Kelas Lain'] = $santriLainnya;
}
}
@ -222,29 +226,13 @@ public function rekapAbsensi(Request $request, $kegiatan_id)
->orderBy('waktu_absen', 'desc')
->get();
// Build kelas list for filter dropdown
if ($kegiatan->isForAllClasses()) {
// Build kelas list for filter dropdown — selalu tampilkan semua kelas aktif
$kelasFilterList = Kelas::active()->ordered()->get();
} else {
$kelasFilterList = $kegiatan->kelasKegiatan;
}
// Grup per kelas berdasarkan kegiatan kelas
if ($kegiatan->isForAllClasses()) {
// Grup per kelas — selalu group by kelas_name santri
$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
$statsQuery = AbsensiKegiatan::where('kegiatan_id', $kegiatan_id);
@ -265,7 +253,73 @@ public function rekapAbsensi(Request $request, $kegiatan_id)
->pluck('total', 'status')
->toArray();
return view('admin.kegiatan.absensi.rekap', compact('kegiatan', 'absensis', 'absensiPerKelas', 'stats', 'kelasFilterList'));
// ── Hitung total SEMUA santri aktif ──
$allSantriQuery = Santri::where('status', 'Aktif');
if ($request->filled('kelas_id')) {
$allSantriQuery->whereHas('kelasSantri', function($q) use ($request) {
$q->where('id_kelas', $request->kelas_id);
});
}
$totalSantriEligible = $allSantriQuery->count();
// Hitung santri unik yang sudah tercatat absensi (sesuai filter)
$recordedQuery = AbsensiKegiatan::where('kegiatan_id', $kegiatan_id);
if ($request->filled('tanggal')) {
$recordedQuery->whereDate('tanggal', $request->tanggal);
}
if ($request->filled('bulan')) {
$recordedQuery->whereMonth('tanggal', date('m', strtotime($request->bulan)))
->whereYear('tanggal', date('Y', strtotime($request->bulan)));
}
if ($request->filled('kelas_id')) {
$recordedQuery->whereHas('santri.kelasSantri', function($q) use ($request) {
$q->where('id_kelas', $request->kelas_id);
});
}
$santriSudahAbsen = $recordedQuery->distinct('id_santri')->count('id_santri');
$belumAbsen = max(0, $totalSantriEligible - $santriSudahAbsen);
// Persentase kehadiran berdasarkan total semua santri aktif
$totalRecorded = array_sum($stats);
$hadirCount = ($stats['Hadir'] ?? 0) + ($stats['Terlambat'] ?? 0);
$persenHadir = $totalSantriEligible > 0 ? round($hadirCount / $totalSantriEligible * 100, 1) : 0;
// Daftar santri yang belum absen (selalu ditampilkan)
$santriBelumAbsen = collect();
// Bangun query ID santri yang sudah absen (sesuai filter aktif)
$sudahAbsenQuery = AbsensiKegiatan::where('kegiatan_id', $kegiatan_id);
if ($request->filled('tanggal')) {
$sudahAbsenQuery->whereDate('tanggal', $request->tanggal);
}
if ($request->filled('bulan')) {
$sudahAbsenQuery->whereMonth('tanggal', date('m', strtotime($request->bulan)))
->whereYear('tanggal', date('Y', strtotime($request->bulan)));
}
if ($request->filled('kelas_id')) {
$sudahAbsenQuery->whereHas('santri.kelasSantri', function($q) use ($request) {
$q->where('id_kelas', $request->kelas_id);
});
}
$idSantriSudahAbsen = $sudahAbsenQuery->pluck('id_santri')->unique()->toArray();
$belumQuery = Santri::where('status', 'Aktif');
if ($request->filled('kelas_id')) {
$belumQuery->whereHas('kelasSantri', function($q) use ($request) {
$q->where('id_kelas', $request->kelas_id);
});
}
$santriBelumAbsen = $belumQuery
->whereNotIn('id_santri', $idSantriSudahAbsen)
->with(['kelasPrimary.kelas'])
->orderBy('nama_lengkap')
->get();
return view('admin.kegiatan.absensi.rekap', compact(
'kegiatan', 'absensis', 'absensiPerKelas', 'stats', 'kelasFilterList',
'totalSantriEligible', 'santriSudahAbsen', 'belumAbsen', 'persenHadir',
'totalRecorded', 'hadirCount', 'santriBelumAbsen'
));
}
/**

View File

@ -0,0 +1,446 @@
<?php
// app/Http/Controllers/Admin/ImportMesinController.php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\AbsensiKegiatan;
use App\Models\ImportMesinLog;
use App\Models\Kegiatan;
use App\Models\Kepulangan;
use App\Models\MesinSantriMapping;
use App\Services\EpposGLogParser;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ImportMesinController extends Controller
{
public function __construct(private EpposGLogParser $parser) {}
// ──────────────────────────────────────────────────────────
// INDEX
// ──────────────────────────────────────────────────────────
public function index()
{
// Hitung yang benar-benar belum punya santri (id_santri null atau kosong)
$belumMapping = MesinSantriMapping::where('is_active', true)
->where(function ($q) {
$q->whereNull('id_santri')->orWhere('id_santri', '');
})->count();
$riwayat = ImportMesinLog::with('user')->latest()->take(10)->get();
return view('admin.mesin.import.index', compact('belumMapping', 'riwayat'));
}
// ──────────────────────────────────────────────────────────
// PREVIEW — hanya POST
// Setelah proses selesai, redirect ke showPreview (GET)
// Ini mencegah error "MethodNotAllowed" saat user refresh halaman preview
// ──────────────────────────────────────────────────────────
public function preview(Request $request)
{
$request->validate([
'file_glog' => 'required|file|max:20480',
'tol_sebelum' => 'nullable|integer|min:0|max:60',
'tol_sesudah' => 'nullable|integer|min:0|max:60',
'isi_alpa' => 'nullable',
'conflict_strategy' => 'nullable|in:mesin,exist,manual',
]);
$tolSebelum = (int)($request->tol_sebelum ?? 15);
$tolSesudah = (int)($request->tol_sesudah ?? 10);
$isiAlpa = $request->has('isi_alpa');
$conflictStrategy = $request->input('conflict_strategy', 'mesin');
// ── Parse GLog ────────────────────────────────────────
try {
$glogRecords = $this->parser->parseGLog(
$request->file('file_glog')->getPathname()
);
} catch (\Throwable $e) {
return back()->with('error', 'Gagal membaca file GLog: ' . $e->getMessage());
}
if (empty($glogRecords)) {
return back()->with('error',
'File GLog tidak mengandung data scan yang valid. ' .
'Pastikan file yang diupload benar (format GLog dari Eppos).'
);
}
// ── Ambil infoData dari mapping yang sudah ada ────────
// Kita bangun infoData dari tabel mesin_santri_mappings
// sehingga tidak perlu upload INFO.XLS lagi
$mappingAll = MesinSantriMapping::where('is_active', true)->get();
// Bangun struktur infoData['jadwal'] dari mapping yang ada
// shifts dikosongkan karena matching pakai jam langsung
$infoData = [
'shifts' => [],
'jadwal' => [],
];
foreach ($mappingAll as $m) {
$infoData['jadwal'][$m->id_mesin] = [
'nama' => $m->nama_mesin ?? '',
'dept' => $m->dept_mesin ?? '',
'shift' => 1, // default, tidak dipakai untuk matching jam
];
}
// ── Kegiatan dari DB ──────────────────────────────────
// Ambil semua kegiatan — waktu_selesai boleh null, pakai waktu_mulai sebagai fallback
// getRawOriginal() bypass Eloquent cast (datetime:H:i → Carbon)
// sehingga kita dapat string murni "04:00:00" dari DB, lalu substr → "04:00"
$kegiatans = Kegiatan::orderBy('hari')->orderBy('waktu_mulai')
->get()
->map(function ($k) {
$rawMulai = $k->getRawOriginal('waktu_mulai');
$rawSelesai = $k->getRawOriginal('waktu_selesai');
$mulai = $rawMulai ? substr($rawMulai, 0, 5) : '00:00';
$selesai = $rawSelesai ? substr($rawSelesai, 0, 5) : $mulai;
return [
'kegiatan_id' => $k->kegiatan_id,
'nama' => $k->nama_kegiatan,
'hari' => $k->hari,
'waktu_mulai' => $mulai,
'waktu_selesai' => $selesai,
];
})->toArray();
if (empty($kegiatans)) {
return back()->with('error',
'Tidak ada kegiatan tersimpan di database. ' .
'Tambahkan kegiatan terlebih dahulu di menu Kegiatan.'
);
}
// ── Match ─────────────────────────────────────────────
$glogGrouped = $this->parser->groupGLogByDay($glogRecords);
$rawHasil = $this->parser->matchToKegiatan(
$glogGrouped, $infoData, $kegiatans, $tolSebelum, $tolSesudah
);
// ── Enrich (santri web + kepulangan + konflik) ────────
$kepulanganCache = [];
$hasilEnriched = [];
foreach ($rawHasil as $dayData) {
$tanggal = $dayData['tanggal'];
$idMesin = $dayData['id_mesin'];
$mapping = MesinSantriMapping::where('id_mesin', $idMesin)
->where('is_active', true)
->with('santri')
->first();
$idSantri = $mapping?->santri?->id_santri;
$namaWeb = $mapping?->santri?->nama_lengkap;
$kelas = $mapping?->santri?->kelasPrimary?->kelas?->nama_kelas ?? '-';
// Cache kepulangan per tanggal agar tidak query berulang
if (!isset($kepulanganCache[$tanggal])) {
$kepulanganCache[$tanggal] = Kepulangan::where('status', 'Disetujui')
->where('tanggal_pulang', '<=', $tanggal)
->where('tanggal_kembali', '>=', $tanggal)
->pluck('id_santri')->toArray();
}
$isPulang = $idSantri && in_array($idSantri, $kepulanganCache[$tanggal]);
$rows = array_map(
function ($row) use ($idSantri, $tanggal, $isPulang, $isiAlpa) {
// Override jika santri sedang kepulangan
$statusFinal = $isPulang ? 'Pulang' : $row['status'];
// Jangan isi Alpa jika opsi tidak aktif
if (!$isiAlpa && $statusFinal === 'Alpa' && !$row['matched']) {
$statusFinal = null;
}
$existing = null;
$isConflict = false;
if ($idSantri) {
$rec = AbsensiKegiatan::where('kegiatan_id', $row['kegiatan_id'])
->where('id_santri', $idSantri)
->whereDate('tanggal', $tanggal)
->first();
if ($rec) {
// getRawOriginal bypass Eloquent datetime cast
$rawWaktu = $rec->getRawOriginal('waktu_absen');
$existing = [
'status' => $rec->status,
'waktu' => $rawWaktu
? substr($rawWaktu, 0, 5) : null,
'metode' => $rec->metode_absen ?? 'Manual',
];
// PENTING: Jika mesin TIDAK punya scan untuk kegiatan
// ini (matched=false, status=Alpa), jangan override
// data manual yang sudah ada. "Tidak ada scan" ≠ "Alpa".
// Pertahankan data lama secara otomatis.
if (!$row['matched'] && $statusFinal === 'Alpa') {
// Tidak override — pakai data existing
$statusFinal = $rec->status;
$isConflict = false;
} else {
// Konflik hanya jika mesin MEMANG punya scan
// (matched=true) tapi statusnya beda dari manual
$isConflict = ($rec->metode_absen !== 'Import_Mesin')
&& ($rec->status !== $statusFinal)
&& $statusFinal !== null;
}
}
}
return array_merge($row, [
'status_final' => $statusFinal,
'existing' => $existing,
'is_conflict' => $isConflict,
]);
},
$dayData['rows']
);
$hasilEnriched[] = array_merge($dayData, [
'id_santri' => $idSantri,
'nama_web' => $namaWeb,
'kelas' => $kelas,
'match_status' => $mapping
? ($idSantri ? 'OK' : 'NO_SANTRI')
: 'NOT_MAPPED',
'is_pulang' => $isPulang,
'rows' => $rows,
]);
}
// Urutkan: tanggal → nama
usort($hasilEnriched, fn($a, $b) =>
[$a['tanggal'], $a['nama_web'] ?? $a['nama_mesin']]
<=> [$b['tanggal'], $b['nama_web'] ?? $b['nama_mesin']]
);
// ── Simpan ke session lalu REDIRECT ke showPreview ────
// Ini adalah PRG Pattern (Post-Redirect-Get):
// POST /import/preview → proses → session → redirect
// GET /import/preview → ambil dari session → tampilkan view
// Sehingga refresh halaman tidak error MethodNotAllowed
session([
'eppos_hasil' => $hasilEnriched,
'tol_sebelum' => $tolSebelum,
'tol_sesudah' => $tolSesudah,
'isi_alpa' => $isiAlpa,
'conflict_strategy' => $conflictStrategy,
]);
return redirect()->route('admin.mesin.import.show-preview');
}
// ──────────────────────────────────────────────────────────
// SHOW PREVIEW — GET (aman di-refresh)
// ──────────────────────────────────────────────────────────
public function showPreview()
{
$hasilEnriched = session('eppos_hasil');
// Jika session kosong (user buka langsung tanpa upload)
if (empty($hasilEnriched)) {
return redirect()->route('admin.mesin.import.index')
->with('error', 'Tidak ada data preview. Silakan upload file GLog terlebih dahulu.');
}
$tolSebelum = session('tol_sebelum', 15);
$tolSesudah = session('tol_sesudah', 10);
$isiAlpa = session('isi_alpa', true);
$conflictStrategy = session('conflict_strategy', 'mesin');
$tanggalList = array_unique(array_column($hasilEnriched, 'tanggal'));
sort($tanggalList);
// Debug: kumpulkan info scan yang tidak cocok untuk ditampilkan
$debugScans = [];
foreach ($hasilEnriched as $h) {
if (!empty($h['unmatched_scans'])) {
$debugScans[] = [
'nama' => $h['nama_web'] ?? $h['nama_mesin'],
'tanggal' => $h['tanggal'],
'id_mesin' => $h['id_mesin'],
'scans' => $h['all_scans'],
'unmatched'=> $h['unmatched_scans'],
];
}
}
$allRows = collect($hasilEnriched)->flatMap(fn($h) => $h['rows']);
$stats = [
'total_santri' => count($hasilEnriched),
'ok' => collect($hasilEnriched)->where('match_status', 'OK')->count(),
'not_mapped' => collect($hasilEnriched)->where('match_status', 'NOT_MAPPED')->count(),
'hadir' => $allRows->where('status_final', 'Hadir')->count(),
'terlambat' => $allRows->where('status_final', 'Terlambat')->count(),
'alpa' => $allRows->where('status_final', 'Alpa')->count(),
'konflik' => $allRows->where('is_conflict', true)->count(),
];
return view('admin.mesin.import.preview', compact(
'hasilEnriched', 'tanggalList', 'stats',
'tolSebelum', 'tolSesudah', 'isiAlpa',
'debugScans', 'conflictStrategy'
));
}
// ──────────────────────────────────────────────────────────
// STORE — simpan ke database
// ──────────────────────────────────────────────────────────
public function store(Request $request)
{
$hasilEnriched = session('eppos_hasil', []);
if (empty($hasilEnriched)) {
return redirect()->route('admin.mesin.import.index')
->with('error', 'Sesi expired. Silakan upload ulang file GLog.');
}
$bulkStrategy = $request->input('conflict_strategy', 'manual');
$choices = $request->input('conflict_choices', []);
$counters = [
'created' => 0,
'updated' => 0,
'kept' => 0,
'skipped' => 0,
'no_santri' => 0,
'null_skip' => 0,
];
DB::beginTransaction();
try {
foreach ($hasilEnriched as $dayData) {
if (!$dayData['id_santri']) {
$counters['no_santri']++;
continue;
}
foreach ($dayData['rows'] as $row) {
// Status null = tidak perlu disimpan
if ($row['status_final'] === null) {
$counters['null_skip']++;
continue;
}
// Alpa tanpa scan (matched=false) + sudah ada data existing
// → pertahankan data lama, jangan simpan Alpa
if (!$row['matched'] && $row['status_final'] === 'Alpa' && !empty($row['existing'])) {
$counters['skipped']++;
continue;
}
// Jika mesin tidak punya scan dan statusFinal = status existing
// (artinya sudah diset ke status existing di preview), skip
if (!$row['matched'] && !empty($row['existing'])
&& $row['status_final'] === $row['existing']['status']) {
$counters['skipped']++;
continue;
}
$key = "{$row['kegiatan_id']}_{$dayData['id_santri']}_{$dayData['tanggal']}";
$hasExisting = !empty($row['existing']);
$isConflict = $row['is_conflict'] ?? false;
if (!$hasExisting) {
// Belum ada data → langsung buat
AbsensiKegiatan::create([
'kegiatan_id' => $row['kegiatan_id'],
'id_santri' => $dayData['id_santri'],
'tanggal' => $dayData['tanggal'],
'status' => $row['status_final'],
'metode_absen' => 'Import_Mesin',
'waktu_absen' => $row['jam_scan']
? Carbon::parse(
$dayData['tanggal'] . ' ' . $row['jam_scan']
)->format('H:i:s')
: Carbon::parse($dayData['tanggal'])->format('H:i:s'),
]);
$counters['created']++;
continue;
}
// Ada data existing tapi tidak konflik (status sama)
// → skip, tidak perlu diubah
if (!$isConflict) {
$counters['skipped']++;
continue;
}
// Ada konflik → lihat strategi bulk dulu, baru per-cell
$choice = ($bulkStrategy !== 'manual')
? $bulkStrategy
: ($choices[$key] ?? null);
if ($choice === 'mesin') {
// Admin pilih: pakai data mesin
AbsensiKegiatan::where('kegiatan_id', $row['kegiatan_id'])
->where('id_santri', $dayData['id_santri'])
->whereDate('tanggal', $dayData['tanggal'])
->update([
'status' => $row['status_final'],
'metode_absen' => 'Import_Mesin',
'waktu_absen' => $row['jam_scan']
? Carbon::parse(
$dayData['tanggal'] . ' ' . $row['jam_scan']
)->format('H:i:s')
: null,
'konflik_catatan' => 'Ditimpa import mesin '
. now()->format('d/m/Y H:i')
. ' (sebelumnya: '
. $row['existing']['status']
. ' via '
. ($row['existing']['metode'] ?? 'Manual')
. ')',
]);
$counters['updated']++;
} else {
// Admin pilih: pertahankan data lama
// Tidak melakukan apa-apa
$counters['kept']++;
}
}
}
// Catat ke log
ImportMesinLog::create([
'user_id' => auth()->id(),
'jumlah_scan' => collect($hasilEnriched)
->flatMap(fn($h) => $h['all_scans'])->count(),
'berhasil' => $counters['created'],
'konflik_selesai' => $counters['updated'],
'dilewati' => $counters['skipped'],
'no_santri' => $counters['no_santri'],
]);
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
return back()->with('error', 'Import gagal: ' . $e->getMessage());
}
// Hapus session setelah berhasil
session()->forget(['eppos_hasil', 'tol_sebelum', 'tol_sesudah', 'isi_alpa', 'conflict_strategy']);
$msg = "Import selesai! "
. "{$counters['created']} data baru tersimpan, "
. "{$counters['updated']} konflik (pilih mesin), "
. "{$counters['kept']} konflik (pertahankan data lama), "
. "{$counters['skipped']} duplikat dilewati.";
if ($counters['no_santri'] > 0) {
$msg .= " | {$counters['no_santri']} santri belum ada mapping (tidak tersimpan).";
}
return redirect()->route('admin.riwayat-kegiatan.index')
->with('success', $msg);
}
}

View File

@ -79,6 +79,10 @@ public function cetakKartu($id_santri)
// ── Siapkan data untuk view ──────────────────────────────────────
$namaSantri = strtoupper($santri->nama_lengkap ?? 'NAMA SANTRI');
// Potong nama max 28 karakter agar muat di kartu
if (mb_strlen($namaSantri) > 28) {
$namaSantri = mb_substr($namaSantri, 0, 27) . '…';
}
$initial = strtoupper(substr($santri->nama_lengkap ?? 'S', 0, 1));
$nis = !empty($santri->nis) ? $santri->nis : '-';
$uid = !empty($santri->rfid_uid) ? $santri->rfid_uid : '-';
@ -108,7 +112,7 @@ public function cetakKartu($id_santri)
}
}
// Foto santri — embed base64 (tidak butuh GD)
// Foto santri — resize ke ukuran kartu lalu embed base64
$fotoBase64 = '';
$fotoMime = 'image/jpeg';
if (!empty($santri->foto)) {
@ -120,7 +124,38 @@ public function cetakKartu($id_santri)
if (file_exists($fp)) {
$ext = strtolower(pathinfo($fp, PATHINFO_EXTENSION));
$fotoMime = in_array($ext, ['png', 'gif', 'webp']) ? 'image/' . $ext : 'image/jpeg';
// Resize agar base64 tidak terlalu besar (max 400×400)
if (extension_loaded('gd')) {
$imgData = file_get_contents($fp);
$src = @imagecreatefromstring($imgData);
if ($src) {
$origW = imagesx($src);
$origH = imagesy($src);
$max = 400;
if ($origW > $max || $origH > $max) {
$ratio = min($max / $origW, $max / $origH);
$newW = (int) round($origW * $ratio);
$newH = (int) round($origH * $ratio);
$dst = imagecreatetruecolor($newW, $newH);
imagecopyresampled($dst, $src, 0, 0, 0, 0, $newW, $newH, $origW, $origH);
imagedestroy($src);
ob_start();
imagejpeg($dst, null, 80);
$resized = ob_get_clean();
imagedestroy($dst);
$fotoBase64 = base64_encode($resized);
$fotoMime = 'image/jpeg';
} else {
imagedestroy($src);
$fotoBase64 = base64_encode($imgData);
}
} else {
$fotoBase64 = base64_encode($imgData);
}
} else {
$fotoBase64 = base64_encode(file_get_contents($fp));
}
break;
}
}
@ -154,11 +189,18 @@ public function cetakKartu($id_santri)
'enableImports' => true,
]);
// Naikkan limit regex agar mPDF tidak error pada HTML besar
$prevLimit = ini_get('pcre.backtrack_limit');
ini_set('pcre.backtrack_limit', '5000000');
// Matikan page break otomatis
$mpdf->SetAutoPageBreak(false);
$mpdf->SetDisplayMode('fullpage');
$mpdf->WriteHTML($html);
// Kembalikan limit semula
ini_set('pcre.backtrack_limit', $prevLimit);
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

@ -237,31 +237,67 @@ public function getDetailModal($kegiatan_id, Request $request)
->whereDate('tanggal', $tanggal)
->orderBy('waktu_absen', 'desc')->get();
if ($kegiatan->isForAllClasses()) {
$isUmum = $kegiatan->isForAllClasses();
// Grup absensi per kelas kegiatan (khusus) atau kelas_name (umum)
if ($isUmum) {
$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;
$filtered = $absensis->filter(fn($item) => $item->santri->kelasSantri->contains('id_kelas', $kelas->id));
if ($filtered->count() > 0) $absensiPerKelas[$kelas->nama_kelas] = $filtered;
}
// Sisanya yang tidak cocok kelas manapun
$placedIds = $absensiPerKelas->flatten()->pluck('id')->toArray();
$lainnya = $absensis->filter(fn($item) => !$absensiPerKelas->flatten()->contains('id', $item->id));
if ($lainnya->count() > 0) $absensiPerKelas['Kelas Lain'] = $lainnya;
}
$stats = [
'hadir' => $absensis->where('status', 'Hadir')->count(),
'terlambat' => $absensis->where('status', 'Terlambat')->count(),
'izin' => $absensis->where('status', 'Izin')->count(),
'sakit' => $absensis->where('status', 'Sakit')->count(),
'alpa' => $absensis->where('status', 'Alpa')->count(),
];
$totalSantri = $kegiatan->isForAllClasses()
? Santri::where('status', 'Aktif')->count()
: $kegiatan->getEligibleSantris()->count();
$totalSantri = Santri::where('status', 'Aktif')->count();
$stats['belum_absen'] = $totalSantri - $absensis->count();
$stats['belum_absen'] = max(0, $totalSantri - $absensis->count());
$stats['sudah_absen'] = $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', 'absensiPerKelas', 'stats', 'tanggal'));
// Daftar santri belum absen, di-group per kelas kegiatan (khusus) atau kelasPrimary (umum)
$idSantriSudahAbsen = $absensis->pluck('id_santri')->toArray();
$allBelumAbsen = Santri::where('status', 'Aktif')
->whereNotIn('id_santri', $idSantriSudahAbsen)
->with(['kelasSantri.kelas', 'kelasPrimary.kelas'])
->orderBy('nama_lengkap')
->get();
if ($isUmum) {
$santriBelumAbsenPerKelas = $allBelumAbsen->groupBy(function($s) {
return optional(optional($s->kelasPrimary)->kelas)->nama_kelas ?? 'Tanpa Kelas';
})->sortKeys();
} else {
$santriBelumAbsenPerKelas = collect();
$placedBelumIds = [];
foreach ($kegiatan->kelasKegiatan as $kelas) {
$inKelas = $allBelumAbsen->filter(function($s) use ($kelas, &$placedBelumIds) {
if (in_array($s->id_santri, $placedBelumIds)) return false;
return $s->kelasSantri->contains('id_kelas', $kelas->id);
});
foreach ($inKelas as $s) $placedBelumIds[] = $s->id_santri;
if ($inKelas->count() > 0) $santriBelumAbsenPerKelas[$kelas->nama_kelas] = $inKelas;
}
$lainnyaBelum = $allBelumAbsen->whereNotIn('id_santri', $placedBelumIds);
if ($lainnyaBelum->count() > 0) $santriBelumAbsenPerKelas['Kelas Lain'] = $lainnyaBelum;
}
$santriBelumAbsen = $allBelumAbsen; // kept for count reference
return view('admin.kegiatan.data.partials.detail-modal', compact('kegiatan', 'absensis', 'absensiPerKelas', 'stats', 'tanggal', 'santriBelumAbsen', 'santriBelumAbsenPerKelas'));
}
/**

View File

@ -0,0 +1,226 @@
<?php
// app/Http/Controllers/Admin/MesinMappingController.php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\MesinSantriMapping;
use App\Models\Santri;
use App\Services\EpposGLogParser;
use Illuminate\Http\Request;
class MesinMappingController extends Controller
{
// ──────────────────────────────────────────────────────────
// INDEX
// ──────────────────────────────────────────────────────────
public function index()
{
$mappings = MesinSantriMapping::with('santri')
->orderByRaw('CAST(id_mesin AS UNSIGNED)')
->get();
$santris = Santri::where('status', 'Aktif')
->orderBy('nama_lengkap')
->get(['id_santri', 'nama_lengkap']);
return view('admin.mesin.mapping-santri.index', compact('mappings', 'santris'));
}
// ──────────────────────────────────────────────────────────
// STORE (tambah manual)
// ──────────────────────────────────────────────────────────
public function store(Request $request)
{
$request->validate([
'id_mesin' => 'required|string|unique:mesin_santri_mappings,id_mesin',
'id_santri' => 'nullable|exists:santris,id_santri',
'nama_mesin' => 'nullable|string|max:100',
'catatan' => 'nullable|string|max:255',
]);
MesinSantriMapping::create($request->only(
'id_mesin', 'id_santri', 'nama_mesin', 'catatan'
));
return back()->with('success', "Mapping ID Mesin {$request->id_mesin} berhasil ditambahkan.");
}
// ──────────────────────────────────────────────────────────
// UPDATE (ganti santri lewat dropdown)
// ──────────────────────────────────────────────────────────
public function update(Request $request, $id)
{
$mapping = MesinSantriMapping::findOrFail($id);
$request->validate([
'id_santri' => 'nullable|exists:santris,id_santri',
]);
$mapping->update(['id_santri' => $request->id_santri ?: null]);
return back()->with('success', 'Mapping berhasil diperbarui.');
}
// ──────────────────────────────────────────────────────────
// DESTROY
// ──────────────────────────────────────────────────────────
public function destroy($id)
{
$mapping = MesinSantriMapping::findOrFail($id);
$idMesin = $mapping->id_mesin;
$mapping->delete();
return back()->with('success', "Mapping ID Mesin {$idMesin} berhasil dihapus.");
}
// ──────────────────────────────────────────────────────────
// IMPORT FROM INFO.XLS
// ──────────────────────────────────────────────────────────
public function importFromInfo(Request $request)
{
$request->validate([
'file_info' => 'required|file|mimes:xls,xlsx|max:10240',
]);
$parser = app(EpposGLogParser::class);
$infoData = $parser->parseInfoFile($request->file('file_info')->getPathname());
$jadwal = $infoData['jadwal'];
$added = 0;
$skipped = 0;
$matched = 0;
// Ambil semua santri aktif sekali saja (efisien, tidak query per-santri)
$semuaSantri = Santri::where('status', 'Aktif')
->get(['id_santri', 'nama_lengkap']);
foreach ($jadwal as $idMesin => $data) {
// Skip jika mapping sudah ada
if (MesinSantriMapping::where('id_mesin', $idMesin)->exists()) {
$skipped++;
continue;
}
// Coba cocokkan nama dengan berbagai strategi
$santri = $this->cariSantriByNama($data['nama'], $semuaSantri);
MesinSantriMapping::create([
'id_mesin' => $idMesin,
'id_santri' => $santri?->id_santri,
'nama_mesin' => $data['nama'],
'dept_mesin' => $data['dept'] ?? null,
]);
if ($santri) $matched++;
$added++;
}
$msg = "{$added} mapping ditambahkan ({$matched} otomatis cocok nama), {$skipped} sudah ada.";
if ($added > $matched) {
$belum = $added - $matched;
$msg .= " {$belum} perlu dipetakan manual (nama tidak cocok).";
}
return back()->with('success', $msg);
}
// ──────────────────────────────────────────────────────────
// HELPER: Cari Santri Berdasarkan Nama (Fuzzy Matching)
//
// Strategi (urutan prioritas):
// 1. Exact match (nama lengkap sama persis, case-insensitive)
// 2. Nama mesin ada di dalam nama lengkap santri
// → "helga faisa" ditemukan di "helga faisa fahar"
// 3. Nama lengkap santri ada di dalam nama mesin
// → "helga" ditemukan di "helga faisa fahar"
// 4. Semua kata dari nama mesin ada di nama santri
// → nama mesin "helga faisa" → cari santri yang punya "helga" DAN "faisa"
// 5. Minimal 1 kata dari nama mesin cocok, pilih santri
// dengan skor kata paling banyak cocok
// ──────────────────────────────────────────────────────────
private function cariSantriByNama(string $namaMesin, $semuaSantri): ?Santri
{
$namaMesinBersih = strtolower(trim($namaMesin));
if (empty($namaMesinBersih)) return null;
// ── Strategi 1: Exact match ───────────────────────────
foreach ($semuaSantri as $santri) {
$namaDb = strtolower(trim($santri->nama_lengkap));
if ($namaDb === $namaMesinBersih) {
return $santri;
}
}
// ── Strategi 2: nama mesin ada di nama santri ─────────
// Contoh: nama mesin "helga faisa" → santri "helga faisa fahar" ✓
foreach ($semuaSantri as $santri) {
$namaDb = strtolower(trim($santri->nama_lengkap));
if (str_contains($namaDb, $namaMesinBersih)) {
return $santri;
}
}
// ── Strategi 3: nama santri ada di nama mesin ─────────
// Contoh: nama mesin "helga faisa fahar" → santri "helga faisa" ✓
foreach ($semuaSantri as $santri) {
$namaDb = strtolower(trim($santri->nama_lengkap));
if (str_contains($namaMesinBersih, $namaDb)) {
return $santri;
}
}
// ── Strategi 4 & 5: Skor berdasarkan kata ────────────
// Pecah nama mesin jadi kata-kata
// Contoh: "helga faisa" → ['helga', 'faisa']
$kataMesin = array_filter(explode(' ', $namaMesinBersih));
if (empty($kataMesin)) return null;
$kandidatTerbaik = null;
$skorTerbaik = 0;
foreach ($semuaSantri as $santri) {
$namaDb = strtolower(trim($santri->nama_lengkap));
$kataDb = array_filter(explode(' ', $namaDb));
$skorCocok = 0;
foreach ($kataMesin as $kata) {
// Minimal 3 karakter agar tidak false positive (mis. "al", "bin")
if (strlen($kata) < 3) continue;
foreach ($kataDb as $kataDbItem) {
if (
$kataDbItem === $kata || // kata persis sama
str_contains($kataDbItem, $kata) || // kata mesin ada di kata db
str_contains($kata, $kataDbItem) // kata db ada di kata mesin
) {
$skorCocok++;
break; // sudah cocok, lanjut kata berikutnya
}
}
}
// Hitung persentase kata yang cocok
$persenCocok = $skorCocok / count($kataMesin);
// Update kandidat jika skor lebih tinggi
if ($persenCocok > $skorTerbaik) {
$skorTerbaik = $persenCocok;
$kandidatTerbaik = $santri;
}
}
// Ambil kandidat hanya jika minimal 50% kata cocok
// Contoh: nama mesin "helga faisa" (2 kata) → butuh minimal 1 kata cocok
// Contoh: nama mesin "helga faisa fahar" (3 kata) → butuh minimal 2 kata cocok
if ($skorTerbaik >= 0.5) {
return $kandidatTerbaik;
}
// Tidak ada yang cocok
return null;
}
}

View File

@ -201,19 +201,43 @@ public function show($id, Request $request)
$kelasList = Kelas::active()->ordered()->with('kelompok')->get();
// Statistik untuk kegiatan ini
$stats = AbsensiKegiatan::where('kegiatan_id', $kegiatan->kegiatan_id)
->select('status', DB::raw('count(*) as total'))
// Statistik untuk kegiatan ini (sesuai filter)
$statsQuery = AbsensiKegiatan::where('kegiatan_id', $kegiatan->kegiatan_id);
if ($request->filled('tanggal_dari')) {
$statsQuery->whereDate('tanggal', '>=', $request->tanggal_dari);
}
if ($request->filled('tanggal_sampai')) {
$statsQuery->whereDate('tanggal', '<=', $request->tanggal_sampai);
}
if ($request->filled('bulan')) {
$statsQuery->whereMonth('tanggal', date('m', strtotime($request->bulan)))
->whereYear('tanggal', date('Y', strtotime($request->bulan)));
}
if ($request->filled('id_kelas')) {
$statsQuery->whereHas('santri.kelasSantri', function($q) use ($request) {
$q->where('id_kelas', $request->id_kelas);
});
}
$stats = $statsQuery->select('status', DB::raw('count(*) as total'))
->groupBy('status')
->pluck('total', 'status')
->toArray();
// Hitung total SEMUA santri aktif
$totalSantriEligible = Santri::where('status', 'Aktif')->count();
$totalRecorded = array_sum($stats);
$hadirCount = ($stats['Hadir'] ?? 0) + ($stats['Terlambat'] ?? 0);
$persenHadir = $totalSantriEligible > 0 ? round($hadirCount / $totalSantriEligible * 100, 1) : 0;
return view('admin.kegiatan.riwayat.show', compact(
'kegiatan',
'riwayats',
'santris',
'kelasList',
'stats'
'stats',
'totalSantriEligible',
'totalRecorded',
'persenHadir'
));
}

View File

@ -20,7 +20,6 @@ public function statusBulanIni(Request $request)
$bulanIni = date('n');
$tahunIni = date('Y');
// Cari SPP bulan ini
$spp = PembayaranSpp::where('id_santri', $idSantri)
->where('bulan', $bulanIni)
->where('tahun', $tahunIni)
@ -34,9 +33,16 @@ public function statusBulanIni(Request $request)
'status' => 'Belum Ada Tagihan',
'periode' => $this->getNamaBulan($bulanIni) . ' ' . $tahunIni,
]
], 200);
]);
}
// ── TAMBAHAN: data cicilan ──────────────────────────────
$isCicilan = $spp->isCicilan();
$nominalTerbayar = (int) $spp->nominal_terbayar; // accessor dari Model
$nominalSisa = (int) $spp->nominal_sisa; // accessor dari Model
$porsentase = $spp->porsentase_cicilan; // accessor dari Model
// ───────────────────────────────────────────────────────
return response()->json([
'success' => true,
'data' => [
@ -45,13 +51,18 @@ public function statusBulanIni(Request $request)
'periode' => $this->getNamaBulan($spp->bulan) . ' ' . $spp->tahun,
'nominal' => (int) $spp->nominal,
'status' => $spp->status,
'tanggal_bayar' => $spp->tanggal_bayar ? $spp->tanggal_bayar->format('Y-m-d') : null,
'tanggal_bayar_formatted' => $spp->tanggal_bayar ? $spp->tanggal_bayar->format('d M Y') : null,
'tanggal_bayar' => $spp->tanggal_bayar?->format('Y-m-d'),
'tanggal_bayar_formatted' => $spp->tanggal_bayar?->format('d M Y'),
'batas_bayar' => $spp->batas_bayar->format('Y-m-d'),
'batas_bayar_formatted' => $spp->batas_bayar->format('d M Y'),
'is_telat' => $spp->isTelat(),
// ── field baru ──
'is_cicilan' => $isCicilan,
'nominal_terbayar' => $nominalTerbayar,
'nominal_sisa' => $nominalSisa,
'porsentase_cicilan' => $porsentase,
]
], 200);
]);
} catch (\Exception $e) {
return response()->json([
@ -69,7 +80,6 @@ public function tunggakan(Request $request)
try {
$idSantri = $request->user()->id_santri;
// Hitung tunggakan
$tunggakanList = PembayaranSpp::where('id_santri', $idSantri)
->where('status', 'Belum Lunas')
->orderBy('tahun', 'asc')
@ -88,7 +98,7 @@ public function tunggakan(Request $request)
'jumlah_bulan' => $jumlahBulan,
'ada_telat' => $adaTelat,
]
], 200);
]);
} catch (\Exception $e) {
return response()->json([
@ -100,38 +110,50 @@ public function tunggakan(Request $request)
/**
* Get riwayat pembayaran SPP
*
* Query param ?status= bisa berisi:
* semua | Lunas | Belum Lunas | Cicilan
*/
public function riwayat(Request $request)
{
try {
$idSantri = $request->user()->id_santri;
// Query riwayat
$query = PembayaranSpp::where('id_santri', $idSantri)
->select([
'id',
'id_pembayaran',
'bulan',
'tahun',
'nominal',
'status',
'tanggal_bayar',
'batas_bayar',
'keterangan'
'id', 'id_pembayaran', 'bulan', 'tahun',
'nominal', 'status', 'tanggal_bayar',
'batas_bayar', 'keterangan',
])
->orderBy('tahun', 'desc')
->orderBy('bulan', 'desc');
// Filter status (optional)
if ($request->filled('status')) {
// ── REVISI: filter status termasuk "Cicilan" ───────────
if ($request->filled('status') && $request->status !== 'semua') {
if ($request->status === 'Cicilan') {
// Cicilan = Belum Lunas + keterangan JSON punya field "terbayar" > 0
$query->where('status', 'Belum Lunas')
->where(function ($q) {
// JSON valid & mengandung "terbayar"
$q->whereRaw("JSON_VALID(keterangan) = 1")
->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(keterangan, '$.terbayar')) > 0");
});
} else {
$query->where('status', $request->status);
}
}
// ───────────────────────────────────────────────────────
// Pagination
$riwayat = $query->paginate(20);
// Format data
$data = $riwayat->map(function ($item) {
// ── TAMBAHAN: data cicilan per item ─────────────────
$isCicilan = $item->isCicilan();
$nominalTerbayar = (int) $item->nominal_terbayar;
$nominalSisa = (int) $item->nominal_sisa;
$porsentase = $item->porsentase_cicilan;
// ────────────────────────────────────────────────────
return [
'id' => $item->id,
'id_pembayaran' => $item->id_pembayaran,
@ -141,12 +163,17 @@ public function riwayat(Request $request)
'bulan_nama' => $this->getNamaBulan($item->bulan),
'nominal' => (int) $item->nominal,
'status' => $item->status,
'tanggal_bayar' => $item->tanggal_bayar ? $item->tanggal_bayar->format('Y-m-d') : null,
'tanggal_bayar_formatted' => $item->tanggal_bayar ? $item->tanggal_bayar->format('d M Y') : null,
'tanggal_bayar' => $item->tanggal_bayar?->format('Y-m-d'),
'tanggal_bayar_formatted' => $item->tanggal_bayar?->format('d M Y'),
'batas_bayar' => $item->batas_bayar->format('Y-m-d'),
'batas_bayar_formatted' => $item->batas_bayar->format('d M Y'),
'is_telat' => $item->isTelat(),
'keterangan' => $item->keterangan,
// ── field baru ──
'is_cicilan' => $isCicilan,
'nominal_terbayar' => $nominalTerbayar,
'nominal_sisa' => $nominalSisa,
'porsentase_cicilan' => $porsentase,
];
});
@ -157,8 +184,8 @@ public function riwayat(Request $request)
'current_page' => $riwayat->currentPage(),
'last_page' => $riwayat->lastPage(),
'total' => $riwayat->total(),
]
], 200);
],
]);
} catch (\Exception $e) {
return response()->json([
@ -176,13 +203,18 @@ public function statistik(Request $request)
try {
$idSantri = $request->user()->id_santri;
$semuaBelumLunas = PembayaranSpp::where('id_santri', $idSantri)
->where('status', 'Belum Lunas')
->get();
$totalLunas = PembayaranSpp::where('id_santri', $idSantri)
->where('status', 'Lunas')
->count();
$totalBelumLunas = PembayaranSpp::where('id_santri', $idSantri)
->where('status', 'Belum Lunas')
->count();
// ── TAMBAHAN: pisahkan cicilan dari belum lunas ─────────
$totalCicilan = $semuaBelumLunas->filter(fn($s) => $s->isCicilan())->count();
$totalBelumLunas = $semuaBelumLunas->filter(fn($s) => !$s->isCicilan())->count();
// ────────────────────────────────────────────────────────
$totalNominalLunas = PembayaranSpp::where('id_santri', $idSantri)
->where('status', 'Lunas')
@ -196,11 +228,12 @@ public function statistik(Request $request)
'success' => true,
'data' => [
'total_lunas' => $totalLunas,
'total_belum_lunas' => $totalBelumLunas,
'total_cicilan' => $totalCicilan, // ← baru
'total_belum_lunas' => $totalBelumLunas, // ← sekarang exclude cicilan
'total_nominal_lunas' => (int) $totalNominalLunas,
'total_nominal_belum_lunas' => (int) $totalNominalBelumLunas,
]
], 200);
]);
} catch (\Exception $e) {
return response()->json([
@ -219,7 +252,7 @@ private function getNamaBulan($bulan)
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 $namaBulan[$bulan] ?? '';

View File

@ -17,7 +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\Keuangan;
use App\Models\UangSaku;
use App\Models\Capaian;
use App\Models\Semester;
@ -26,20 +26,24 @@
class DashboardController extends Controller
{
/**
* Mapping hari Carbon (English) DB enum (Indonesia)
* Mapping hari Carbon (English) -> DB enum (Indonesia)
*/
private function hariIndonesia(): array
{
return [
'Monday' => 'Senin', 'Tuesday' => 'Selasa', 'Wednesday' => 'Rabu',
'Thursday' => 'Kamis', 'Friday' => 'Jumat', 'Saturday' => 'Sabtu',
'Monday' => 'Senin',
'Tuesday' => 'Selasa',
'Wednesday' => 'Rabu',
'Thursday' => 'Kamis',
'Friday' => 'Jumat',
'Saturday' => 'Sabtu',
'Sunday' => 'Ahad',
];
}
/**
* Dashboard Admin
*/
// ══════════════════════════════════════════════════════════════════
// DASHBOARD ADMIN — tidak ada perubahan
// ══════════════════════════════════════════════════════════════════
public function admin()
{
try {
@ -49,13 +53,13 @@ public function admin()
$bulanIni = (int) $today->format('m');
$tahunIni = (int) $today->format('Y');
// ────────────────────────── KPI CARDS ──────────────────────────
// KPI CARDS
$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' => function ($q) use ($today) {
$q->whereDate('tanggal', $today);
}])
@ -64,23 +68,15 @@ public function admin()
->get();
$totalKegiatan = $kegiatanHariIni->count();
$sudahAbsensi = $kegiatanHariIni->filter(function ($k) {
return $k->absensis->isNotEmpty();
})->count();
$sudahAbsensi = $kegiatanHariIni->filter(fn($k) => $k->absensis->isNotEmpty())->count();
$belumAbsensi = $totalKegiatan - $sudahAbsensi;
// Santri di UKP (sedang dirawat)
$santriSakit = KesehatanSantri::dirawat()->count();
// Pengajuan kepulangan menunggu approval
$kepulanganMenunggu = PengajuanKepulangan::where('status', 'Menunggu')->count();
// Santri aktif yang belum punya akun wali (super_admin only)
$santriTanpaWali = 0;
if ($user->role === 'super_admin') {
$santriTanpaWali = Santri::aktif()
->whereDoesntHave('waliUser')
->count();
$santriTanpaWali = Santri::aktif()->whereDoesntHave('waliUser')->count();
}
$kpiCards = compact(
@ -88,7 +84,7 @@ public function admin()
'belumAbsensi', 'santriSakit', 'kepulanganMenunggu', 'santriTanpaWali'
);
// ──────────────────── JADWAL KEGIATAN HARI INI ────────────────────
// JADWAL KEGIATAN HARI INI
$kegiatanHariIni->each(function ($kegiatan) use ($now, $today, $totalSantriAktif) {
$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');
@ -101,16 +97,15 @@ public function admin()
$totalAbsen = $kegiatan->absensis->count();
$hadir = $kegiatan->absensis->where('status', 'Hadir')->count();
$kegiatan->persen_kehadiran = $totalAbsen > 0 ? round(($hadir / $totalAbsen) * 100) : 0;
$kegiatan->total_absensi = $totalAbsen;
$kegiatan->belum_input = $kegiatan->status_kegiatan === 'selesai' && $totalAbsen === 0;
});
// ────────────────────────── ALERT PANEL ──────────────────────────
// 1) Santri alpa beruntun (semua role bisa lihat)
// ALERT PANEL
$santriAlpaBeruntun = $this->getSantriAlpaBeruntun();
// 2) SPP jatuh tempo (super_admin only)
$sppJatuhTempo = collect([]);
if ($user->role === 'super_admin') {
$sppJatuhTempo = PembayaranSpp::telat()
@ -121,7 +116,6 @@ public function admin()
->get();
}
// 3) Pengajuan kepulangan menunggu review
$kepulanganPending = PengajuanKepulangan::where('status', 'Menunggu')
->with('santri:id_santri,nama_lengkap')
->select('id_pengajuan', 'id_santri', 'tanggal_pulang', 'tanggal_kembali', 'alasan')
@ -131,31 +125,26 @@ public function admin()
$alerts = compact('santriAlpaBeruntun', 'sppJatuhTempo', 'kepulanganPending');
// ──────────────── GRAFIK TREN KEHADIRAN (4 MINGGU) ────────────────
// GRAFIK TREN KEHADIRAN (4 MINGGU)
$trenKehadiran = $this->getTrenKehadiran($today);
// ──────────────── RINGKASAN SPP + KEUANGAN BULAN INI ─────────────
// Default (untuk non super_admin atau jika query gagal)
// RINGKASAN SPP + KEUANGAN BULAN INI
$sppBulanIni = [
'lunas' => 0,
'belum' => 0,
'terkumpul' => 0,
'totalTagihan' => 0,
'pemasukanLain' => 0, // pemasukan kas pondok selain SPP
'pengeluaran' => 0, // pengeluaran kas pondok
'pemasukanLain' => 0,
'pengeluaran' => 0,
];
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 = (float) PembayaranSpp::where('bulan', $bulanIni)->where('tahun', $tahunIni)->lunas()->sum('nominal');
$totalTagihan = (float) PembayaranSpp::where('bulan', $bulanIni)->where('tahun', $tahunIni)->sum('nominal');
// ── Data Keuangan Pondok (non-SPP) ──
$pemasukanLain = (float) Keuangan::pemasukan()
->whereMonth('tanggal', $bulanIni)
->whereYear('tanggal', $tahunIni)
@ -185,11 +174,10 @@ public function admin()
}
}
// ══════════════════ HELPER METHODS ══════════════════
// ══════════════════════════════════════════════════════════════════
// HELPER METHODS (dipakai oleh admin)
// ══════════════════════════════════════════════════════════════════
/**
* Santri dengan alpa 3x beruntun dalam 7 hari terakhir
*/
private function getSantriAlpaBeruntun(int $threshold = 3): \Illuminate\Support\Collection
{
$weekAgo = Carbon::today()->subDays(7);
@ -217,9 +205,6 @@ private function getSantriAlpaBeruntun(int $threshold = 3): \Illuminate\Support\
]);
}
/**
* Tren kehadiran 4 minggu terakhir, dikelompokkan per kategori kegiatan
*/
private function getTrenKehadiran(Carbon $today): array
{
$labels = [];
@ -239,21 +224,21 @@ private function getTrenKehadiran(Carbon $today): array
$totalAbsen = AbsensiKegiatan::whereIn('kegiatan_id', $kegiatanIds)
->dateRange($start, $end)
->count();
$hadir = AbsensiKegiatan::whereIn('kegiatan_id', $kegiatanIds)
->dateRange($start, $end)
->where('status', 'Hadir')
->count();
$series[$kat->nama_kategori][] = $totalAbsen > 0 ? round(($hadir / $totalAbsen) * 100, 1) : 0;
$series[$kat->nama_kategori][] = $totalAbsen > 0
? round(($hadir / $totalAbsen) * 100, 1)
: 0;
}
}
return compact('labels', 'series');
}
/**
* Feed aktivitas terbaru
*/
private function getFeedAktivitas(Carbon $today): \Illuminate\Support\Collection
{
$items = collect();
@ -299,9 +284,58 @@ private function getFeedAktivitas(Carbon $today): \Illuminate\Support\Collection
return $items->sortByDesc('time')->take(10)->values();
}
// ══════════════════════════════════════════════════════════════════
// HELPER: Absensi per kategori (dipakai santri())
// ══════════════════════════════════════════════════════════════════
/**
* Dashboard Santri
* Ambil statistik absensi per kategori kegiatan untuk 1 santri
* dalam rentang tanggal tertentu.
*
* @param int $idSantri
* @param string $dateStart format Y-m-d
* @param string $dateEnd format Y-m-d
* @return array ['labels'=>[], 'hadir'=>[], 'alpa'=>[], 'izin'=>[], 'sakit'=>[]]
*/
private function getAbsensiPerKategori(string|int $idSantri, string $dateStart, string $dateEnd): array
{
$result = ['labels' => [], 'hadir' => [], 'alpa' => [], 'izin' => [], 'sakit' => []];
$kategoris = KategoriKegiatan::select('kategori_id', 'nama_kategori')
->orderBy('nama_kategori')
->get();
foreach ($kategoris as $kat) {
$kegIds = Kegiatan::where('kategori_id', $kat->kategori_id)
->pluck('kegiatan_id');
if ($kegIds->isEmpty()) {
continue;
}
$abs = AbsensiKegiatan::where('id_santri', $idSantri)
->whereIn('kegiatan_id', $kegIds)
->whereBetween('tanggal', [$dateStart, $dateEnd])
->get();
// Skip kategori yang tidak punya record sama sekali di periode ini
if ($abs->isEmpty()) {
continue;
}
$result['labels'][] = $kat->nama_kategori;
$result['hadir'][] = $abs->whereIn('status', ['Hadir', 'Terlambat'])->count();
$result['alpa'][] = $abs->where('status', 'Alpa')->count();
$result['izin'][] = $abs->where('status', 'Izin')->count();
$result['sakit'][] = $abs->where('status', 'Sakit')->count();
}
return $result;
}
// ══════════════════════════════════════════════════════════════════
// DASHBOARD SANTRI
// ══════════════════════════════════════════════════════════════════
public function santri()
{
try {
@ -312,9 +346,7 @@ public function santri()
Log::info('Role: ' . $account->role);
Log::info('ID Santri: ' . $account->id_santri);
$santri = Santri::with([
'kelasPrimary.kelas.kelompok',
])
$santri = Santri::with(['kelasPrimary.kelas.kelompok'])
->where('id_santri', $account->id_santri)
->select('id_santri', 'nama_lengkap')
->first();
@ -329,9 +361,8 @@ public function santri()
$namaKelas = $santri->kelas;
$idSantri = $santri->id_santri;
$today = Carbon::today();
$weekAgo = Carbon::now()->subDays(7);
// Ambil semester aktif dengan FALLBACK
// ─── Semester aktif ───────────────────────────────────────
$semesterAktif = null;
try {
$semesterAktif = Semester::aktif()
@ -348,118 +379,81 @@ public function santri()
Log::info('Semester aktif: ' . ($semesterAktif ? $semesterAktif->nama_semester : 'Tidak ada'));
} catch (\Exception $e) {
Log::warning('Error mengambil semester: ' . $e->getMessage());
$semesterAktif = null;
}
// Progres Al-Qur'an
// ─── 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) {
$q->where('kategori', 'Al-Qur\'an');
})->avg('persentase') ?? 0;
Log::info('Progres Al-Quran: ' . $progresAlquran);
$progresAlquran = $query->whereHas('materi', fn($q) => $q->where('kategori', "Al-Qur'an"))
->avg('persentase') ?? 0;
} catch (\Exception $e) {
Log::warning('Error progres Al-Quran: ' . $e->getMessage());
}
// Progres Hadist
// ─── 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) {
$q->where('kategori', 'Hadist');
})->avg('persentase') ?? 0;
Log::info('Progres Hadist: ' . $progresHadist);
$progresHadist = $query->whereHas('materi', fn($q) => $q->where('kategori', 'Hadist'))
->avg('persentase') ?? 0;
} catch (\Exception $e) {
Log::warning('Error progres Hadist: ' . $e->getMessage());
}
// Progres Materi Tambahan
// ─── 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) {
$q->where('kategori', 'Materi Tambahan');
})->avg('persentase') ?? 0;
Log::info('Progres Materi Tambahan: ' . $progresMateriTambahan);
$progresMateriTambahan = $query->whereHas('materi', fn($q) => $q->where('kategori', 'Materi Tambahan'))
->avg('persentase') ?? 0;
} catch (\Exception $e) {
Log::warning('Error progres Materi Tambahan: ' . $e->getMessage());
}
// Data untuk grafik: Progress per Materi
// ─── Capaian per Materi ───────────────────────────────────
$capaianPerMateri = collect([]);
try {
$query = Capaian::with(['materi' => function ($q) {
$q->select('id_materi', 'nama_kitab', 'kategori', 'total_halaman');
}])
$query = Capaian::with(['materi' => fn($q) => $q->select('id_materi', 'nama_kitab', 'kategori', 'total_halaman')])
->where('id_santri', $idSantri);
if ($semesterAktif) {
$query->where('id_semester', $semesterAktif->id_semester);
}
$capaianPerMateri = $query->select('id', 'id_materi', 'persentase', 'halaman_selesai')
->orderBy('persentase', 'desc')
->limit(10)
->get();
Log::info('Capaian per materi: ' . $capaianPerMateri->count() . ' items');
} catch (\Exception $e) {
Log::warning('Error capaian per materi: ' . $e->getMessage());
$capaianPerMateri = collect([]);
}
// Data untuk grafik: Distribusi Status
$distribusiStatus = [
'selesai' => 0,
'hampir_selesai' => 0,
'sedang_berjalan' => 0,
'baru_dimulai' => 0,
];
// ─── 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);
}
$distribusiStatus = [
'selesai' => (clone $baseQuery)->where('persentase', '>=', 100)->count(),
'hampir_selesai' => (clone $baseQuery)->whereBetween('persentase', [75, 99.99])->count(),
'sedang_berjalan' => (clone $baseQuery)->whereBetween('persentase', [25, 74.99])->count(),
'baru_dimulai' => (clone $baseQuery)->whereBetween('persentase', [0, 24.99])->count(),
];
Log::info('Distribusi status: ' . json_encode($distribusiStatus));
} catch (\Exception $e) {
Log::warning('Error distribusi status: ' . $e->getMessage());
}
$data = [
'nama_santri' => $santri->nama_lengkap,
'kelas' => $namaKelas,
'progres_quran' => round($progresAlquran, 1),
'progres_hadist' => round($progresHadist, 1),
'progres_materi_tambahan' => round($progresMateriTambahan, 1),
'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));
// Status kesehatan
// ─── Status Kesehatan ─────────────────────────────────────
$statusKesehatan = null;
try {
$statusKesehatan = KesehatanSantri::where('id_santri', $idSantri)
@ -471,7 +465,7 @@ public function santri()
Log::warning('Error status kesehatan: ' . $e->getMessage());
}
// Kepulangan aktif
// ─── Kepulangan Aktif ─────────────────────────────────────
$kepulanganAktif = null;
try {
$kepulanganAktif = Kepulangan::where('id_santri', $idSantri)
@ -484,12 +478,12 @@ public function santri()
Log::warning('Error kepulangan aktif: ' . $e->getMessage());
}
// Berita terbaru
// ─── Berita Terbaru ───────────────────────────────────────
// Tanpa filter tanggal agar semua berita relevan muncul, limit 5
$beritaTerbaru = collect([]);
try {
$beritaTerbaru = Berita::select('id_berita', 'judul', 'created_at')
->where('status', 'published')
->where('created_at', '>=', $weekAgo)
->where(function ($query) use ($namaKelas) {
$query->where('target_berita', 'semua')
->orWhere(function ($q) use ($namaKelas) {
@ -500,13 +494,157 @@ public function santri()
->orderBy('created_at', 'desc')
->limit(5)
->get();
Log::info('Berita terbaru: ' . $beritaTerbaru->count() . ' items');
} catch (\Exception $e) {
Log::warning('Error berita terbaru: ' . $e->getMessage());
$beritaTerbaru = collect([]);
}
// ─── Statistik Kepulangan Tahun Ini ──────────────────────
$statistikKepulangan = [
'total_hari' => 0,
'sisa_kuota' => 12,
'persen_kuota' => 0,
'disetujui' => 0,
'menunggu' => 0,
'over_limit' => false,
];
try {
$kepulanganTahunIni = Kepulangan::where('id_santri', $idSantri)
->whereYear('tanggal_pulang', $today->year)
->get();
$totalHariKepulangan = $kepulanganTahunIni
->whereIn('status', ['Disetujui', 'Selesai'])
->sum('durasi_izin');
$statistikKepulangan = [
'total_hari' => $totalHariKepulangan,
'sisa_kuota' => max(0, 12 - $totalHariKepulangan),
'persen_kuota' => min(100, round(($totalHariKepulangan / 12) * 100)),
'disetujui' => $kepulanganTahunIni->whereIn('status', ['Disetujui', 'Selesai'])->count(),
'menunggu' => $kepulanganTahunIni->where('status', 'Menunggu')->count(),
'over_limit' => $totalHariKepulangan > 12,
];
Log::info('Statistik kepulangan: ' . json_encode($statistikKepulangan));
} catch (\Exception $e) {
Log::warning('Error statistik kepulangan: ' . $e->getMessage());
}
// ─── Statistik Kesehatan Bulan Ini ───────────────────────
$statistikKesehatan = [
'total_kunjungan' => 0,
'sembuh' => 0,
'dirawat' => 0,
'izin' => 0,
];
try {
$kesehatanBulanIni = KesehatanSantri::where('id_santri', $idSantri)
->whereMonth('tanggal_masuk', $today->month)
->whereYear('tanggal_masuk', $today->year)
->get();
$statistikKesehatan = [
'total_kunjungan' => $kesehatanBulanIni->count(),
'sembuh' => $kesehatanBulanIni->where('status', 'sembuh')->count(),
'dirawat' => $kesehatanBulanIni->where('status', 'dirawat')->count(),
'izin' => $kesehatanBulanIni->where('status', 'izin')->count(),
];
} catch (\Exception $e) {
Log::warning('Error statistik kesehatan: ' . $e->getMessage());
}
// ─── 5 Pelanggaran Terbaru ────────────────────────────────
$pelanggaranTerbaru = collect([]);
try {
$pelanggaranTerbaru = RiwayatPelanggaran::with('kategori:id,id_kategori,nama_pelanggaran')
->where('id_santri', $idSantri)
->select('id', 'id_riwayat', 'id_kategori', 'tanggal', 'poin', 'keterangan')
->orderBy('tanggal', 'desc')
->limit(5)
->get();
Log::info('Pelanggaran terbaru: ' . $pelanggaranTerbaru->count() . ' items');
} catch (\Exception $e) {
Log::warning('Error pelanggaran terbaru: ' . $e->getMessage());
}
// ─── [BARU] Absensi per Kategori — Bulan Ini ─────────────
$absensiPerKategori = ['labels' => [], 'hadir' => [], 'alpa' => [], 'izin' => [], 'sakit' => []];
try {
$startBulan = $today->copy()->startOfMonth()->format('Y-m-d');
$endBulan = $today->format('Y-m-d');
$absensiPerKategori = $this->getAbsensiPerKategori($idSantri, $startBulan, $endBulan);
Log::info('Absensi per kategori bulan ini: ' . count($absensiPerKategori['labels']) . ' kategori');
} catch (\Exception $e) {
Log::warning('Error absensi per kategori bulan: ' . $e->getMessage());
}
// ─── [BARU] Absensi per Kategori — Minggu Ini ────────────
$absensiPerKategoriMinggu = ['labels' => [], 'hadir' => [], 'alpa' => [], 'izin' => [], 'sakit' => []];
try {
$startMinggu = $today->copy()->startOfWeek(Carbon::MONDAY)->format('Y-m-d');
$endMinggu = $today->format('Y-m-d');
$absensiPerKategoriMinggu = $this->getAbsensiPerKategori($idSantri, $startMinggu, $endMinggu);
Log::info('Absensi per kategori minggu ini: ' . count($absensiPerKategoriMinggu['labels']) . ' kategori');
} catch (\Exception $e) {
Log::warning('Error absensi per kategori minggu: ' . $e->getMessage());
}
// ─── [BARU] Status Input Capaian ──────────────────────────
$statusInputCapaian = [
'is_open' => false,
'deadline' => null,
'sudah_input' => 0,
'total_materi' => 0,
];
try {
if ($semesterAktif) {
// Sesuaikan nama kolom jika berbeda di tabel semesters
$bukaSemester = $semesterAktif->tanggal_buka_input ?? null;
$tutupSemester = $semesterAktif->tanggal_tutup_input ?? null;
$isOpen = false;
if ($bukaSemester && $tutupSemester) {
$now = Carbon::now();
$isOpen = $now->gte(Carbon::parse($bukaSemester))
&& $now->lte(Carbon::parse($tutupSemester));
}
$sudahInput = Capaian::where('id_santri', $idSantri)
->where('id_semester', $semesterAktif->id_semester)
->where('persentase', '>', 0)
->count();
$totalMateri = Capaian::where('id_santri', $idSantri)
->where('id_semester', $semesterAktif->id_semester)
->count();
$statusInputCapaian = [
'is_open' => $isOpen,
'deadline' => $tutupSemester,
'sudah_input' => $sudahInput,
'total_materi' => $totalMateri,
];
}
} catch (\Exception $e) {
Log::warning('Error status input capaian: ' . $e->getMessage());
}
// ─── Data array untuk view ────────────────────────────────
$data = [
'nama_santri' => $santri->nama_lengkap,
'kelas' => $namaKelas,
'progres_quran' => round($progresAlquran, 1),
'progres_hadist' => round($progresHadist, 1),
'progres_materi_tambahan' => round($progresMateriTambahan, 1),
'saldo_uang_saku' => $santri->saldo_uang_saku ?? 0,
'poin_pelanggaran' => RiwayatPelanggaran::where('id_santri', $idSantri)->sum('poin') ?? 0,
];
Log::info('=== DASHBOARD SANTRI SUCCESS ===');
return view('santri.dashboardSantri', compact(
@ -518,7 +656,14 @@ public function santri()
'kepulanganAktif',
'capaianPerMateri',
'distribusiStatus',
'semesterAktif'
'semesterAktif',
'statistikKepulangan',
'statistikKesehatan',
'pelanggaranTerbaru',
// ─── variabel baru ───
'absensiPerKategori',
'absensiPerKategoriMinggu',
'statusInputCapaian'
));
} catch (\Exception $e) {

View File

@ -17,11 +17,6 @@ private function getSantriId()
return auth('santri')->user()->id_santri;
}
/**
* 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);
@ -40,7 +35,6 @@ private function resolveDateRange(Request $request, string $defaultPreset = 'tod
$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();
@ -52,34 +46,29 @@ private function resolveDateRange(Request $request, string $defaultPreset = 'tod
}
}
// ================================================================
// INDEX
// ================================================================
public function index(Request $request)
{
$idSantri = $this->getSantriId();
// ✅ FIX: No 'kelas' column, use relasi
$santri = Santri::where('id_santri', $idSantri)
->with(['kelasPrimary.kelas'])
->select('id_santri', 'nama_lengkap', 'nis', 'status')
->firstOrFail();
$namaKelas = optional(optional($santri->kelasPrimary)->kelas)->nama_kelas ?? '-';
$kelasSantriId = optional($santri->kelasPrimary)->id_kelas;
// -- Aktif tab (dari request, default: statistik) --
$activeTab = $request->input('tab', 'statistik');
// -- 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)
// Statistik range
// ── Statistik range ───────────────────────────────────
$statPresetReq = $request->input('preset_stat', $request->input('preset', 'this_week'));
[$statFrom, $statTo, $statPreset] = $this->resolveDateRange(
$request->merge(['preset' => $statPresetReq,
$request->merge([
'preset' => $statPresetReq,
'date_from' => $request->input('stat_date_from'),
'date_to' => $request->input('stat_date_to')]),
'date_to' => $request->input('stat_date_to'),
]),
'this_week'
);
if ($statPreset === 'custom') {
@ -87,34 +76,31 @@ public function index(Request $request)
$statTo = $request->filled('stat_date_to') ? Carbon::parse($request->stat_date_to)->endOfDay() : $statTo;
}
// Jadwal range
// ── Jadwal range ──────────────────────────────────────
$jadPresetReq = $request->input('preset_jad', $request->input('preset', 'today'));
[$jadFrom, $jadTo, $jadPreset] = $this->resolveDateRange(
$request->merge(['preset' => $jadPresetReq,
$request->merge([
'preset' => $jadPresetReq,
'date_from' => $request->input('jad_date_from'),
'date_to' => $request->input('jad_date_to')]),
'date_to' => $request->input('jad_date_to'),
]),
'today'
);
// 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'
);
// -- Mapping hari --
// ── Mapping hari Carbon → nama hari di DB ─────────────
$hariMapDb = [
'Senin' => 'Senin', 'Selasa' => 'Selasa', 'Rabu' => 'Rabu',
'Kamis' => 'Kamis', 'Jumat' => 'Jumat', 'Sabtu' => 'Sabtu',
'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) ──────────────────────────────────
// ── KPI stats (stat range) ────────────────────────────
$statFromStr = $statFrom->format('Y-m-d');
$statToStr = $statTo->format('Y-m-d');
@ -127,12 +113,26 @@ public function index(Request $request)
$totalRange = array_sum($statsRange);
$hadirRange = $statsRange['Hadir'] ?? 0;
$terlambatRange = $statsRange['Terlambat'] ?? 0;
$izinRange = $statsRange['Izin'] ?? 0;
$sakitRange = $statsRange['Sakit'] ?? 0;
$alpaRange = $statsRange['Alpa'] ?? 0;
$persentaseKehadiran = $totalRange > 0 ? round($hadirRange / $totalRange * 100, 1) : 0;
$pulangRange = $statsRange['Pulang'] ?? 0;
// ── JADWAL ────────────────────────────────────────────────────────
// ── Expected total: semua kegiatan di hari itu, tanpa filter kelas ──
$expectedTotal = 0;
$curStat = $statFrom->copy();
while ($curStat->lte($statTo)) {
$hariDb = $hariMapDb[$curStat->locale('id')->dayName] ?? $curStat->locale('id')->dayName;
$expectedTotal += Kegiatan::where('hari', $hariDb)->count();
$curStat->addDay();
}
$belumAbsenRange = max(0, $expectedTotal - $totalRange);
$hadirEfektif = $hadirRange + $terlambatRange;
$persentaseKehadiran = $expectedTotal > 0 ? round($hadirEfektif / $expectedTotal * 100, 1) : 0;
// ── Jadwal dalam range: semua kegiatan, tanpa filter kelas ───
$hariDalamRange = [];
$cursor = $jadFrom->copy();
while ($cursor->lte($jadTo)) {
@ -144,62 +144,35 @@ public function index(Request $request)
$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();
// Status absensi per kegiatan dalam range jadwal
// ── Status absensi santri 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();
// 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));
}
$riwayats = $queryRiwayat->orderBy('tanggal', 'desc')
->orderBy('waktu_absen', 'desc')
->paginate(15)
->appends(request()->query());
// ── STREAK ────────────────────────────────────────────────────────
// ── Streak ───────────────────────────────────────────
$streak = 0;
AbsensiKegiatan::where('id_santri', $idSantri)
->orderByDesc('tanggal')->orderByDesc('waktu_absen')
->select('status')->limit(60)
->orderByDesc('tanggal')
->orderByDesc('waktu_absen')
->select('status')
->limit(60)
->each(function ($a) use (&$streak) {
if ($a->status === 'Hadir') $streak++;
if (in_array($a->status, ['Hadir', 'Terlambat'])) $streak++;
else return false;
});
// ── GRAFIK TREN (stat range) ──────────────────────────────────────
// ── Grafik tren ───────────────────────────────────────
$diffDays = $statFrom->diffInDays($statTo);
$dataGrafik = [];
@ -207,8 +180,13 @@ public function index(Request $request)
$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();
$hadir = AbsensiKegiatan::where('id_santri', $idSantri)
->whereDate('tanggal', $d)
->whereIn('status', ['Hadir', 'Terlambat'])
->count();
$total = AbsensiKegiatan::where('id_santri', $idSantri)
->whereDate('tanggal', $d)
->count();
$dataGrafik[] = ['label' => $cur->format('d/m'), 'hadir' => $hadir, 'total' => $total];
$cur->addDay();
}
@ -217,107 +195,88 @@ public function index(Request $request)
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];
$hadir = AbsensiKegiatan::where('id_santri', $idSantri)
->whereBetween('tanggal', [$wStart->format('Y-m-d'), $wEnd->format('Y-m-d')])
->whereIn('status', ['Hadir', 'Terlambat'])
->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('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('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();
// ── Recent Absensi (8 terbaru dalam stat range) ───────
$recentAbsensi = AbsensiKegiatan::with('kegiatan.kategori')
->where('id_santri', $idSantri)
->whereBetween('tanggal', [$statFromStr, $statToStr])
->orderBy('tanggal', 'desc')
->orderBy('waktu_absen', 'desc')
->limit(8)
->get();
// ── HEATMAP: kalender bulan aktif (stat range, max tampil 1 bulan) ─
// Kita buat kalender bulan-bulan dalam stat range, dengan angka tanggal
// ── Heatmap kalender ──────────────────────────────────
$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
$firstDayOfWeek = $cur->copy()->startOfMonth()->dayOfWeekIso;
$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);
$hadirCount = $rows->whereIn('status', ['Hadir', 'Terlambat'])->count();
$pct = round($hadirCount / $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(),
'count' => $rows->whereIn('status', ['Hadir', 'Terlambat'])->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', 'namaKelas',
'jadwalDalamRange', 'absensiDalamRange', 'absensiHariIni', 'hariIni',
'jadPreset', 'jadFrom', 'jadTo',
'riwayats', 'riwPreset', 'riwFrom', 'riwTo',
'statsRange', 'totalRange', 'hadirRange', 'izinRange', 'sakitRange', 'alpaRange',
'persentaseKehadiran', 'streak',
'statsRange', 'totalRange',
'hadirRange', 'terlambatRange', 'izinRange', 'sakitRange', 'alpaRange', 'pulangRange',
'hadirEfektif',
'persentaseKehadiran', 'streak', 'expectedTotal', 'belumAbsenRange',
'dataGrafik', 'statPreset', 'statFrom', 'statTo', 'statFromStr', 'statToStr', 'diffDays',
'consistencyScores',
'recentAbsensi',
'heatmapMonths',
'kategoriList',
'activeTab', 'hariIni'
));
}
// ================================================================
// SHOW — support filter tanggal, semua data ikut filter
// ================================================================
public function show($kegiatan_id, Request $request)
{
$idSantri = $this->getSantriId();
$santri = Santri::where('id_santri', $idSantri)
->with(['kelasPrimary.kelas'])
->select('id_santri', 'nama_lengkap', 'nis', 'status')
->firstOrFail();
@ -325,47 +284,135 @@ public function show($kegiatan_id, Request $request)
->where('kegiatan_id', $kegiatan_id)
->firstOrFail();
$riwayats = AbsensiKegiatan::where('id_santri', $santri->id_santri)
->where('kegiatan_id', $kegiatan_id)
->orderBy('tanggal', 'desc')
->paginate(20);
// ── Resolve date range ────────────────────────────────
$preset = $request->input('preset', 'this_week');
$now = Carbon::now();
$stats = AbsensiKegiatan::where('id_santri', $santri->id_santri)
switch ($preset) {
case 'this_week':
$dateFrom = $now->copy()->startOfWeek();
$dateTo = $now->copy()->endOfWeek();
break;
case 'this_month':
$dateFrom = $now->copy()->startOfMonth();
$dateTo = $now->copy()->endOfMonth();
break;
case 'last_month':
$dateFrom = $now->copy()->subMonth()->startOfMonth();
$dateTo = $now->copy()->subMonth()->endOfMonth();
break;
case 'last_3m':
$dateFrom = $now->copy()->subMonths(3)->startOfDay();
$dateTo = $now->copy()->endOfDay();
break;
case 'all':
$oldest = AbsensiKegiatan::where('id_santri', $idSantri)
->where('kegiatan_id', $kegiatan_id)
->min('tanggal');
$dateFrom = $oldest
? Carbon::parse($oldest)->startOfDay()
: $now->copy()->startOfWeek();
$dateTo = $now->copy()->endOfDay();
break;
default:
$dateFrom = $request->filled('date_from')
? Carbon::parse($request->date_from)->startOfDay()
: $now->copy()->startOfWeek();
$dateTo = $request->filled('date_to')
? Carbon::parse($request->date_to)->endOfDay()
: $now->copy()->endOfWeek();
if ($dateFrom->gt($dateTo)) [$dateFrom, $dateTo] = [$dateTo, $dateFrom];
$preset = 'custom';
}
$fromStr = $dateFrom->format('Y-m-d');
$toStr = $dateTo->format('Y-m-d');
// ── Stats dalam range ─────────────────────────────────
$stats = AbsensiKegiatan::where('id_santri', $idSantri)
->where('kegiatan_id', $kegiatan_id)
->whereBetween('tanggal', [$fromStr, $toStr])
->select('status', DB::raw('count(*) as total'))
->groupBy('status')
->pluck('total', 'status')
->toArray();
$totalAbsensi = array_sum($stats);
$hadirEfektif = ($stats['Hadir'] ?? 0) + ($stats['Terlambat'] ?? 0);
$persentaseHadir = $totalAbsensi > 0
? round(($stats['Hadir'] ?? 0) / $totalAbsensi * 100, 1) : 0;
? round($hadirEfektif / $totalAbsensi * 100, 1) : 0;
$trendBulanan = [];
for ($i = 5; $i >= 0; $i--) {
$bulan = Carbon::now()->subMonths($i);
// ── Riwayat tabel (paginated, ikut range) ─────────────
$riwayats = AbsensiKegiatan::where('id_santri', $idSantri)
->where('kegiatan_id', $kegiatan_id)
->whereBetween('tanggal', [$fromStr, $toStr])
->orderBy('tanggal', 'desc')
->paginate(20)
->appends($request->query());
// ── Lookup tanggal => status untuk kalender visual ────
// Query terpisah agar tidak terbatas oleh pagination $riwayats
$absensiByDate = AbsensiKegiatan::where('id_santri', $idSantri)
->where('kegiatan_id', $kegiatan_id)
->whereBetween('tanggal', [$fromStr, $toStr])
->select('tanggal', 'status')
->get()
->mapWithKeys(fn($a) => [Carbon::parse($a->tanggal)->format('Y-m-d') => $a->status])
->toArray();
// ── Tren data ─────────────────────────────────────────
$diffDays = $dateFrom->diffInDays($dateTo);
$trendData = [];
if ($diffDays <= 31) {
$cur = $dateFrom->copy();
while ($cur->lte($dateTo)) {
$d = $cur->format('Y-m-d');
$data = AbsensiKegiatan::where('id_santri', $idSantri)
->where('kegiatan_id', $kegiatan_id)
->whereMonth('tanggal', $bulan->month)
->whereYear('tanggal', $bulan->year)
->whereDate('tanggal', $d)
->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,
$trendData[] = [
'label' => $cur->format('d/m'),
'hadir' => ($data['Hadir'] ?? 0) + ($data['Terlambat'] ?? 0),
'total' => array_sum($data),
];
$cur->addDay();
}
$trendLabel = 'Harian';
} else {
$cur = $dateFrom->copy()->startOfWeek();
while ($cur->lte($dateTo)) {
$wStart = $cur->copy()->max($dateFrom);
$wEnd = $cur->copy()->endOfWeek()->min($dateTo);
$data = AbsensiKegiatan::where('id_santri', $idSantri)
->where('kegiatan_id', $kegiatan_id)
->whereBetween('tanggal', [$wStart->format('Y-m-d'), $wEnd->format('Y-m-d')])
->select('status', DB::raw('count(*) as total'))
->groupBy('status')
->pluck('total', 'status')
->toArray();
$trendData[] = [
'label' => $wStart->format('d/m') . '' . $wEnd->format('d/m'),
'hadir' => ($data['Hadir'] ?? 0) + ($data['Terlambat'] ?? 0),
'total' => array_sum($data),
];
$cur->addWeek();
}
$trendLabel = 'Mingguan';
}
// Referrer tab untuk tombol kembali
$fromTab = $request->input('from_tab', 'riwayat');
$fromTab = $request->input('from_tab', 'jadwal');
return view('santri.kegiatan.show', compact(
'santri', 'kegiatan', 'riwayats',
'stats', 'totalAbsensi', 'persentaseHadir',
'trendBulanan', 'fromTab'
'stats', 'totalAbsensi', 'hadirEfektif', 'persentaseHadir',
'trendData', 'trendLabel',
'dateFrom', 'dateTo', 'fromStr', 'toStr', 'preset', 'fromTab',
'absensiByDate'
));
}
}

View File

@ -16,7 +16,8 @@ class AbsensiKegiatan extends Model
'id_santri',
'tanggal',
'status',
'metode_absen',
'metode_absen', // ← BARU: 'Manual' | 'RFID' | 'Import_Mesin'
'konflik_catatan', // ← BARU: catatan resolusi konflik
'waktu_absen',
];
@ -40,9 +41,17 @@ protected static function boot()
$num = $last ? intval(substr($last->absensi_id, 1)) + 1 : 1;
$model->absensi_id = 'A' . str_pad($num, 3, '0', STR_PAD_LEFT);
}
// Default metode_absen jika tidak diset
if (empty($model->metode_absen)) {
$model->metode_absen = 'Manual';
}
});
}
// ──────────────────────────────────────────────────────────
// RELASI
// ──────────────────────────────────────────────────────────
/**
* Relasi ke Santri
*/
@ -59,6 +68,10 @@ public function kegiatan()
return $this->belongsTo(Kegiatan::class, 'kegiatan_id', 'kegiatan_id');
}
// ──────────────────────────────────────────────────────────
// SCOPES
// ──────────────────────────────────────────────────────────
/**
* Scope: Filter berdasarkan tanggal
*/
@ -76,7 +89,36 @@ public function scopeKegiatan($query, $kegiatan_id)
}
/**
* Accessor: Status Badge (HTML - untuk admin)
* Scope: Filter by date range
*/
public function scopeDateRange($query, $start, $end)
{
return $query->whereBetween('tanggal', [$start, $end]);
}
/**
* Scope: Filter by month
*/
public function scopeByMonth($query, $month, $year)
{
return $query->whereMonth('tanggal', $month)
->whereYear('tanggal', $year);
}
/**
* Scope: Filter by metode absen
*/
public function scopeByMetode($query, $metode)
{
return $query->where('metode_absen', $metode);
}
// ──────────────────────────────────────────────────────────
// ACCESSORS
// ──────────────────────────────────────────────────────────
/**
* Accessor: Status Badge HTML (untuk admin)
*/
public function getStatusBadgeAttribute()
{
@ -88,13 +130,22 @@ public function getStatusBadgeAttribute()
'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;
}
// ============================================
// ✅ TAMBAHKAN METHOD-METHOD BARU DI BAWAH INI
// ============================================
/**
* Accessor: Metode Badge HTML (untuk tampilan tabel absensi)
* Manual=biru, RFID=hijau, Import_Mesin=oranye
*/
public function getMetodeBadgeAttribute()
{
$badges = [
'Manual' => '<span style="background:#DBEAFE;color:#1D4ED8;border-radius:8px;padding:2px 8px;font-size:11px;font-weight:600">✋ Manual</span>',
'RFID' => '<span style="background:#DCFCE7;color:#166534;border-radius:8px;padding:2px 8px;font-size:11px;font-weight:600">💳 RFID</span>',
'Import_Mesin' => '<span style="background:#FFF7ED;color:#C05621;border-radius:8px;padding:2px 8px;font-size:11px;font-weight:600">👆 Mesin</span>',
];
return $badges[$this->metode_absen] ?? $this->metode_absen;
}
/**
* Accessor: Tanggal Formatted (untuk view santri)
@ -105,7 +156,7 @@ public function getTanggalFormattedAttribute()
}
/**
* Accessor: Waktu Absen Formatted (untuk view santri)
* Accessor: Waktu Absen Formatted
*/
public function getWaktuAbsenFormattedAttribute()
{
@ -127,21 +178,4 @@ public function getStatusBadgeClassAttribute()
default => 'badge-secondary',
};
}
/**
* Scope: Filter by date range
*/
public function scopeDateRange($query, $start, $end)
{
return $query->whereBetween('tanggal', [$start, $end]);
}
/**
* Scope: Filter by month
*/
public function scopeByMonth($query, $month, $year)
{
return $query->whereMonth('tanggal', $month)
->whereYear('tanggal', $year);
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ImportMesinLog extends Model
{
protected $fillable = [
'user_id', 'jumlah_scan', 'berhasil',
'konflik_selesai', 'dilewati', 'no_santri',
];
public function user(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class);
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MesinSantriMapping extends Model
{
protected $fillable = [
'id_mesin', 'id_santri', 'nama_mesin', 'dept_mesin', 'is_active', 'catatan',
];
protected $casts = ['is_active' => 'boolean'];
public function santri(): BelongsTo
{
return $this->belongsTo(Santri::class, 'id_santri', 'id_santri');
}
}

View File

@ -0,0 +1,553 @@
<?php
/**
* EpposGLogParser.php versi 2
* app/Services/EpposGLogParser.php
*
* ═══════════════════════════════════════════════════════════════
* PERUBAHAN UTAMA dari versi 1:
* IOMd TIDAK lagi diabaikan. Setiap slot di shift mesin
* (JK1 Masuk, JK1 Pulang, JK2 Masuk, JK2 Pulang, Lb Masuk, Lb Pulang)
* bisa dipetakan ke kegiatan web yang BERBEDA.
*
* CONTOH MESIN SHOLAT:
* JK1 Masuk (IOMd=2) jam 04:00 Shubuh
* JK1 Pulang (IOMd=4) jam 05:00 Dhuhur
* JK2 Masuk (IOMd=2) jam 11:45 Ashar
* JK2 Pulang (IOMd=4) jam 12:20 Maghrib
* Lb Masuk (IOMd=2) jam 15:05 Isya
*
* CONTOH MESIN NGAJI:
* JK1 Masuk (IOMd=2) jam 05:00 Ngaji Shubuh
* JK1 Pulang (IOMd=4) jam 06:00 sekolah
* JK2 Masuk (IOMd=2) jam 13:00 Ngaji Siang
* JK2 Pulang (IOMd=4) jam 15:00 Ngaji Maghrib
* Lb Masuk (IOMd=2) jam 18:00 Ngaji Malam
*
* ═══════════════════════════════════════════════════════════════
* FORMAT GLOG.TXT (Tab-Separated):
* No | Mchn | EnNo | Name | Mode | IOMd | DateTime
* 000001 | 1 | 000000001 | helga faisa | 1 | 2 | 2026/02/28 04:05:00
*
* IOMd=2 scan MASUK (Check In)
* IOMd=4 scan PULANG (Check Out)
*
* FORMAT INFO.XLS:
* Sheet "Shift" No.Shift | JK1 Msuk | JK1 Kluar | JK2 Msuk | JK2 Kluar | Lb Msuk | Lb Kluar
* Sheet "Jadwal" No | Nama | Departemen | Shift
* ═══════════════════════════════════════════════════════════════
*/
namespace App\Services;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Carbon\Carbon;
class EpposGLogParser
{
// IOMd values dari mesin Eppos
const IOMD_MASUK = 2;
const IOMD_PULANG = 4;
// 6 slot per shift (nama slot → key di array shift)
// Urutan ini penting untuk matching prioritas
const SLOT_KEYS = [
'jk1_msuk', // JK1 Masuk (IOMd=2)
'jk1_kluar', // JK1 Pulang (IOMd=4)
'jk2_msuk', // JK2 Masuk (IOMd=2)
'jk2_kluar', // JK2 Pulang (IOMd=4)
'lb_msuk', // Lembur Masuk (IOMd=2)
'lb_kluar', // Lembur Pulang (IOMd=4)
];
// Masing-masing slot → IOMd yang diharapkan
const SLOT_IOMD = [
'jk1_msuk' => self::IOMD_MASUK,
'jk1_kluar' => self::IOMD_PULANG,
'jk2_msuk' => self::IOMD_MASUK,
'jk2_kluar' => self::IOMD_PULANG,
'lb_msuk' => self::IOMD_MASUK,
'lb_kluar' => self::IOMD_PULANG,
];
// ─────────────────────────────────────────────────────────────
// PARSE INFO.XLS
// ─────────────────────────────────────────────────────────────
/**
* Parse INFO.XLS konfigurasi shift dan daftar santri di mesin
*
* @return array [
* 'shifts' => [
* 1 => [
* 'jk1_msuk' => '04:00',
* 'jk1_kluar' => '05:00',
* 'jk2_msuk' => '11:45',
* 'jk2_kluar' => '12:20',
* 'lb_msuk' => '15:05',
* 'lb_kluar' => null, // null = slot tidak dipakai
* ],
* ],
* 'jadwal' => [
* '1' => ['nama'=>'helga faisa', 'dept'=>'Office', 'shift'=>1],
* ]
* ]
*/
public function parseInfoFile(string $path): array
{
$spreadsheet = IOFactory::load($path);
return [
'shifts' => $this->parseShifts($spreadsheet->getSheetByName('Shift')),
'jadwal' => $this->parseJadwal($spreadsheet->getSheetByName('Jadwal')),
];
}
private function parseShifts($sheet): array
{
$shifts = [];
// Kolom: A=No, B=JK1 Msuk, C=JK1 Kluar, D=JK2 Msuk, E=JK2 Kluar, F=Lb Msuk, G=Lb Kluar
for ($row = 6; $row <= $sheet->getHighestRow(); $row++) {
$no = $sheet->getCell("A{$row}")->getValue();
if (!is_numeric($no)) continue;
$s = [
'jk1_msuk' => $this->readTime($sheet->getCell("B{$row}")->getValue()),
'jk1_kluar' => $this->readTime($sheet->getCell("C{$row}")->getValue()),
'jk2_msuk' => $this->readTime($sheet->getCell("D{$row}")->getValue()),
'jk2_kluar' => $this->readTime($sheet->getCell("E{$row}")->getValue()),
'lb_msuk' => $this->readTime($sheet->getCell("F{$row}")->getValue()),
'lb_kluar' => $this->readTime($sheet->getCell("G{$row}")->getValue()),
];
// Skip shift yang semua slot-nya kosong
$adaIsi = array_filter($s);
if (empty($adaIsi)) continue;
$shifts[(int)$no] = $s;
}
return $shifts;
}
private function parseJadwal($sheet): array
{
$jadwal = [];
for ($row = 3; $row <= $sheet->getHighestRow(); $row++) {
$no = $sheet->getCell("A{$row}")->getValue();
$nama = $sheet->getCell("B{$row}")->getValue();
if (!is_numeric($no) || empty($nama)) continue;
$jadwal[(string)(int)$no] = [
'nama' => trim((string)$nama),
'dept' => trim((string)($sheet->getCell("C{$row}")->getValue() ?? '')),
'shift' => (int)($sheet->getCell("D{$row}")->getValue() ?? 1),
];
}
return $jadwal;
}
// ─────────────────────────────────────────────────────────────
// PARSE GLOG.TXT ← PERUBAHAN UTAMA: simpan IOMd per scan
// ─────────────────────────────────────────────────────────────
/**
* Parse GLog.txt semua record scan, TERMASUK IOMd
*
* @return array [
* [
* 'id_mesin' => '1',
* 'nama_mesin' => 'helga faisa',
* 'tanggal' => '2026-02-28',
* 'jam' => '04:05',
* 'iomd' => 2, // ← BARU: 2=Masuk, 4=Pulang
* 'dt_raw' => '2026/02/28 04:05:00',
* ],
* ]
*/
public function parseGLog(string $path): array
{
$content = file_get_contents($path);
$content = str_replace(["\r\n", "\r"], "\n", $content);
$lines = explode("\n", trim($content));
$records = [];
foreach ($lines as $line) {
$line = trim($line);
if (empty($line)) continue;
$cols = explode("\t", $line);
$cols = array_values(array_filter(array_map('trim', $cols), fn($v) => $v !== ''));
// Minimal 7 kolom: No | Mchn | EnNo | Name | Mode | IOMd | DateTime
if (count($cols) < 7) continue;
if ($cols[0] === 'No') continue; // header
$enno = $cols[2] ?? '';
$namaMesin = $cols[3] ?? '';
$iomdRaw = $cols[5] ?? ''; // kolom ke-6 (index 5)
$dtRaw = $cols[6] ?? '';
if (!is_numeric(ltrim($enno, '0') ?: '0')) continue;
if (empty($dtRaw)) continue;
// IOMd: harus 2 atau 4
$iomd = (int)$iomdRaw;
if (!in_array($iomd, [self::IOMD_MASUK, self::IOMD_PULANG])) continue;
// Parse DateTime
$dtRaw = preg_replace('/\s+/', ' ', trim($dtRaw));
$parts = explode(' ', $dtRaw);
if (count($parts) < 2) continue;
$tglStr = $parts[0]; // "2026/02/28"
$jamStr = substr($parts[1], 0, 5); // "04:05"
if (!preg_match('/^\d{4}\/\d{2}\/\d{2}$/', $tglStr)) continue;
if (!preg_match('/^\d{2}:\d{2}$/', $jamStr)) continue;
$tanggal = str_replace('/', '-', $tglStr);
$idMesin = (string)(int)ltrim($enno, '0') ?: '0';
$records[] = [
'id_mesin' => $idMesin,
'nama_mesin' => trim($namaMesin),
'tanggal' => $tanggal,
'jam' => $jamStr,
'iomd' => $iomd, // ← BARU
'dt_raw' => $dtRaw,
];
}
return $records;
}
// ─────────────────────────────────────────────────────────────
// GROUP BY DAY ← PERUBAHAN: scans sekarang simpan iomd
// ─────────────────────────────────────────────────────────────
/**
* Kelompokkan per (id_mesin + tanggal)
* scans sekarang array of ['jam'=>'04:05','iomd'=>2]
*
* @return array [
* '1_2026-02-28' => [
* 'id_mesin' => '1',
* 'nama_mesin' => 'helga faisa',
* 'tanggal' => '2026-02-28',
* 'scans' => [
* ['jam'=>'04:05','iomd'=>2],
* ['jam'=>'05:10','iomd'=>4],
* ],
* ],
* ]
*/
public function groupGLogByDay(array $records): array
{
$grouped = [];
foreach ($records as $r) {
$key = "{$r['id_mesin']}_{$r['tanggal']}";
if (!isset($grouped[$key])) {
$grouped[$key] = [
'id_mesin' => $r['id_mesin'],
'nama_mesin' => $r['nama_mesin'],
'tanggal' => $r['tanggal'],
'scans' => [],
];
}
// Hindari duplikat jam+iomd yang persis sama
$duplikat = array_filter(
$grouped[$key]['scans'],
fn($s) => $s['jam'] === $r['jam'] && $s['iomd'] === $r['iomd']
);
if (!empty($duplikat)) continue;
$grouped[$key]['scans'][] = [
'jam' => $r['jam'],
'iomd' => $r['iomd'],
];
}
// Sort scan berurutan berdasarkan jam
foreach ($grouped as &$g) {
usort($g['scans'], fn($a, $b) => strcmp($a['jam'], $b['jam']));
}
return $grouped;
}
// ─────────────────────────────────────────────────────────────
// MATCH TO KEGIATAN ← PERUBAHAN UTAMA
// ─────────────────────────────────────────────────────────────
/**
* Cocokkan setiap scan ke kegiatan web.
*
* LOGIKA BARU (pakai IOMd):
* ──────────────────────────────────────────────────────────
* 1. Ambil shift santri dari infoData['jadwal']
* 2. Buat "slot windows" dari shift tersebut:
* Setiap slot (jk1_msuk, jk1_kluar, dst) punya jam + IOMd
* 3. Untuk setiap scan (jam + iomd):
* a. Cari slot yang IOMd-nya cocok DAN jam scan masuk window ±toleransi
* b. Dari slot yang cocok, cari kegiatan web hari ini yang waktunya paling dekat
* 4. Hasilkan baris per kegiatan: Hadir / Terlambat / Alpa
*
* FALLBACK (tanpa IOMd, jika infoData kosong):
* Jika santri tidak ada di infoData (baru daftar, seperti firda),
* cocokkan hanya berdasarkan jam (abaikan IOMd) dengan toleransi lebih sempit.
* ──────────────────────────────────────────────────────────
*
* @param array $glogGrouped Output groupGLogByDay()
* @param array $infoData Output parseInfoFile() ['shifts'=>[...],'jadwal'=>[...]]
* @param array $kegiatans Dari DB: [['kegiatan_id','nama','hari','waktu_mulai','waktu_selesai'],...]
* @param int $tolSebelum Menit toleransi SEBELUM waktu_mulai kegiatan
* @param int $tolSesudah Menit toleransi SESUDAH waktu_selesai kegiatan
*/
public function matchToKegiatan(
array $glogGrouped,
array $infoData,
array $kegiatans,
int $tolSebelum = 15,
int $tolSesudah = 10
): array {
$hasil = [];
foreach ($glogGrouped as $dayData) {
$tanggal = $dayData['tanggal'];
$idMesin = $dayData['id_mesin'];
$scans = $dayData['scans']; // [['jam'=>'04:05','iomd'=>2], ...]
$hari = $this->tanggalToHari($tanggal);
// Kegiatan hari ini dari web
$kegHariIni = array_values(
array_filter($kegiatans, fn($k) => $k['hari'] === $hari)
);
// Info shift santri ini dari INFO.XLS
$jadwalInfo = $infoData['jadwal'][$idMesin] ?? null;
$nomorShift = $jadwalInfo ? ($jadwalInfo['shift'] ?? 1) : null;
$shiftData = ($nomorShift && isset($infoData['shifts'][$nomorShift]))
? $infoData['shifts'][$nomorShift]
: null;
// Build slot windows dari shift santri
// slotWindows: [ ['slot'=>'jk1_msuk','jam'=>'04:00','iomd'=>2], ... ]
$slotWindows = $shiftData
? $this->buildSlotWindows($shiftData)
: [];
// ── Matching ────────────────────────────────────────────
$matchedKg = []; // kegiatan_id → true (sudah dapat scan)
$usedScans = []; // index scan yang sudah dipakai
$rowMap = []; // kegiatan_id → result row
foreach ($scans as $idx => $scan) {
$scanJam = $scan['jam'];
$scanIomd = $scan['iomd'];
$scanMnt = $this->toMinutes($scanJam);
$bestKg = null;
$bestSelisih = PHP_INT_MAX;
$bestSlot = null;
if (!empty($slotWindows)) {
// ── MODE UTAMA: pakai IOMd dari shift ──────────────
// Langkah 1: cari slot yang IOMd-nya cocok DAN jam dalam window
foreach ($slotWindows as $sw) {
if ($sw['iomd'] !== $scanIomd) continue; // IOMd harus cocok
if ($sw['jam'] === null) continue; // slot tidak diset
$slotMnt = $this->toMinutes($sw['jam']);
$windowMulai = $slotMnt - $tolSebelum;
$windowAkhir = $slotMnt + $tolSesudah;
if ($scanMnt < $windowMulai || $scanMnt > $windowAkhir) continue;
// Slot cocok — sekarang cari kegiatan web yang paling dekat
foreach ($kegHariIni as $kg) {
if (isset($matchedKg[$kg['kegiatan_id']])) continue;
$kgMulaiMnt = $this->toMinutes($kg['waktu_mulai']);
$kgSelesaiMnt = $this->toMinutes($kg['waktu_selesai'] ?: $kg['waktu_mulai']);
$kgWindowMul = $kgMulaiMnt - $tolSebelum;
$kgWindowAkh = $kgSelesaiMnt + $tolSesudah;
// Jam slot harus masuk window kegiatan
if ($slotMnt < $kgWindowMul || $slotMnt > $kgWindowAkh) continue;
$selisih = abs($slotMnt - $kgMulaiMnt);
if ($selisih < $bestSelisih) {
$bestSelisih = $selisih;
$bestKg = $kg;
$bestSlot = $sw;
}
}
}
} else {
// ── FALLBACK: shifts kosong, matching hanya berdasarkan jam ──
// Pakai toleransi penuh (bukan dikurangi)
// Cari kegiatan yang paling dekat jamnya dengan scan
foreach ($kegHariIni as $kg) {
if (isset($matchedKg[$kg['kegiatan_id']])) continue;
$kgMulaiMnt = $this->toMinutes($kg['waktu_mulai']);
$kgSelesaiMnt = $this->toMinutes($kg['waktu_selesai'] ?: $kg['waktu_mulai']);
// Window: tolSebelum menit sebelum mulai s/d tolSesudah menit setelah selesai
$kgWindowMul = $kgMulaiMnt - $tolSebelum;
$kgWindowAkh = $kgSelesaiMnt + $tolSesudah;
if ($scanMnt < $kgWindowMul || $scanMnt > $kgWindowAkh) continue;
$selisih = abs($scanMnt - $kgMulaiMnt);
if ($selisih < $bestSelisih) {
$bestSelisih = $selisih;
$bestKg = $kg;
}
}
}
// ── Simpan hasil match ────────────────────────────
if ($bestKg) {
$kgMulaiMnt = $this->toMinutes($bestKg['waktu_mulai']);
// Grace period: scan sampai 5 menit setelah mulai → masih Hadir
// Lebih dari 5 menit → Terlambat
$graceMnt = 5;
$selisih = $scanMnt - $kgMulaiMnt;
$status = $selisih <= $graceMnt ? 'Hadir' : 'Terlambat';
$matchedKg[$bestKg['kegiatan_id']] = true;
$usedScans[] = $idx;
$rowMap[$bestKg['kegiatan_id']] = [
'kegiatan_id' => $bestKg['kegiatan_id'],
'nama_kegiatan' => $bestKg['nama'],
'waktu_mulai' => $bestKg['waktu_mulai'],
'jam_scan' => $scanJam,
'iomd_scan' => $scanIomd,
'label_iomd' => $scanIomd === self::IOMD_MASUK ? 'Masuk' : 'Pulang',
'status' => $status,
'selisih_menit' => max(0, $selisih - $graceMnt), // hanya menit yg melebihi grace
'matched' => true,
];
}
}
// ── Isi Alpa untuk kegiatan tanpa scan ───────────────
foreach ($kegHariIni as $kg) {
if (!isset($rowMap[$kg['kegiatan_id']])) {
$rowMap[$kg['kegiatan_id']] = [
'kegiatan_id' => $kg['kegiatan_id'],
'nama_kegiatan' => $kg['nama'],
'waktu_mulai' => $kg['waktu_mulai'],
'jam_scan' => null,
'iomd_scan' => null,
'label_iomd' => null,
'status' => 'Alpa',
'selisih_menit' => null,
'matched' => false,
];
}
}
// Scan yang tidak cocok ke kegiatan apapun
$unmatchedScans = [];
foreach ($scans as $idx => $scan) {
if (!in_array($idx, $usedScans)) {
$unmatchedScans[] = $scan['jam'] . ' (' . ($scan['iomd'] === 2 ? 'Masuk' : 'Pulang') . ')';
}
}
$rows = collect($rowMap)->sortBy('waktu_mulai')->values()->toArray();
$hasil[] = [
'id_mesin' => $idMesin,
'nama_mesin' => $dayData['nama_mesin'],
'tanggal' => $tanggal,
'hari' => $hari,
'all_scans' => $scans,
'unmatched_scans' => $unmatchedScans,
'shift_dipakai' => $nomorShift, // ← BARU: untuk debug di preview
'rows' => $rows,
];
}
return $hasil;
}
// ─────────────────────────────────────────────────────────────
// BUILD SLOT WINDOWS dari data shift
// ─────────────────────────────────────────────────────────────
/**
* Dari satu shift, buat array slot windows yang bisa dicocokkan dengan scan.
*
* @param array $shiftData ['jk1_msuk'=>'04:00','jk1_kluar'=>'05:00', ...]
* @return array [
* ['slot'=>'jk1_msuk', 'jam'=>'04:00', 'iomd'=>2],
* ['slot'=>'jk1_kluar', 'jam'=>'05:00', 'iomd'=>4],
* ['slot'=>'jk2_msuk', 'jam'=>'11:45', 'iomd'=>2],
* ['slot'=>'jk2_kluar', 'jam'=>'12:20', 'iomd'=>4],
* ['slot'=>'lb_msuk', 'jam'=>'15:05', 'iomd'=>2],
* ['slot'=>'lb_kluar', 'jam'=>null, 'iomd'=>4], // null = tidak dipakai
* ]
*/
private function buildSlotWindows(array $shiftData): array
{
$windows = [];
foreach (self::SLOT_KEYS as $slotKey) {
$windows[] = [
'slot' => $slotKey,
'jam' => $shiftData[$slotKey] ?? null,
'iomd' => self::SLOT_IOMD[$slotKey],
];
}
return $windows;
}
// ─────────────────────────────────────────────────────────────
// HELPERS
// ─────────────────────────────────────────────────────────────
private function toMinutes(string $hhmm): int
{
if (!str_contains($hhmm, ':')) return 0;
[$h, $m] = explode(':', $hhmm);
return (int)$h * 60 + (int)$m;
}
public function tanggalToHari(string $tanggal): string
{
return [
'Monday' => 'Senin',
'Tuesday' => 'Selasa',
'Wednesday' => 'Rabu',
'Thursday' => 'Kamis',
'Friday' => 'Jumat',
'Saturday' => 'Sabtu',
'Sunday' => 'Ahad',
][Carbon::parse($tanggal)->format('l')] ?? 'Senin';
}
/**
* Baca nilai jam dari Excel bisa berupa string "05:00" atau float (serial Excel)
*/
private function readTime($val): ?string
{
if ($val === null || $val === '') return null;
if (is_float($val) || (is_string($val) && is_numeric($val) && str_contains($val, '.'))) {
$totalMin = round((float)$val * 24 * 60);
return sprintf('%02d:%02d', intdiv($totalMin, 60), $totalMin % 60);
}
$str = preg_replace('/\s+/', '', trim((string)$val));
if (preg_match('/^(\d{1,2}):(\d{2})$/', $str, $m)) {
return sprintf('%02d:%02d', (int)$m[1], (int)$m[2]);
}
return null;
}
}

View File

@ -12,7 +12,8 @@
"laravel/framework": "^10.10",
"laravel/sanctum": "^3.3",
"laravel/tinker": "^2.8",
"mpdf/mpdf": "^8.2"
"mpdf/mpdf": "^8.2",
"phpoffice/phpspreadsheet": "^5.5"
},
"require-dev": {
"fakerphp/faker": "^1.9.1",

375
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": "a470717879bee7fca3c22f312f8d5be7",
"content-hash": "3a9939a01eba9d3a8fd51aa2d2dfb692",
"packages": [
{
"name": "barryvdh/laravel-dompdf",
@ -212,6 +212,85 @@
],
"time": "2023-12-11T17:09:12+00:00"
},
{
"name": "composer/pcre",
"version": "3.3.2",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<1.11.10"
},
"require-dev": {
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-strict-rules": "^1 || ^2",
"phpunit/phpunit": "^8 || ^9"
},
"type": "library",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Pcre\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
"keywords": [
"PCRE",
"preg",
"regex",
"regular expression"
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.3.2"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
"type": "tidelift"
}
],
"time": "2024-11-12T16:29:46+00:00"
},
{
"name": "dflydev/dot-access-data",
"version": "v3.0.3",
@ -2374,6 +2453,191 @@
],
"time": "2024-09-21T08:32:55+00:00"
},
{
"name": "maennchen/zipstream-php",
"version": "3.1.2",
"source": {
"type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git",
"reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/aeadcf5c412332eb426c0f9b4485f6accba2a99f",
"reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"ext-zlib": "*",
"php-64bit": "^8.2"
},
"require-dev": {
"brianium/paratest": "^7.7",
"ext-zip": "*",
"friendsofphp/php-cs-fixer": "^3.16",
"guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
"phpunit/phpunit": "^11.0",
"vimeo/psalm": "^6.0"
},
"suggest": {
"guzzlehttp/psr7": "^2.4",
"psr/http-message": "^2.0"
},
"type": "library",
"autoload": {
"psr-4": {
"ZipStream\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paul Duncan",
"email": "pabs@pablotron.org"
},
{
"name": "Jonatan Männchen",
"email": "jonatan@maennchen.ch"
},
{
"name": "Jesse Donat",
"email": "donatj@gmail.com"
},
{
"name": "András Kolesár",
"email": "kolesar@kolesar.hu"
}
],
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
"keywords": [
"stream",
"zip"
],
"support": {
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.2"
},
"funding": [
{
"url": "https://github.com/maennchen",
"type": "github"
}
],
"time": "2025-01-27T12:07:53+00:00"
},
{
"name": "markbaker/complex",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPComplex.git",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Complex\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@lange.demon.co.uk"
}
],
"description": "PHP Class for working with complex numbers",
"homepage": "https://github.com/MarkBaker/PHPComplex",
"keywords": [
"complex",
"mathematics"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
},
"time": "2022-12-06T16:21:08+00:00"
},
{
"name": "markbaker/matrix",
"version": "3.0.1",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPMatrix.git",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpdocumentor/phpdocumentor": "2.*",
"phploc/phploc": "^4.0",
"phpmd/phpmd": "2.*",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"sebastian/phpcpd": "^4.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Matrix\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@demon-angel.eu"
}
],
"description": "PHP Class for working with matrices",
"homepage": "https://github.com/MarkBaker/PHPMatrix",
"keywords": [
"mathematics",
"matrix",
"vector"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
},
"time": "2022-12-02T22:17:43+00:00"
},
{
"name": "masterminds/html5",
"version": "2.10.0",
@ -3228,6 +3492,115 @@
},
"time": "2020-10-15T08:29:30+00:00"
},
{
"name": "phpoffice/phpspreadsheet",
"version": "5.5.0",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "eecd31b885a1c8192f12738130f85bbc6e8906ba"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/eecd31b885a1c8192f12738130f85bbc6e8906ba",
"reference": "eecd31b885a1c8192f12738130f85bbc6e8906ba",
"shasum": ""
},
"require": {
"composer/pcre": "^1||^2||^3",
"ext-ctype": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
"ext-filter": "*",
"ext-gd": "*",
"ext-iconv": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-simplexml": "*",
"ext-xml": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"ext-zip": "*",
"ext-zlib": "*",
"maennchen/zipstream-php": "^2.1 || ^3.0",
"markbaker/complex": "^3.0",
"markbaker/matrix": "^3.0",
"php": "^8.1",
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
"dompdf/dompdf": "^2.0 || ^3.0",
"ext-intl": "*",
"friendsofphp/php-cs-fixer": "^3.2",
"mitoteam/jpgraph": "^10.5",
"mpdf/mpdf": "^8.1.1",
"phpcompatibility/php-compatibility": "^9.3",
"phpstan/phpstan": "^1.1 || ^2.0",
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
"phpunit/phpunit": "^10.5",
"squizlabs/php_codesniffer": "^3.7",
"tecnickcom/tcpdf": "^6.5"
},
"suggest": {
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
"ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
},
"type": "library",
"autoload": {
"psr-4": {
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Maarten Balliauw",
"homepage": "https://blog.maartenballiauw.be"
},
{
"name": "Mark Baker",
"homepage": "https://markbakeruk.net"
},
{
"name": "Franck Lefevre",
"homepage": "https://rootslabs.net"
},
{
"name": "Erik Tilt"
},
{
"name": "Adrien Crivelli"
},
{
"name": "Owen Leibman"
}
],
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
"keywords": [
"OpenXML",
"excel",
"gnumeric",
"ods",
"php",
"spreadsheet",
"xls",
"xlsx"
],
"support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.5.0"
},
"time": "2026-03-01T00:58:56+00:00"
},
{
"name": "phpoption/phpoption",
"version": "1.9.4",

View File

@ -14,6 +14,7 @@
| To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
*/
'paths' => ['api/*', 'storage/*'],
'paths' => ['api/*', 'sanctum/csrf-cookie'],

View File

@ -15,7 +15,7 @@ public function up(): void
$table->string('id_santri', 10);
$table->date('tanggal');
$table->enum('status', ['Hadir', 'Izin', 'Sakit', 'Alpa']);
$table->enum('metode_absen', ['Manual', 'RFID'])->default('Manual');
$table->string('metode_absen', 50)->default('Manual');
$table->time('waktu_absen')->nullable();
$table->timestamps();

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('absensi_kegiatans', function (Blueprint $table) {
if (!Schema::hasColumn('absensi_kegiatans', 'metode_absen')) {
$table->string('metode_absen')->default('Manual')->after('status');
// Nilai: 'Manual' | 'RFID' | 'Import_Mesin'
}
if (!Schema::hasColumn('absensi_kegiatans', 'konflik_catatan')) {
$table->string('konflik_catatan')->nullable()->after('metode_absen');
}
});
}
public function down(): void
{
Schema::table('absensi_kegiatans', function (Blueprint $table) {
$table->dropColumnIfExists('metode_absen');
$table->dropColumnIfExists('konflik_catatan');
});
}
};

View File

@ -0,0 +1,28 @@
<?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('mesin_santri_mappings', function (Blueprint $table) {
$table->id();
$table->string('id_mesin')->unique(); // EnNo dari GLog: '1','2','8'
$table->string('id_santri')->nullable();
$table->string('nama_mesin')->nullable();
$table->string('dept_mesin')->nullable();
$table->boolean('is_active')->default(true);
$table->text('catatan')->nullable();
$table->timestamps();
$table->index('id_santri');
$table->index('is_active');
});
}
public function down(): void
{
Schema::dropIfExists('mesin_santri_mappings');
}
};

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('import_mesin_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->integer('jumlah_scan')->default(0);
$table->integer('berhasil')->default(0);
$table->integer('konflik_selesai')->default(0);
$table->integer('dilewati')->default(0);
$table->integer('no_santri')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('import_mesin_logs');
}
};

View File

@ -142,6 +142,7 @@ @keyframes spin {
.app-wrapper {
display: flex;
min-height: 100vh;
align-items: flex-start;
}
/* ===================================
@ -154,10 +155,18 @@ .sidebar {
transition: var(--transition-base);
flex-shrink: 0;
box-shadow: 4px 0 15px rgba(111, 186, 157, 0.15);
position: relative;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
overflow-x: hidden;
z-index: 100;
}
/* Hide sidebar scrollbar but keep it functional */
.sidebar::-webkit-scrollbar { width: 0; }
.sidebar { scrollbar-width: none; }
.sidebar-header {
padding: 16px 12px;
text-align: center;
@ -314,6 +323,136 @@ .sidebar-toggle-btn-mobile {
z-index: 1001;
}
/* ===== SIDEBAR BRAND ===== */
.sidebar-header {
position: relative;
}
.sidebar-brand {
display: flex;
flex-direction: column;
align-items: center;
padding: 14px 10px 12px;
text-align: center;
}
/* Icon wrapper dengan ring animasi */
.sidebar-brand-icon-wrapper {
position: relative;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
}
.sidebar-brand-icon-wrapper::before {
content: '';
position: absolute;
inset: -6px;
border-radius: 50%;
border: 1.5px solid rgba(255, 255, 255, 0.3);
animation: brand-pulse 3s ease-in-out infinite;
}
.sidebar-brand-icon-wrapper::after {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background: linear-gradient(135deg, rgba(255,255,255,0.1), rgba(255,255,255,0.03));
border: 1px solid rgba(255,255,255,0.2);
}
@keyframes brand-pulse {
0%, 100% { transform: scale(1); opacity: 0.4; }
50% { transform: scale(1.12); opacity: 1; }
}
.sidebar-brand-icon {
position: relative;
z-index: 1;
font-size: 1.5rem;
color: #FFFFFF;
filter: drop-shadow(0 1px 4px rgba(0,0,0,0.15));
animation: brand-float 4s ease-in-out infinite;
}
@keyframes brand-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
}
/* Teks nama utama */
.sidebar-brand-name {
display: block;
font-family: 'Cinzel', serif;
font-weight: 700;
font-size: 0.82rem;
letter-spacing: 0.05em;
line-height: 1.3;
color: #FFFFFF;
}
/* Sub-label PKPPS */
.sidebar-brand-sub {
display: block;
font-family: 'Cinzel', serif;
font-weight: 600;
font-size: 0.55rem;
letter-spacing: 0.35em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.7);
margin-top: 2px;
}
/* Ornamen garis + titik */
.sidebar-brand-ornament {
display: flex;
align-items: center;
gap: 5px;
margin-top: 6px;
}
.ornament-line {
width: 22px;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.45));
}
.ornament-line:last-child {
background: linear-gradient(90deg, rgba(255,255,255,0.45), transparent);
}
.ornament-dot {
width: 3px;
height: 3px;
border-radius: 50%;
background: #FFFFFF;
box-shadow: 0 0 4px rgba(255,255,255,0.6);
}
/* ===== COLLAPSED (desktop) — teks hilang, icon saja ===== */
.sidebar.collapsed .sidebar-brand-text,
.sidebar.collapsed .sidebar-brand-ornament {
display: none;
}
.sidebar.collapsed .sidebar-brand {
padding: 12px 6px;
}
.sidebar.collapsed .sidebar-brand-icon-wrapper {
width: 32px;
height: 32px;
margin-bottom: 0;
}
.sidebar.collapsed .sidebar-brand-icon {
font-size: 1.1rem;
}
/* ===================================
6. MAIN CONTENT
=================================== */
@ -358,6 +497,8 @@ .sidebar-toggle-btn:hover {
.main-content {
padding: clamp(10px, 1.2vw, 16px);
flex-grow: 1;
transition: opacity 0.3s ease-in;
opacity: 1;
}
/* ===================================
@ -493,6 +634,43 @@ .content-box {
border: 1px solid var(--primary-light);
}
/* ===================================
RESPONSIVE GRID UTILITY CLASSES
Replaces inline grid-template-columns styles
=================================== */
.grid-2col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.grid-main-sidebar {
display: grid;
grid-template-columns: 1fr 280px;
gap: 18px;
align-items: start;
}
.grid-main-sidebar-lg {
display: grid;
grid-template-columns: 1fr 340px;
gap: 18px;
align-items: start;
}
.grid-auto-fill {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.grid-content-auto {
display: grid;
grid-template-columns: 1fr auto;
gap: 14px;
align-items: start;
}
/* ===================================
10. ALERTS
=================================== */
@ -873,6 +1051,17 @@ .table-container {
border: 1px solid var(--primary-light);
}
/* Table Wrapper - Horizontal scroll on mobile */
.table-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
margin-bottom: 16px;
}
.table-wrapper .data-table {
min-width: 600px;
}
/* ===================================
14. DETAIL SECTIONS
=================================== */
@ -1461,20 +1650,74 @@ @media (max-width: 768px) {
padding: 10px;
}
/* Sidebar Brand — mobile: hide text, show icon only */
.sidebar-brand-text,
.sidebar-brand-ornament {
display: none;
}
.sidebar-brand {
padding: 10px 6px;
}
.sidebar-brand-icon-wrapper {
width: 32px;
height: 32px;
margin-bottom: 0;
}
.sidebar-brand-icon {
font-size: 1.1rem;
}
/* Tapi kalau sidebar mobile-active (terbuka penuh), tampilkan teks */
.sidebar.mobile-active .sidebar-brand-text,
.sidebar.mobile-active .sidebar-brand-ornament {
display: flex;
}
.sidebar.mobile-active .sidebar-brand-text {
display: block;
}
.sidebar.mobile-active .sidebar-brand {
padding: 14px 10px 12px;
}
.sidebar.mobile-active .sidebar-brand-icon-wrapper {
width: 44px;
height: 44px;
margin-bottom: 8px;
}
.sidebar.mobile-active .sidebar-brand-icon {
font-size: 1.5rem;
}
.page-header h2 {
font-size: clamp(0.9rem, 1.5vw, 1.1rem);
}
/* Cards */
/* Cards - 2 columns on tablet */
.row-cards {
grid-template-columns: 1fr;
gap: 16px;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.card-value {
font-size: 1.3rem;
}
/* Responsive grid utilities */
.grid-main-sidebar,
.grid-main-sidebar-lg {
grid-template-columns: 1fr;
}
.grid-content-auto {
grid-template-columns: 1fr;
}
/* Tables */
.data-table {
font-size: 0.78rem;
@ -1681,12 +1924,6 @@ @media (max-width: 480px) {
padding: 5px 4px;
}
/* Hide less important columns */
.data-table th:nth-child(2),
.data-table td:nth-child(2) {
display: none;
}
/* Detail Header */
.detail-header {
flex-direction: column;
@ -1725,6 +1962,30 @@ @media (max-width: 480px) {
.kelas-grid {
grid-template-columns: 1fr;
}
/* Row Cards - 2 columns on small mobile */
.row-cards {
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.row-cards-5 {
grid-template-columns: repeat(2, 1fr);
}
.kpi-grid-kegiatan {
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
/* Responsive grid utilities - stack on small mobile */
.grid-2col {
grid-template-columns: 1fr;
}
.grid-auto-fill {
grid-template-columns: repeat(2, 1fr);
}
}
/* ===================================
@ -1796,9 +2057,9 @@ @media (max-width: 768px) {
max-height: 200px !important;
}
/* Cards Statistik */
/* Cards Statistik - 2 columns */
.row-cards {
grid-template-columns: 1fr;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
@ -1825,14 +2086,14 @@ @media (max-width: 768px) {
}
@media (max-width: 480px) {
/* Quick Links Grid - Full Width */
/* Quick Links Grid - 2 columns */
.content-box > div[style*="grid"] {
grid-template-columns: 1fr !important;
grid-template-columns: repeat(2, 1fr) !important;
}
/* Card Values */
.card-value {
font-size: 1.3rem !important;
font-size: 1.3rem;
}
/* Page Header */
@ -1871,11 +2132,11 @@ @media (max-width: 768px) {
@media (max-width: 480px) {
.row-cards {
grid-template-columns: 1fr !important;
grid-template-columns: repeat(2, 1fr);
}
.card-value {
font-size: 1.3rem !important;
font-size: 1.3rem;
}
}
@ -2073,7 +2334,7 @@ @media (max-width: 768px) {
@media (max-width: 480px) {
.row-cards-5 {
grid-template-columns: 1fr;
grid-template-columns: repeat(2, 1fr);
}
.spp-summary {
grid-template-columns: 1fr;
@ -2536,7 +2797,7 @@ @media (max-width: 768px) {
}
@media (max-width: 480px) {
.kpi-grid-kegiatan { grid-template-columns: 1fr; }
.kpi-grid-kegiatan { grid-template-columns: repeat(2, 1fr); gap: 10px; }
}
/* ===================================

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -216,6 +216,91 @@
border-color:#6FBA9D; background:#fff; box-shadow:0 0 0 3px rgba(111,186,157,.1);
}
/* ── Demo Account Button ── */
.lg-demo-btn {
display:flex; align-items:center; justify-content:center; gap:8px;
margin-top:10px; padding:9px;
background:transparent; border:1.5px dashed #A8D8C6; border-radius:10px;
font-size:.76rem; font-weight:600; color:#8AADA0; cursor:pointer;
font-family:inherit; width:100%;
transition:all .2s;
}
.lg-demo-btn:hover { border-color:#6FBA9D; color:#3D8A6E; background:#EBF7F2; }
/* ── Modal Overlay ── */
.lg-modal-overlay {
position:fixed; inset:0; z-index:9999;
background:rgba(15,33,24,.45); backdrop-filter:blur(4px);
display:flex; align-items:center; justify-content:center;
opacity:0; pointer-events:none; transition:opacity .25s ease;
}
.lg-modal-overlay.open { opacity:1; pointer-events:all; }
/* ── Modal Box ── */
.lg-modal {
background:#fff; border-radius:20px; padding:32px 28px;
width:100%; max-width:380px; position:relative;
box-shadow:0 20px 60px rgba(15,33,24,.18);
transform:translateY(16px) scale(.97); transition:transform .25s ease;
}
.lg-modal-overlay.open .lg-modal { transform:translateY(0) scale(1); }
.lg-modal::before {
content:''; position:absolute; top:0; left:0; right:0; height:3px;
background:linear-gradient(90deg,#6FBA9D,#A8D8C6,#6FBA9D);
border-radius:20px 20px 0 0;
}
.lg-modal-close {
position:absolute; top:14px; right:16px;
background:none; border:none; font-size:1.1rem;
color:#B8D4C8; cursor:pointer; line-height:1;
transition:color .2s;
}
.lg-modal-close:hover { color:#3D8A6E; }
.lg-modal-ico {
width:44px; height:44px; border-radius:12px; background:#EBF7F2;
display:flex; align-items:center; justify-content:center;
color:#3D8A6E; font-size:1rem; margin-bottom:14px;
}
.lg-modal-title {
font-family:'DM Serif Display',serif;
font-size:1.35rem; color:#0F2118; margin-bottom:4px;
}
.lg-modal-sub { font-size:.75rem; color:#8AADA0; margin-bottom:20px; line-height:1.5; }
.lg-demo-table { width:100%; border-collapse:collapse; }
.lg-demo-table thead tr th {
font-size:.64rem; font-weight:700; letter-spacing:1.2px;
text-transform:uppercase; color:#8AADA0;
padding:0 10px 8px; text-align:left;
border-bottom:1px solid #EBF7F2;
}
.lg-demo-table tbody tr { transition:background .15s; }
.lg-demo-table tbody tr:hover { background:#F4FCF8; }
.lg-demo-table tbody tr td {
padding:10px 10px; font-size:.78rem; color:#2A4235;
border-bottom:1px solid #F0FAF5; vertical-align:middle;
}
.lg-demo-table tbody tr:last-child td { border-bottom:none; }
.lg-role-badge {
display:inline-block; padding:2px 8px;
border-radius:20px; font-size:.66rem; font-weight:700;
letter-spacing:.5px;
}
.badge-super { background:#FFF3E0; color:#E65100; }
.badge-akademik { background:#E3F2FD; color:#1565C0; }
.badge-pamong { background:#F3E5F5; color:#6A1B9A; }
.badge-santri { background:#E8F5E9; color:#2E7D32; }
.lg-copy-btn {
background:none; border:none; color:#A8D8C6; cursor:pointer;
font-size:.72rem; padding:2px 4px; border-radius:4px;
transition:color .2s;
}
.lg-copy-btn:hover { color:#3D8A6E; }
.lg-modal-note {
margin-top:16px; padding:10px 12px;
background:#FFFBF0; border-left:3px solid #FFD54F;
border-radius:8px; font-size:.72rem; color:#795548; line-height:1.6;
}
/* Responsive */
@media (max-width: 900px) {
.lg-layout { gap:48px; padding:32px 36px; }
@ -358,6 +443,12 @@
<a href="{{ route('santri.login') }}" class="lg-santri-link">
<i class="fas fa-user-graduate"></i> Login sebagai Santri / Wali
</a>
{{-- ── Tombol Akun Demo (tambahan) ── --}}
<button type="button" class="lg-demo-btn" id="lgDemoBtn">
<i class="fas fa-info-circle"></i> Lihat Akun Demo untuk Pengujian
</button>
</form>
</div>
</div>
@ -365,6 +456,76 @@
</div>
</div>
{{-- ── Modal Akun Demo ── --}}
<div class="lg-modal-overlay" id="lgDemoOverlay">
<div class="lg-modal">
<button class="lg-modal-close" id="lgModalClose" aria-label="Tutup">
<i class="fas fa-times"></i>
</button>
<div class="lg-modal-ico"><i class="fas fa-users"></i></div>
<div class="lg-modal-title">Akun Demo</div>
<table class="lg-demo-table">
<thead>
<tr>
<th>Role</th>
<th>Email / Username</th>
<th>Password</th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="lg-role-badge badge-super">Superadmin</span></td>
<td style="font-size:.72rem;">helga.faisa06@gmail.com</td>
<td style="font-size:.72rem;">Admin123_</td>
<td>
<button class="lg-copy-btn" onclick="lgCopy('helga.faisa06@gmail.com','Admin123_')" title="Isi otomatis">
<i class="fas fa-arrow-right"></i>
</button>
</td>
</tr>
<tr>
<td><span class="lg-role-badge badge-akademik">Akademik</span></td>
<td style="font-size:.72rem;">akademik@test.com</td>
<td style="font-size:.72rem;">password123</td>
<td>
<button class="lg-copy-btn" onclick="lgCopy('akademik@test.com','password123')" title="Isi otomatis">
<i class="fas fa-arrow-right"></i>
</button>
</td>
</tr>
<tr>
<td><span class="lg-role-badge badge-pamong">Pamong</span></td>
<td style="font-size:.72rem;">pamong@test.com</td>
<td style="font-size:.72rem;">password123</td>
<td>
<button class="lg-copy-btn" onclick="lgCopy('pamong@test.com','password123')" title="Isi otomatis">
<i class="fas fa-arrow-right"></i>
</button>
</td>
</tr>
<tr>
<td><span class="lg-role-badge badge-santri">Santri</span></td>
<td style="font-size:.72rem;">Helga Faisa</td>
<td style="font-size:.72rem;">s001</td>
<td>
<button class="lg-copy-btn" onclick="lgCopy('Helga Faisa','s001')" title="Isi otomatis">
<i class="fas fa-arrow-right"></i>
</button>
</td>
</tr>
</tbody>
</table>
<div class="lg-modal-note">
<i class="fas fa-info-circle"></i>
Klik <i class="fas fa-arrow-right"></i> untuk mengisi form login otomatis.
Akun santri dapat digunakan di halaman login santri.
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Toggle password
@ -420,6 +581,33 @@
if (e.key === 'Enter') { e.preventDefault(); p.focus(); }
});
}
// ── Demo Modal ──
const demoBtn = document.getElementById('lgDemoBtn');
const overlay = document.getElementById('lgDemoOverlay');
const modalClose = document.getElementById('lgModalClose');
function openModal() { overlay.classList.add('open'); }
function closeModal() { overlay.classList.remove('open'); }
if (demoBtn) demoBtn.addEventListener('click', openModal);
if (modalClose) modalClose.addEventListener('click', closeModal);
if (overlay) overlay.addEventListener('click', function(e) {
if (e.target === overlay) closeModal();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeModal();
});
});
// Auto-fill form dari modal
function lgCopy(email, pass) {
const u = document.getElementById('username');
const p = document.getElementById('password');
if (u) u.value = email;
if (p) p.value = pass;
document.getElementById('lgDemoOverlay').classList.remove('open');
if (u) u.focus();
}
</script>
@endsection

View File

@ -1,4 +1,4 @@
@extends('layouts.app')
@extends('layouts.app')
@section('title', 'Daftar Berita')
@ -68,6 +68,7 @@ class="form-control"
<!-- Tabel Berita -->
<div class="content-box">
@if($berita->count() > 0)
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -142,6 +143,7 @@ class="btn btn-warning btn-sm"
@endforeach
</tbody>
</table>
</div>
<!-- Pagination -->
<div style="margin-top: 14px; display: flex; justify-content: center;">

View File

@ -1,4 +1,4 @@
@extends('layouts.app')
@extends('layouts.app')
@section('content')
<div class="page-header">
@ -34,7 +34,7 @@
<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>
@ -88,6 +88,7 @@
</h4>
@if($capaians->count() > 0)
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -151,6 +152,7 @@ class="btn btn-sm btn-warning" title="Edit">
@endforeach
</tbody>
</table>
</div>
@else
<div class="empty-state">
<i class="fas fa-clipboard-list"></i>

View File

@ -1,4 +1,4 @@
@extends('layouts.app')
@extends('layouts.app')
@section('content')
<div class="page-header">
@ -92,6 +92,7 @@
@endif
@if($santriData->count() > 0)
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -150,6 +151,7 @@ class="btn btn-sm btn-primary" title="Lihat Detail Capaian">
@endforeach
</tbody>
</table>
</div>
@else
<div class="empty-state">
<i class="fas fa-inbox"></i>

View File

@ -1,4 +1,4 @@
@extends('layouts.app')
@extends('layouts.app')
@section('content')
<div class="page-header">
@ -106,6 +106,8 @@
Kategori: {{ $kategori }}
</h4>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -157,6 +159,8 @@ class="btn btn-sm btn-warning" title="Edit">
@endforeach
</tbody>
</table>
</div>
</div>
@endif
@endforeach

View File

@ -1,10 +1,10 @@
{{-- resources/views/admin/dashboard/_jadwal-kegiatan.blade.php --}}
{{-- 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 }}
Jadwal Kegiatan {{ $hari }}
</h4>
@if($kegiatan->isEmpty())
@ -14,6 +14,7 @@
</div>
@else
<div class="table-responsive" style="overflow-x:auto;">
<div class="table-wrapper">
<table class="data-table" style="margin-top:0;">
<thead>
<tr>
@ -40,7 +41,7 @@
</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>
<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>
@ -68,7 +69,7 @@
<span style="color:#bbb;">({{ $k->total_absensi }} data)</span>
</small>
@else
<small class="text-muted"></small>
<small class="text-muted"></small>
@endif
</td>
</tr>
@ -76,5 +77,6 @@
</tbody>
</table>
</div>
</div>
@endif
</div>

View File

@ -1,4 +1,4 @@
@extends('layouts.app')
@extends('layouts.app')
@section('title', 'Master Pelanggaran')
@ -80,6 +80,7 @@
</div>
@if($data->isNotEmpty())
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -160,6 +161,7 @@ class="btn btn-sm btn-danger"
@endforeach
</tbody>
</table>
</div>
@else
<div class="empty-state">
<i class="fas fa-folder-open"></i>

View File

@ -1,4 +1,4 @@
@extends('layouts.app')
@extends('layouts.app')
@section('title', 'Detail Pelanggaran')
@ -75,6 +75,7 @@
<h3 style="color: var(--primary-color); margin-bottom: 15px;">
<i class="fas fa-history"></i> Riwayat Penggunaan (5 Terbaru)
</h3>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -112,6 +113,7 @@
@endforeach
</tbody>
</table>
</div>
@endif
<div class="btn-group" style="margin-top: 22px;">

View File

@ -33,9 +33,10 @@
@if($kegiatanInfo['is_umum'])
<strong>Kegiatan Umum</strong> - Diikuti oleh semua santri aktif ({{ $santris->count() }} santri)
@else
<strong>Kegiatan Khusus</strong> - Diikuti oleh kelas:
<strong>Kegiatan Khusus</strong> - Untuk kelas:
<strong style="color: var(--primary-color);">{{ $kegiatanInfo['kelas_list'] }}</strong>
({{ $kegiatanInfo['jumlah_kelas'] }} kelas, {{ $santris->count() }} santri)
({{ $kegiatanInfo['jumlah_kelas'] }} kelas)
&nbsp;|&nbsp; Total semua santri aktif: <strong>{{ $santris->count() }} santri</strong>
@endif
</div>
@ -46,7 +47,9 @@
@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).
<strong>Mode Edit</strong> - Data absensi untuk tanggal ini sudah ada
(<strong>{{ count($absensiData) }}</strong> dari <strong>{{ $santris->count() }}</strong> santri sudah diinput,
<strong style="color: {{ ($santris->count() - count($absensiData)) > 0 ? '#dc2626' : '#059669' }};">{{ $santris->count() - count($absensiData) }} belum absen</strong>).
Anda dapat mengubah status absensi lalu klik Simpan.
</div>
@endif
@ -124,6 +127,7 @@
<span class="badge badge-primary">{{ $totalKelas }} santri</span>
</div>
</div>
<div class="table-wrapper">
<table class="data-table" style="margin-top: 0; border-top-left-radius: 0; border-top-right-radius: 0;">
<thead>
<tr>
@ -203,6 +207,7 @@
</tbody>
</table>
</div>
</div>
@endforeach
<div id="noKelasSelected" class="empty-state" style="padding: 40px 20px;">

View File

@ -1,4 +1,4 @@
{{-- views/admin/kegiatan/absensi/rekap.blade.php --}}
{{-- views/admin/kegiatan/absensi/rekap.blade.php --}}
@extends('layouts.app')
@section('content')
@ -6,6 +6,77 @@
<h2><i class="fas fa-chart-bar"></i> Rekap Absensi: {{ $kegiatan->nama_kegiatan }}</h2>
</div>
{{-- Ringkasan Total Santri & Progress --}}
<div style="background: #fff; border-radius: 12px; padding: 18px 22px; margin-bottom: 16px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); border-left: 4px solid #2563eb;">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 12px;">
<div>
<h3 style="margin: 0; font-size: 1rem; color: #1a2332;">
<i class="fas fa-users" style="color: #2563eb;"></i> Total Semua Santri: <strong>{{ $totalSantriEligible }}</strong>
</h3>
<p style="margin: 4px 0 0; font-size: 0.84rem; color: #6b7280;">
Sudah absen: <strong style="color: #059669;">{{ $santriSudahAbsen }}</strong>
&nbsp;·&nbsp;
Belum absen: <strong style="color: {{ $belumAbsen > 0 ? '#dc2626' : '#059669' }};">{{ $belumAbsen }}</strong>
</p>
</div>
<div style="text-align: right;">
<div style="font-size: 1.6rem; font-weight: 800; color: {{ $persenHadir >= 85 ? '#059669' : ($persenHadir >= 70 ? '#d97706' : '#dc2626') }};">
{{ $persenHadir }}%
</div>
<div style="font-size: 0.78rem; color: #6b7280;">Kehadiran</div>
</div>
</div>
{{-- Progress bar --}}
<div style="height: 28px; background: #f3f4f6; border-radius: 14px; overflow: hidden; display: flex;">
@php
$pctHadir = $totalSantriEligible > 0 ? round(($stats['Hadir'] ?? 0) / $totalSantriEligible * 100, 1) : 0;
$pctTerlambat = $totalSantriEligible > 0 ? round(($stats['Terlambat'] ?? 0) / $totalSantriEligible * 100, 1) : 0;
$pctIzin = $totalSantriEligible > 0 ? round(($stats['Izin'] ?? 0) / $totalSantriEligible * 100, 1) : 0;
$pctSakit = $totalSantriEligible > 0 ? round(($stats['Sakit'] ?? 0) / $totalSantriEligible * 100, 1) : 0;
$pctAlpa = $totalSantriEligible > 0 ? round(($stats['Alpa'] ?? 0) / $totalSantriEligible * 100, 1) : 0;
$pctBelum = $totalSantriEligible > 0 ? round($belumAbsen / $totalSantriEligible * 100, 1) : 0;
@endphp
@if($pctHadir > 0)
<div style="width: {{ $pctHadir }}%; background: #22c55e; display: flex; align-items: center; justify-content: center; color: white; font-size: 0.73rem; font-weight: 700;" title="Hadir: {{ $stats['Hadir'] ?? 0 }}">
{{ ($stats['Hadir'] ?? 0) > 0 ? ($stats['Hadir'] ?? 0) : '' }}
</div>
@endif
@if($pctTerlambat > 0)
<div style="width: {{ $pctTerlambat }}%; background: #FF9800; display: flex; align-items: center; justify-content: center; color: white; font-size: 0.73rem; font-weight: 700;" title="Terlambat: {{ $stats['Terlambat'] ?? 0 }}">
{{ ($stats['Terlambat'] ?? 0) > 0 ? ($stats['Terlambat'] ?? 0) : '' }}
</div>
@endif
@if($pctIzin > 0)
<div style="width: {{ $pctIzin }}%; background: #f59e0b; display: flex; align-items: center; justify-content: center; color: white; font-size: 0.73rem; font-weight: 700;" title="Izin: {{ $stats['Izin'] ?? 0 }}">
{{ ($stats['Izin'] ?? 0) > 0 ? ($stats['Izin'] ?? 0) : '' }}
</div>
@endif
@if($pctSakit > 0)
<div style="width: {{ $pctSakit }}%; background: #3b82f6; display: flex; align-items: center; justify-content: center; color: white; font-size: 0.73rem; font-weight: 700;" title="Sakit: {{ $stats['Sakit'] ?? 0 }}">
{{ ($stats['Sakit'] ?? 0) > 0 ? ($stats['Sakit'] ?? 0) : '' }}
</div>
@endif
@if($pctAlpa > 0)
<div style="width: {{ $pctAlpa }}%; background: #ef4444; display: flex; align-items: center; justify-content: center; color: white; font-size: 0.73rem; font-weight: 700;" title="Alpa: {{ $stats['Alpa'] ?? 0 }}">
{{ ($stats['Alpa'] ?? 0) > 0 ? ($stats['Alpa'] ?? 0) : '' }}
</div>
@endif
@if($pctBelum > 0)
<div style="width: {{ $pctBelum }}%; background: #d1d5db; display: flex; align-items: center; justify-content: center; color: #6b7280; font-size: 0.73rem; font-weight: 700;" title="Belum Absen: {{ $belumAbsen }}">
{{ $belumAbsen > 0 ? $belumAbsen : '' }}
</div>
@endif
</div>
<div style="display: flex; gap: 14px; flex-wrap: wrap; margin-top: 8px; font-size: 0.75rem; color: #6b7280;">
<span><span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:#22c55e;margin-right:3px;"></span> Hadir</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:#FF9800;margin-right:3px;"></span> Terlambat</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:#f59e0b;margin-right:3px;"></span> Izin</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:#3b82f6;margin-right:3px;"></span> Sakit</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:#ef4444;margin-right:3px;"></span> Alpa</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:#d1d5db;margin-right:3px;"></span> Belum Absen</span>
</div>
</div>
<div class="row-cards">
<div class="card card-success">
<h3>Hadir</h3>
@ -32,6 +103,11 @@
<div class="card-value">{{ $stats['Alpa'] ?? 0 }}</div>
<i class="fas fa-times-circle card-icon"></i>
</div>
<div class="card" style="border-top: 3px solid #9ca3af;">
<h3>Belum Absen</h3>
<div class="card-value" style="color: {{ $belumAbsen > 0 ? '#dc2626' : '#6b7280' }};">{{ $belumAbsen }}</div>
<i class="fas fa-hourglass-half card-icon" style="color: #9ca3af;"></i>
</div>
</div>
<div class="content-box">
@ -75,6 +151,8 @@
</span>
</h4>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -99,6 +177,8 @@
<td>
@if($absensi->metode_absen == 'RFID')
<span class="badge badge-primary"><i class="fas fa-id-card"></i> RFID</span>
@elseif($absensi->metode_absen == 'Import_Mesin')
<span class="badge" style="background: #7c3aed; color: white;"><i class="fas fa-desktop"></i> Mesin</span>
@else
<span class="badge badge-secondary"><i class="fas fa-hand-pointer"></i> Manual</span>
@endif
@ -122,6 +202,8 @@
@endforeach
</tbody>
</table>
</div>
</div>
@endforeach
@else
@ -134,5 +216,50 @@
</a>
</div>
@endif
{{-- Daftar santri yang belum absen --}}
@if($santriBelumAbsen->count() > 0)
<div class="content-box" style="margin-top: 18px; border-left: 4px solid #f59e0b;">
<h4 style="margin: 0 0 12px; color: #d97706;">
<i class="fas fa-exclamation-triangle"></i> Santri Belum Absen ({{ $santriBelumAbsen->count() }} orang)
@if(request('tanggal'))
<span style="font-size: 0.8rem; font-weight: 400; color: #6b7280; margin-left: 6px;">
Tanggal: {{ \Carbon\Carbon::parse(request('tanggal'))->format('d/m/Y') }}
</span>
@endif
</h4>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th style="width: 50px;">No</th>
<th style="width: 100px;">ID Santri</th>
<th>Nama Santri</th>
<th style="width: 150px;">Kelas</th>
<th style="width: 120px; text-align: center;">Status</th>
</tr>
</thead>
<tbody>
@foreach($santriBelumAbsen as $index => $santri)
<tr>
<td>{{ $index + 1 }}</td>
<td><strong>{{ $santri->id_santri }}</strong></td>
<td>{{ $santri->nama_lengkap }}</td>
<td>{{ optional(optional($santri->kelasPrimary)->kelas)->nama_kelas ?? '-' }}</td>
<td class="text-center">
<span class="badge" style="background: #fef3c7; color: #92400e; padding: 4px 10px; border-radius: 12px; font-size: 0.8rem;">
<i class="fas fa-hourglass-half"></i> Belum Absen
</span>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endif
</div>
@endsection

View File

@ -29,8 +29,20 @@
{{-- ============================================================ --}}
{{-- PAGE HEADER --}}
{{-- ============================================================ --}}
<div class="page-header">
<div class="page-header" style="display:flex; align-items:center; justify-content:space-between;">
<h2><i class="fas fa-tachometer-alt"></i> Dashboard Absensi</h2>
<div style="display:flex; gap:8px;">
<a href="{{ route('admin.mesin.mapping-santri.index') }}"
class="btn btn-sm btn-secondary">
<i class="fas fa-link"></i> Mapping Fingerprint
</a>
<a href="{{ route('admin.mesin.import.index') }}"
class="btn btn-sm btn-success"
style="background:#0F7B6C; border-color:#0F7B6C;">
<i class="fas fa-file-import"></i> Import
</a>
</div>
</div>
<p style="color: var(--text-light); margin-top: 5px; margin-bottom: 14px;">

View File

@ -106,6 +106,8 @@
<span class="badge badge-primary">{{ $kegiatanHari->count() }} kegiatan</span>
</h4>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -165,6 +167,8 @@
@endforeach
</tbody>
</table>
</div>
</div>
@endif
@endforeach

View File

@ -145,6 +145,10 @@
<span style="background: #6FBAA5; color: white; padding: 3px 8px; border-radius: 8px; font-size: 0.75rem;">
<i class="fas fa-id-card"></i> RFID
</span>
@elseif($absensi->metode_absen == 'Import_Mesin')
<span style="background: #7c3aed; color: white; padding: 3px 8px; border-radius: 8px; font-size: 0.75rem;">
<i class="fas fa-desktop"></i> Mesin
</span>
@else
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 8px; font-size: 0.75rem;">
<i class="fas fa-hand-pointer"></i> Manual
@ -166,6 +170,56 @@
@endif
</div>
{{-- Daftar Santri Belum Absen --}}
@if(isset($santriBelumAbsen) && $santriBelumAbsen->count() > 0)
<div style="margin-top: 18px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<h5 style="color: #d97706; margin: 0;">
<i class="fas fa-exclamation-triangle"></i> Santri Belum Absen ({{ $santriBelumAbsen->count() }} orang)
</h5>
</div>
@foreach($santriBelumAbsenPerKelas as $namaKelas => $kelasSantris)
<div style="margin-bottom: 12px;">
<div style="background: linear-gradient(135deg, #fef3c7, #fef9c3); padding: 8px 14px; border-radius: 8px 8px 0 0; display: flex; justify-content: space-between; align-items: center;">
<h6 style="margin: 0; color: #92400e; font-size: 0.9rem;">
<i class="fas fa-school"></i> {{ $namaKelas }}
</h6>
<span style="background: #f59e0b; color: white; padding: 2px 10px; border-radius: 10px; font-size: 0.75rem; font-weight: 600;">
{{ $kelasSantris->count() }} santri
</span>
</div>
<div style="max-height: 200px; overflow-y: auto; border: 1px solid #fde68a; border-top: 0; border-radius: 0 0 8px 8px;">
<table style="width: 100%; border-collapse: collapse;">
<thead style="position: sticky; top: 0; background: white; box-shadow: 0 2px 4px rgba(0,0,0,0.05);">
<tr style="border-bottom: 2px solid #fde68a;">
<th style="padding: 8px; text-align: left; font-size: 0.8rem; color: #6c757d;">No</th>
<th style="padding: 8px; text-align: left; font-size: 0.8rem; color: #6c757d;">ID</th>
<th style="padding: 8px; text-align: left; font-size: 0.8rem; color: #6c757d;">Nama Santri</th>
<th style="padding: 8px; text-align: center; font-size: 0.8rem; color: #6c757d;">Status</th>
</tr>
</thead>
<tbody>
@foreach($kelasSantris as $idx => $santri)
<tr style="border-bottom: 1px solid #fef3c7;">
<td style="padding: 6px 8px; font-size: 0.85rem;">{{ $idx + 1 }}</td>
<td style="padding: 6px 8px; font-size: 0.85rem; font-weight: 600;">{{ $santri->id_santri }}</td>
<td style="padding: 6px 8px; font-size: 0.85rem;">{{ $santri->nama_lengkap }}</td>
<td style="padding: 6px 8px; text-align: center;">
<span style="background: #fef3c7; color: #92400e; padding: 3px 8px; border-radius: 10px; font-size: 0.75rem; font-weight: 600;">
<i class="fas fa-hourglass-half"></i> Belum
</span>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endforeach
</div>
@endif
{{-- Action Buttons --}}
<div style="margin-top: 25px; padding-top: 20px; border-top: 2px solid #e9ecef; display: flex; gap: 10px; justify-content: flex-end;">
<a href="{{ route('admin.absensi-kegiatan.input', $kegiatan->kegiatan_id) }}?tanggal={{ $tanggal }}"

View File

@ -40,6 +40,7 @@
text-align: center;
vertical-align: middle;
padding: 1.5mm 2mm;
overflow: hidden;
}
.h-sub {
display:block; font-size:3.5pt; color:#a89060;
@ -62,6 +63,7 @@
text-align: center;
vertical-align: middle;
padding: 0;
overflow: hidden;
}
/* ── DIVIDER 1.6mm ── */
@ -85,22 +87,27 @@
vertical-align: middle;
text-align: center;
padding: 0.5mm 3mm;
overflow: hidden;
}
.nama-box {
background: #0d1f3c;
border: 0.5mm solid #c9a227;
border-radius: 1mm;
text-align: center;
padding: 1.8mm 2mm;
padding: 1.2mm 1.5mm;
width: 100%;
display: block;
overflow: hidden;
max-height: 7.5mm;
}
.nama-text {
font-size:8pt; font-weight:bold; color:#fff;
letter-spacing:0.5pt; font-family:Georgia,serif;
font-size:6pt; font-weight:bold; color:#fff;
letter-spacing:0.3pt; font-family:Georgia,serif;
text-align: center;
display: block;
width: 100%;
white-space: nowrap;
overflow: hidden;
}
/* ── INFO 9mm ── */
@ -109,6 +116,7 @@
background: #0b1a2e;
vertical-align: middle;
padding: 1mm 3mm 0 3mm;
overflow: hidden;
}
.info-t {
width:48mm; height:8mm;
@ -118,14 +126,15 @@
}
.info-t td {
height:8mm; padding:0 1.5mm; vertical-align:middle;
overflow:hidden;
}
.ic-nis { width:30%; }
.ic-kelas { width:37%; border-left:0.4mm solid rgba(201,162,39,0.5); border-right:0.4mm solid rgba(201,162,39,0.5); }
.ic-rfid { width:33%; }
.lbl { display:block; font-size:3pt; color:#c9a227; font-weight:bold; letter-spacing:0.3pt; text-transform:uppercase; margin-bottom:0.5mm; }
.val { display:block; font-size:5.5pt; color:#fff; font-weight:bold; }
.val-sm { display:block; font-size:4.5pt; color:#fff; font-weight:bold; }
.val-rfid { display:block; font-size:3.5pt; color:#90c4f0; font-weight:bold; font-family:monospace; word-break:break-all; }
.val { display:block; font-size:4.5pt; color:#fff; font-weight:bold; white-space:nowrap; overflow:hidden; }
.val-sm { display:block; font-size:4pt; color:#fff; font-weight:bold; overflow:hidden; }
.val-rfid { display:block; font-size:3pt; color:#90c4f0; font-weight:bold; font-family:monospace; word-break:break-all; overflow:hidden; }
/* ── BOTTOM 9mm ── */
.td-bottom {
@ -153,39 +162,47 @@
<span class="h-loc">PKPPS Riyadlul Jannah</span>
</td></tr>
{{-- FOTO: SVG dengan clipPath lingkaran --}}
{{-- FOTO: teknik "mask ring" 100% kompatibel mPDF --}}
<tr><td class="td-foto">
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
width="34mm" height="34mm"
viewBox="0 0 100 100"
overflow="hidden"
style="display:block;margin:0 auto;">
<defs>
<clipPath id="cp">
<circle cx="50" cy="50" r="44"/>
</clipPath>
</defs>
{{-- Background --}}
<circle cx="50" cy="50" r="44" fill="#1a2f4a"/>
{{-- 1. Background bulat (terlihat jika tidak ada foto) --}}
<circle cx="50" cy="50" r="37" fill="#1a2f4a"/>
@if($fotoBase64 !== '')
{{-- Foto: x=6,y=6 supaya pas dalam circle r=44 lebar=88 --}}
<image x="6" y="6" width="88" height="88"
clip-path="url(#cp)"
{{-- 2. Foto persegi biasa sengaja lebih besar dari lingkaran --}}
<image x="10" y="10" width="80" height="80"
preserveAspectRatio="xMidYMid slice"
xlink:href="data:{{ $fotoMime }};base64,{{ $fotoBase64 }}"
href="data:{{ $fotoMime }};base64,{{ $fotoBase64 }}"/>
@else
<text x="50" y="50" text-anchor="middle" dominant-baseline="central"
font-size="36" font-weight="bold" fill="#c9a227"
font-size="34" font-weight="bold" fill="#c9a227"
font-family="Georgia">{{ $initial }}</text>
@endif
{{-- Ring emas luar --}}
<circle cx="50" cy="50" r="48" fill="none" stroke="#c9a227" stroke-width="3.5"/>
{{-- Ring tipis dalam --}}
<circle cx="50" cy="50" r="44" fill="none" stroke="#8b6914" stroke-width="1"/>
{{-- 3. MASK RING lingkaran tebal warna background menutupi
semua bagian foto di luar radius 37.
r=54 sw=34 inner=54-17=37, outer=54+17=71 (sampai sudut) --}}
<circle cx="50" cy="50" r="54" fill="none"
stroke="#0b1a2e" stroke-width="34"/>
{{-- 4. Bingkai emas utama --}}
<circle cx="50" cy="50" r="38.5" fill="none"
stroke="#c9a227" stroke-width="3"/>
{{-- 5. Aksen tipis luar --}}
<circle cx="50" cy="50" r="41" fill="none"
stroke="rgba(201,162,39,0.4)" stroke-width="0.6"/>
{{-- 6. Aksen tipis dalam --}}
<circle cx="50" cy="50" r="36" fill="none"
stroke="rgba(139,105,20,0.5)" stroke-width="0.5"/>
</svg>
</td></tr>

View File

@ -1,4 +1,4 @@
@extends('layouts.app')
@extends('layouts.app')
@section('content')
<div class="page-header">
@ -39,6 +39,7 @@
</div>
@if($santris->count() > 0)
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -94,6 +95,7 @@
@endforeach
</tbody>
</table>
</div>
<div style="margin-top: 14px;">
{{ $santris->links() }}

View File

@ -1,4 +1,4 @@
@extends('layouts.app')
@extends('layouts.app')
@section('content')
<div class="page-header">
@ -35,6 +35,7 @@
</div>
@if($kategoris->count() > 0)
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -75,6 +76,7 @@
@endforeach
</tbody>
</table>
</div>
<div style="margin-top: 14px;">
{{ $kategoris->links() }}

View File

@ -1,4 +1,4 @@
@extends('layouts.app')
@extends('layouts.app')
@section('content')
<style>
@ -121,6 +121,7 @@
@if(!empty($breakdownPerKelas) && count($breakdownPerKelas) > 0)
<div class="chart-box">
<h4><i class="fas fa-school"></i> Breakdown Per Kelas</h4>
<div class="table-wrapper">
<table class="data-table" style="font-size:0.85rem;">
<thead><tr><th>Kelas</th><th class="text-center">Total</th><th class="text-center">Hadir</th><th class="text-center" style="min-width:180px;">% Kehadiran</th></tr></thead>
<tbody>
@ -140,6 +141,7 @@
</tbody>
</table>
</div>
</div>
@endif
{{-- Santri Tidak Pernah Hadir --}}

View File

@ -1,4 +1,4 @@
@extends('layouts.app')
@extends('layouts.app')
@section('content')
<style>
@ -115,7 +115,7 @@
@if($streak > 0)
<div class="insight-box i-success">
<i class="fas fa-fire"></i>
Streak kehadiran beruntun: <strong>{{ $streak }} kegiatan</strong> 🔥
Streak kehadiran beruntun: <strong>{{ $streak }} kegiatan</strong> 🔥
</div>
@endif
@if(($stats->total ?? 0) > 0)
@ -131,6 +131,7 @@
<div class="chart-box">
<h4><i class="fas fa-tasks"></i> Kehadiran Per Kegiatan</h4>
@if($perKegiatan->count() > 0)
<div class="table-wrapper">
<table class="data-table" style="font-size:0.85rem;">
<thead>
<tr>
@ -159,6 +160,7 @@
@endforeach
</tbody>
</table>
</div>
@else
<p style="color:var(--text-light); font-size:0.85rem;">Belum ada data kehadiran per kegiatan.</p>
@endif
@ -168,6 +170,7 @@
<div class="chart-box">
<h4><i class="fas fa-history"></i> Riwayat Absensi Terbaru</h4>
@if($riwayatTerbaru->count() > 0)
<div class="table-wrapper">
<table class="data-table" style="font-size:0.85rem;">
<thead><tr><th>Tanggal</th><th>Kegiatan</th><th>Kategori</th><th class="text-center">Status</th></tr></thead>
<tbody>
@ -181,6 +184,7 @@
@endforeach
</tbody>
</table>
</div>
@else
<p style="color:var(--text-light);">Belum ada riwayat.</p>
@endif

View File

@ -385,6 +385,7 @@ class="item-name" style="color: inherit; text-decoration: none;">
<details>
<summary>{{ $kelompok['nama_kelompok'] }} ({{ count($kelompok['kelas']) }} kelas)</summary>
<div class="kelas-detail-body">
<div class="table-wrapper">
<table class="data-table" style="font-size: 0.85rem;">
<thead>
<tr>
@ -410,6 +411,7 @@ class="item-name" style="color: inherit; text-decoration: none;">
</tbody>
</table>
</div>
</div>
</details>
@endforeach
</div>

View File

@ -1,4 +1,4 @@
@extends('layouts.app')
@extends('layouts.app')
@section('content')
<style>
@ -42,6 +42,7 @@
<div class="content-box">
@if($santris->count() > 0)
<div class="table-wrapper">
<table class="data-table" style="font-size:0.85rem;">
<thead>
<tr>
@ -83,6 +84,7 @@
@endforeach
</tbody>
</table>
</div>
<div style="margin-top:16px;">
{{ $santris->links() }}

View File

@ -1,4 +1,4 @@
{{-- resources/views/admin/kegiatan/riwayat/detail-santri.blade.php --}}
{{-- resources/views/admin/kegiatan/riwayat/detail-santri.blade.php --}}
@extends('layouts.app')
@section('content')
@ -73,6 +73,7 @@
<h3 style="margin: 0 0 20px 0; color: var(--primary-color);">
<i class="fas fa-layer-group"></i> Kehadiran Per Kelas
</h3>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -110,6 +111,7 @@
</tbody>
</table>
</div>
</div>
@endif
<!-- Riwayat Lengkap -->
@ -119,6 +121,7 @@
</h3>
@if($riwayats->count() > 0)
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -143,6 +146,7 @@
@endforeach
</tbody>
</table>
</div>
<div style="margin-top: 14px;">
{{ $riwayats->links() }}

View File

@ -135,6 +135,47 @@ class="btn-back">
</div>
</div>
{{-- Ringkasan Total Santri --}}
@if(isset($totalSantriEligible))
<div style="background: #fff; border-radius: 12px; padding: 16px 20px; margin-bottom: 14px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); border-left: 4px solid #2563eb;">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 10px;">
<div>
<h4 style="margin: 0; font-size: 1rem; color: #1a2332;">
<i class="fas fa-users" style="color: #2563eb;"></i> Total Semua Santri: <strong>{{ $totalSantriEligible }}</strong>
</h4>
<p style="margin: 4px 0 0; font-size: 0.84rem; color: #6b7280;">
Sudah absen: <strong style="color: #059669;">{{ $totalRecorded }}</strong>
&nbsp;·&nbsp;
Belum absen: <strong style="color: {{ ($totalSantriEligible - $totalRecorded) > 0 ? '#dc2626' : '#059669' }};">{{ max(0, $totalSantriEligible - $totalRecorded) }}</strong>
</p>
</div>
<div style="text-align: right;">
<div style="font-size: 1.5rem; font-weight: 800; color: {{ $persenHadir >= 85 ? '#059669' : ($persenHadir >= 70 ? '#d97706' : '#dc2626') }};">
{{ $persenHadir }}%
</div>
<div style="font-size: 0.78rem; color: #6b7280;">Kehadiran</div>
</div>
</div>
{{-- Progress bar --}}
@php
$pctSudah = $totalSantriEligible > 0 ? round($totalRecorded / $totalSantriEligible * 100, 1) : 0;
$pctBelumRiwayat = 100 - $pctSudah;
@endphp
<div style="height: 24px; background: #f3f4f6; border-radius: 12px; overflow: hidden; display: flex;">
@if($pctSudah > 0)
<div style="width: {{ $pctSudah }}%; background: linear-gradient(90deg, #22c55e, #16a34a); display: flex; align-items: center; justify-content: center; color: white; font-size: 0.73rem; font-weight: 700;" title="Sudah Absen: {{ $totalRecorded }}">
{{ $totalRecorded }}
</div>
@endif
@if($pctBelumRiwayat > 0 && ($totalSantriEligible - $totalRecorded) > 0)
<div style="width: {{ $pctBelumRiwayat }}%; background: #d1d5db; display: flex; align-items: center; justify-content: center; color: #6b7280; font-size: 0.73rem; font-weight: 700;" title="Belum Absen: {{ $totalSantriEligible - $totalRecorded }}">
{{ $totalSantriEligible - $totalRecorded }}
</div>
@endif
</div>
</div>
@endif
{{-- 6 KPI Cards --}}
<div class="stats-row">
<div class="stat-card hadir">
@ -274,6 +315,27 @@ class="btn-reset">
$dayAlpa = $records->where('status', 'Alpa')->count();
$dayPulang = $records->where('status', 'Pulang')->count();
$dayTotal = $records->count();
// Group per kelas kegiatan (khusus) atau kelas_name santri (umum)
$isUmum = $kegiatan->kelasKegiatan->isEmpty();
if ($isUmum) {
$recordsPerKelas = $records->groupBy(fn($r) =>
optional(optional($r->santri->kelasSantri->first())->kelas)->nama_kelas ?? 'Tanpa Kelas'
)->sortKeys();
} else {
$recordsPerKelas = collect();
$placedIds = [];
foreach ($kegiatan->kelasKegiatan as $kls) {
$inKelas = $records->filter(function($r) use ($kls, &$placedIds) {
if (in_array($r->id, $placedIds)) return false;
return $r->santri->kelasSantri->contains('id_kelas', $kls->id);
});
foreach ($inKelas as $r) $placedIds[] = $r->id;
if ($inKelas->count() > 0) $recordsPerKelas[$kls->nama_kelas] = $inKelas;
}
$lainnya = $records->filter(fn($r) => !in_array($r->id, $placedIds));
if ($lainnya->count() > 0) $recordsPerKelas['Kelas Lain'] = $lainnya;
}
@endphp
<div class="day-group">
@ -306,20 +368,28 @@ class="btn-reset">
</div>
</div>
<div class="day-body">
@foreach($recordsPerKelas as $namaKelas => $kelasRecords)
<div style="background: linear-gradient(135deg, #f0fdf4, #e8f5e9); padding: 8px 18px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #e2e8f0;">
<span style="font-size: 0.85rem; font-weight: 600; color: #065f46;">
<i class="fas fa-school"></i> {{ $namaKelas }}
</span>
<span style="background: #6FBAA5; color: white; padding: 2px 10px; border-radius: 10px; font-size: 0.75rem; font-weight: 600;">
{{ $kelasRecords->count() }} santri
</span>
</div>
<table>
<thead>
<tr>
<th style="width: 45px;">No</th>
<th style="width: 90px;">ID Santri</th>
<th>Nama Santri</th>
<th style="width: 140px;">Kelas</th>
<th style="width: 90px; text-align: center;">Status</th>
<th style="width: 80px; text-align: center;">Waktu</th>
<th style="width: 80px;">Metode</th>
</tr>
</thead>
<tbody>
@foreach($records as $index => $riwayat)
@foreach($kelasRecords->values() as $index => $riwayat)
<tr>
<td>{{ $index + 1 }}</td>
<td><strong>{{ $riwayat->id_santri }}</strong></td>
@ -329,13 +399,6 @@ class="btn-reset">
{{ $riwayat->santri->nama_lengkap }}
</a>
</td>
<td>
@if($riwayat->santri->kelasSantri->first() && $riwayat->santri->kelasSantri->first()->kelas)
{{ $riwayat->santri->kelasSantri->first()->kelas->nama_kelas }}
@else
<span style="color: #9CA3AF;">-</span>
@endif
</td>
<td style="text-align: center;">{!! $riwayat->status_badge !!}</td>
<td style="text-align: center;">
{{ $riwayat->waktu_absen ? \Carbon\Carbon::parse($riwayat->waktu_absen)->format('H:i') : '-' }}
@ -345,6 +408,10 @@ class="btn-reset">
<span style="background: #DBEAFE; color: #1E40AF; padding: 3px 8px; border-radius: 10px; font-size: 0.75rem; font-weight: 600;">
<i class="fas fa-id-card"></i> RFID
</span>
@elseif($riwayat->metode_absen == 'Import_Mesin')
<span style="background: #EDE9FE; color: #6B21A8; padding: 3px 8px; border-radius: 10px; font-size: 0.75rem; font-weight: 600;">
<i class="fas fa-desktop"></i> Mesin
</span>
@else
<span style="background: #E5E7EB; color: #374151; padding: 3px 8px; border-radius: 10px; font-size: 0.75rem; font-weight: 600;">
<i class="fas fa-hand-pointer"></i> Manual
@ -355,6 +422,7 @@ class="btn-reset">
@endforeach
</tbody>
</table>
@endforeach
</div>
</div>
@endforeach

View File

@ -1,4 +1,4 @@
{{--
{{--
============================================================================
LOKASI FILE: resources/views/admin/kelas/index.blade.php
============================================================================
@ -104,6 +104,7 @@ class="form-control"
<!-- Kelas List -->
<div class="content-box">
@if ($kelas->count() > 0)
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -169,6 +170,7 @@ class="btn btn-sm btn-danger"
@endforeach
</tbody>
</table>
</div>
<!-- Pagination -->
@if ($kelas->hasPages())

View File

@ -1,4 +1,4 @@
{{--
{{--
============================================================================
LOKASI FILE: resources/views/admin/kelas/kelompok/index.blade.php
============================================================================
@ -89,6 +89,7 @@ class="form-control"
<!-- Kelompok List -->
<div class="content-box">
@if ($kelompokKelas->count() > 0)
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -155,6 +156,7 @@ class="btn btn-sm btn-danger"
@endforeach
</tbody>
</table>
</div>
<!-- Pagination -->
@if ($kelompokKelas->hasPages())

View File

@ -1,4 +1,4 @@
@extends('layouts.app')
@extends('layouts.app')
@section('title', 'Kenaikan Kelas Massal')
@ -85,6 +85,8 @@
</p>
</div>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -132,7 +134,7 @@
@endforeach
</select>
@else
<span class="text-muted" style="font-size:0.85rem;"></span>
<span class="text-muted" style="font-size:0.85rem;"></span>
@endif
</td>
<td style="text-align: center;">
@ -163,6 +165,8 @@ class="btn btn-sm btn-info"
@endforeach
</tbody>
</table>
</div>
</div>
@else
<div class="content-box">
@ -196,7 +200,7 @@ class="btn btn-sm btn-info"
<script>
document.addEventListener('DOMContentLoaded', function () {
// ── Enable/disable tombol Naikkan berdasarkan pilihan dropdown ──
// ── Enable/disable tombol Naikkan berdasarkan pilihan dropdown ──
document.querySelectorAll('.target-kelas-select').forEach(function (select) {
var kelasId = select.dataset.kelasId;
var button = document.querySelector('.btn-naikkan[data-kelas-id="' + kelasId + '"]');
@ -213,7 +217,7 @@ class="btn btn-sm btn-info"
});
});
// ── Handle klik tombol Naikkan ──
// ── Handle klik tombol Naikkan ──
document.querySelectorAll('.btn-naikkan').forEach(function (button) {
button.addEventListener('click', function (e) {
e.preventDefault();

View File

@ -1,4 +1,4 @@
@extends('layouts.app')
@extends('layouts.app')
@section('title', 'Preview Kenaikan Kelas')
@ -98,6 +98,7 @@
</div>
@if ($santriList->count() > 0)
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -152,6 +153,7 @@ class="santri-avatar">
@endforeach
</tbody>
</table>
</div>
<hr>

View File

@ -1,4 +1,4 @@
@extends('layouts.app')
@extends('layouts.app')
@section('title', 'Detail Kelas')
@ -97,6 +97,8 @@
<span class="badge badge-info">{{ $santriList->count() }} santri</span>
</h3>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -131,6 +133,8 @@ class="btn btn-sm btn-info"
@endforeach
</tbody>
</table>
</div>
</div>
@else
<div class="content-box" style="margin-top: 14px;">

View File

@ -1,4 +1,4 @@
{{-- resources/views/admin/kepulangan/index.blade.php --}}
{{-- resources/views/admin/kepulangan/index.blade.php --}}
@extends('layouts.app')
@ -174,6 +174,7 @@ class="form-control"
{{-- Data Table (SAMA SEPERTI SEBELUMNYA) --}}
<div style="overflow-x: auto;">
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -326,6 +327,7 @@ class="btn btn-sm btn-danger"
</tbody>
</table>
</div>
</div>
{{-- Pagination --}}
@if($kepulangan->hasPages())
@ -618,14 +620,14 @@ function calculateDurasiAktual() {
if (durasiAktual < durasiRencana) {
const selisih = durasiRencana - durasiAktual;
selisihText = `✅ Santri pulang ${selisih} hari lebih cepat dari rencana. Kuota akan berkurang ${durasiAktual} hari.`;
selisihText = `✅ Santri pulang ${selisih} hari lebih cepat dari rencana. Kuota akan berkurang ${durasiAktual} hari.`;
selisihColor = '#28a745';
} else if (durasiAktual > durasiRencana) {
const selisih = durasiAktual - durasiRencana;
selisihText = `⚠️ Santri pulang ${selisih} hari lebih lambat dari rencana. Kuota akan bertambah ${selisih} hari.`;
selisihText = `⚠️ Santri pulang ${selisih} hari lebih lambat dari rencana. Kuota akan bertambah ${selisih} hari.`;
selisihColor = '#ffc107';
} else {
selisihText = `✠Sesuai rencana (${durasiAktual} hari).`;
selisihText = `âœ✠Sesuai rencana (${durasiAktual} hari).`;
selisihColor = '#007bff';
}

View File

@ -1,4 +1,4 @@
{{-- resources/views/admin/kepulangan/over-limit.blade.php --}}
{{-- resources/views/admin/kepulangan/over-limit.blade.php --}}
@extends('layouts.app')
@ -13,17 +13,17 @@
<div style="background: linear-gradient(135deg, #ff5252 0%, #f48fb1 100%); color: white; padding: 14px; border-radius: 12px; margin-bottom: 14px;">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 20px; align-items: center;">
<div>
<h4 style="margin: 0 0 5px 0; opacity: 0.9;">⚠️ Total Santri Over Limit</h4>
<h4 style="margin: 0 0 5px 0; opacity: 0.9;">⚠️ Total Santri Over Limit</h4>
<p style="margin: 0; font-size: 2rem; font-weight: 700;">{{ $santriList->count() }}</p>
</div>
<div>
<h4 style="margin: 0 0 5px 0; opacity: 0.9;">📅 Periode Kuota</h4>
<h4 style="margin: 0 0 5px 0; opacity: 0.9;">📅 Periode Kuota</h4>
<p style="margin: 0; font-size: 1.1rem; font-weight: 600;">
{{ $settings->periode_mulai->format('d M Y') }} - {{ $settings->periode_akhir->format('d M Y') }}
</p>
</div>
<div>
<h4 style="margin: 0 0 5px 0; opacity: 0.9;">📊 Kuota Maksimal</h4>
<h4 style="margin: 0 0 5px 0; opacity: 0.9;">📊 Kuota Maksimal</h4>
<p style="margin: 0; font-size: 1.1rem; font-weight: 600;">{{ $settings->kuota_maksimal }} Hari / Tahun</p>
</div>
<div style="text-align: right;">
@ -36,7 +36,7 @@
{{-- Alert Info --}}
<div style="background: #fff3cd; padding: 15px; border-radius: 8px; margin-bottom: 14px; border-left: 4px solid #ffc107;">
<strong>ℹ️ Informasi:</strong>
<strong>ℹ️ Informasi:</strong>
<p style="margin: 10px 0 0 0;">
Berikut adalah daftar santri yang telah melebihi kuota maksimal <strong>{{ $settings->kuota_maksimal }} hari</strong> dalam periode ini.
Santri tetap bisa mengajukan izin, namun akan mendapat peringatan visual.
@ -46,6 +46,7 @@
<div class="content-box">
@if($santriList->count() > 0)
<div style="overflow-x: auto;">
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -126,10 +127,11 @@ class="btn btn-sm btn-warning"
</tbody>
</table>
</div>
</div>
{{-- Summary Statistics --}}
<div style="margin-top: 22px; padding: 14px; background: #f8f9fa; border-radius: 8px;">
<h4 style="margin: 0 0 15px 0; color: #2C3E50;">📊 Ringkasan Statistik</h4>
<h4 style="margin: 0 0 15px 0; color: #2C3E50;">📊 Ringkasan Statistik</h4>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 20px;">
<div style="text-align: center; padding: 15px; background: white; border-radius: 8px; border: 2px solid #dc3545;">
<div style="font-size: 0.85rem; color: #7F8C8D; margin-bottom: 5px;">Total Santri Over Limit</div>

View File

@ -1,4 +1,4 @@
{{-- resources/views/admin/kepulangan/pengajuan.blade.php --}}
{{-- resources/views/admin/kepulangan/pengajuan.blade.php --}}
@extends('layouts.app')
@ -98,6 +98,7 @@ class="form-control"
{{-- Data Table --}}
<div style="overflow-x: auto;">
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -198,6 +199,7 @@ class="btn btn-sm btn-danger"
</tbody>
</table>
</div>
</div>
{{-- Pagination --}}
@if($pengajuan->hasPages())

View File

@ -1,4 +1,4 @@
{{-- resources/views/admin/kepulangan/settings.blade.php --}}
{{-- resources/views/admin/kepulangan/settings.blade.php --}}
@extends('layouts.app')
@ -112,7 +112,7 @@ class="form-control"
</div>
<div style="background: #E8F7F2; padding: 15px; border-radius: 8px; margin-bottom: 14px; border-left: 4px solid #6FBA9D;">
<strong>ℹ️ Informasi:</strong>
<strong>ℹ️ Informasi:</strong>
<ul style="margin: 10px 0 0 20px; padding: 0;">
<li>Periode ini menentukan rentang waktu perhitungan kuota</li>
<li>Perubahan periode akan mempengaruhi perhitungan kuota santri</li>
@ -133,7 +133,7 @@ class="form-control"
</h3>
<div style="background: #fff3cd; padding: 15px; border-radius: 8px; margin-bottom: 14px; border-left: 4px solid #ffc107;">
<strong>⚠️ PERHATIAN:</strong>
<strong>⚠️ PERHATIAN:</strong>
<p style="margin: 10px 0 0 0;">
Reset kuota akan mengubah status semua izin yang "Disetujui" dalam periode saat ini menjadi "Selesai".
Ini akan mereset perhitungan kuota untuk memulai periode baru.
@ -175,7 +175,7 @@ class="form-control"
{{-- Info Tambahan --}}
<div style="background: #e3f2fd; padding: 15px; border-radius: 8px; border-left: 4px solid #2196f3;">
<strong>💡 Tips:</strong>
<strong>💡 Tips:</strong>
<ul style="margin: 10px 0 0 20px; padding: 0; font-size: 0.9rem;">
<li>Reset individual dapat dilakukan dari halaman detail santri</li>
<li>Reset massal sebaiknya dilakukan di akhir periode</li>
@ -194,6 +194,7 @@ class="form-control"
@if($resetLogs->count() > 0)
<div style="overflow-x: auto;">
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -214,7 +215,7 @@ class="form-control"
<td>
<span style="display: inline-block; padding: 4px 10px; border-radius: 4px; font-size: 0.85rem; font-weight: 600;
{{ $log->jenis_reset == 'massal' ? 'background: #dc3545; color: white;' : 'background: #ffc107; color: #000;' }}">
{{ $log->jenis_reset == 'massal' ? '👥 Massal' : '👤 Individual' }}
{{ $log->jenis_reset == 'massal' ? '👥 Massal' : '👤 Individual' }}
</span>
</td>
<td>
@ -249,6 +250,7 @@ class="form-control"
</tbody>
</table>
</div>
</div>
@else
<div style="text-align: center; padding: 22px; color: #7F8C8D;">
<i class="fas fa-inbox" style="font-size: 2.2rem; margin-bottom: 15px; display: block;"></i>
@ -267,7 +269,7 @@ class="form-control"
</h3>
</div>
<div style="padding: 14px; background: #fff3cd; border: 1px solid #ffeaa7; border-radius: 6px; margin-bottom: 15px;">
<h4 style="margin: 0 0 10px 0; color: #856404;">⚠️ PERINGATAN PENTING!</h4>
<h4 style="margin: 0 0 10px 0; color: #856404;">⚠️ PERINGATAN PENTING!</h4>
<p style="margin: 0; color: #856404;">
Anda akan mereset kuota untuk <strong>{{ $totalSantri }} santri aktif</strong>.
Semua izin yang berstatus "Disetujui" akan diubah menjadi "Selesai".
@ -279,7 +281,7 @@ class="form-control"
</p>
<ul style="margin: 10px 0 0 20px; padding: 0; font-size: 0.9rem;">
<li>Semua perhitungan kuota akan direset ke 0</li>
<li>Status izin "Disetujui" → "Selesai"</li>
<li>Status izin "Disetujui" → "Selesai"</li>
<li>Data arsip tetap tersimpan</li>
<li>Aktivitas tercatat dalam log</li>
</ul>

View File

@ -1,4 +1,4 @@
{{-- resources/views/admin/kepulangan/show.blade.php --}}
{{-- resources/views/admin/kepulangan/show.blade.php --}}
@extends('layouts.app')
@ -67,19 +67,19 @@
<td>
@if($kepulangan->is_aktif)
<span style="display: inline-block; background: #28a745; color: white; padding: 4px 10px; border-radius: 4px; font-size: 0.9rem;">
🏠 Sedang Izin
🏠 Sedang Izin
</span>
@elseif($kepulangan->is_terlambat)
<span style="display: inline-block; background: #dc3545; color: white; padding: 4px 10px; border-radius: 4px; font-size: 0.9rem;">
⏰ Terlambat Kembali
⏰ Terlambat Kembali
</span>
@elseif($kepulangan->status == 'Selesai')
<span style="display: inline-block; background: #6c757d; color: white; padding: 4px 10px; border-radius: 4px; font-size: 0.9rem;">
✅ Sudah Selesai
✅ Sudah Selesai
</span>
@else
<span style="display: inline-block; background: #81C6E8; color: white; padding: 4px 10px; border-radius: 4px; font-size: 0.9rem;">
📅 Belum Dimulai
📅 Belum Dimulai
</span>
@endif
</td>
@ -287,6 +287,7 @@ class="btn btn-warning"
<i class="fas fa-list"></i> Riwayat Kepulangan Lainnya (5 Terakhir)
</h4>
<div style="overflow-x: auto;">
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -331,6 +332,7 @@ class="btn btn-sm btn-primary">
</table>
</div>
</div>
</div>
@endif
{{-- Modals --}}

View File

@ -1,4 +1,4 @@
@extends('layouts.app')
@extends('layouts.app')
@section('title', 'Data Kesehatan Santri')
@ -91,6 +91,7 @@ class="form-control"
<!-- Data Table -->
@if($kesehatanSantri->count() > 0)
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -182,6 +183,7 @@ class="btn btn-danger btn-sm"
@endforeach
</tbody>
</table>
</div>
<!-- Pagination -->
<div style="margin-top: 14px; display: flex; justify-content: center;">

View File

@ -1,4 +1,4 @@
@extends('layouts.app')
@extends('layouts.app')
@section('title', 'Riwayat Kesehatan Santri')
@ -84,6 +84,7 @@
</div>
@if($riwayatKesehatan->count() > 0)
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -164,6 +165,7 @@ class="btn btn-secondary btn-sm"
@endforeach
</tbody>
</table>
</div>
<!-- Pagination -->
<div style="margin-top: 14px; display: flex; justify-content: center;">

View File

@ -1,4 +1,4 @@
@extends('layouts.app')
@extends('layouts.app')
@section('title', 'Detail Kesehatan Santri')
@ -289,7 +289,7 @@ class="btn btn-primary">
<form action="{{ route('admin.kesehatan-santri.destroy', $kesehatanSantri) }}"
method="POST"
style="display: inline;"
onsubmit="return confirm('⚠️ Yakin ingin menghapus data kesehatan ini?\n\nData yang dihapus tidak dapat dikembalikan!')">
onsubmit="return confirm('⚠️ Yakin ingin menghapus data kesehatan ini?\n\nData yang dihapus tidak dapat dikembalikan!')">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-danger">
@ -311,6 +311,8 @@ class="btn btn-primary">
</span>
</h3>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -361,6 +363,8 @@ class="btn btn-primary btn-sm">
</tbody>
</table>
</div>
<div style="text-align: center; margin-top: 14px;">
<a href="{{ route('admin.kesehatan-santri.riwayat', $kesehatanSantri->id_santri) }}"
class="btn btn-primary">

View File

@ -1,4 +1,4 @@
@extends('layouts.app')
@extends('layouts.app')
@section('content')
<div class="page-header">
@ -57,6 +57,7 @@
</form>
@if($transaksi->count() > 0)
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -98,6 +99,7 @@
@endforeach
</tbody>
</table>
</div>
<div style="margin-top: 14px;">{{ $transaksi->links() }}</div>
@else
<div class="empty-state">

View File

@ -1,4 +1,4 @@
@extends('layouts.app')
@extends('layouts.app')
@section('content')
@php
@ -63,6 +63,7 @@
<div class="content-box">
<h4 style="margin-bottom:12px;"><i class="fas fa-arrow-up" style="color:var(--danger-color);"></i> Pengeluaran Terbesar</h4>
@if($detailPengeluaran->count() > 0)
<div class="table-wrapper">
<table class="data-table">
<thead><tr><th>Tanggal</th><th>Keterangan</th><th>Nominal</th></tr></thead>
<tbody>
@ -75,6 +76,7 @@
@endforeach
</tbody>
</table>
</div>
@else
<p class="text-muted">Tidak ada pengeluaran bulan ini.</p>
@endif
@ -84,6 +86,7 @@
<div class="content-box">
<h4 style="margin-bottom:12px;"><i class="fas fa-arrow-down" style="color:var(--success-color);"></i> Pemasukan Non-SPP</h4>
@if($detailPemasukan->count() > 0)
<div class="table-wrapper">
<table class="data-table">
<thead><tr><th>Tanggal</th><th>Keterangan</th><th>Nominal</th></tr></thead>
<tbody>
@ -96,6 +99,7 @@
@endforeach
</tbody>
</table>
</div>
@else
<p class="text-muted">Tidak ada pemasukan non-SPP bulan ini.</p>
@endif

View File

@ -1,4 +1,4 @@
@extends('layouts.app')
@extends('layouts.app')
@section('title', 'Klasifikasi Pelanggaran')
@ -35,6 +35,7 @@
</div>
@if($data->isNotEmpty())
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -97,6 +98,7 @@ class="btn btn-sm btn-warning"
@endforeach
</tbody>
</table>
</div>
@else
<div class="empty-state">
<i class="fas fa-folder-open"></i>

View File

@ -1,4 +1,4 @@
@extends('layouts.app')
@extends('layouts.app')
@section('title', 'Detail Klasifikasi')
@ -53,6 +53,7 @@
<h3 style="color: var(--primary-color); margin-bottom: 15px;">
<i class="fas fa-list"></i> Daftar Pelanggaran
</h3>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -83,6 +84,7 @@
@endforeach
</tbody>
</table>
</div>
@else
<div class="empty-state">
<i class="fas fa-inbox"></i>

View File

@ -1,4 +1,4 @@
@extends('layouts.app')
@extends('layouts.app')
@section('content')
<div class="page-header">
@ -85,6 +85,7 @@ class="btn btn-secondary"
{{-- Table Section --}}
<div class="content-box">
@if($materis->count() > 0)
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -139,6 +140,7 @@ class="btn btn-sm btn-warning" title="Edit">
@endforeach
</tbody>
</table>
</div>
{{-- Pagination --}}
<div style="margin-top: 14px;">

View File

@ -0,0 +1,259 @@
{{-- resources/views/admin/mesin/import/index.blade.php --}}
@extends('layouts.app')
@section('title', 'Import Absensi Mesin')
@section('content')
<div class="page-header">
<h2><i class="fas fa-file-import"></i> Import Absensi Fingerprint</h2>
<a href="{{ route('admin.kegiatan.index') }}" class="btn btn-sm btn-secondary">
<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-times-circle"></i> {{ session('error') }}
</div>
@endif
@if($belumMapping > 0)
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle"></i>
<strong>{{ $belumMapping }}</strong> ID mesin belum dipetakan ke santri.
Data santri tersebut tidak akan tersimpan saat import.
<a href="{{ route('admin.mesin.mapping-santri.index') }}"
class="btn btn-sm btn-warning" style="margin-left:8px;">
Lengkapi Mapping
</a>
</div>
@endif
{{-- Info Cara Kerja --}}
<div class="content-box" style="margin-bottom:14px">
<h4 style="margin:0 0 12px;color:var(--primary-color)">
<i class="fas fa-info-circle"></i> Cara Kerja Matching
</h4>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px">
<div style="background:#FFF3E8;border:1px solid #FDBA74;border-radius:8px;padding:12px">
<div style="font-size:18px;margin-bottom:4px"></div>
<div style="font-weight:700;color:#C05621;margin-bottom:4px">Mesin Sholat</div>
<div style="font-size:12px;color:#374151;line-height:1.6">
JK1 Masuk Shubuh<br>
JK1 Pulang Dhuhur<br>
JK2 Masuk Ashar<br>
JK2 Pulang Maghrib<br>
Lb Masuk Isya
</div>
</div>
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:8px;padding:12px">
<div style="font-size:18px;margin-bottom:4px"></div>
<div style="font-weight:700;color:#1D4ED8;margin-bottom:4px">Mesin Ngaji</div>
<div style="font-size:12px;color:#374151;line-height:1.6">
JK1 Masuk Ngaji Shubuh<br>
JK1 Pulang sekolah<br>
JK2 Masuk Ngaji Siang<br>
JK2 Pulang Ngaji Maghrib<br>
Lb Masuk Ngaji Malam
</div>
</div>
<div style="background:#FEF9C3;border:1px solid #FDE68A;border-radius:8px;padding:12px">
<div style="font-size:18px;margin-bottom:4px"></div>
<div style="font-weight:700;color:#92400E;margin-bottom:4px">Konflik</div>
<div style="font-size:12px;color:#374151;line-height:1.6">
Jika santri sudah punya absen<br>
Manual/RFID, sistem akan<br>
minta pilihan Anda di halaman<br>
preview sebelum disimpan.
</div>
</div>
</div>
</div>
{{-- Form Upload --}}
<div class="content-box">
<h4 style="margin:0 0 16px">
<i class="fas fa-upload" style="color:var(--primary-color)"></i>
Upload File GLog.txt
</h4>
<form action="{{ route('admin.mesin.import.preview') }}"
method="POST"
enctype="multipart/form-data">
@csrf
<div class="form-group" style="margin-bottom:16px">
<label style="font-weight:600;font-size:14px">
<i class="fas fa-database" style="color:#1A56DB"></i>
File GLog.txt <span style="color:red">*</span>
</label>
<input type="file" name="file_glog" class="form-control"
accept=".txt,.csv,.xls,.xlsx" required>
<small class="text-muted">
Export dari software Eppos: menu
<strong>Report Download Log</strong>.
Pilih periode tanggal yang diinginkan lalu export.
</small>
@error('file_glog')
<div style="color:#EF4444;font-size:12px;margin-top:4px">
{{ $message }}
</div>
@enderror
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px">
<div class="form-group" style="margin:0">
<label style="font-weight:600;font-size:14px">
<i class="fas fa-clock"></i>
Toleransi Sebelum Kegiatan (menit)
</label>
<input type="number" name="tol_sebelum" class="form-control"
value="15" min="0" max="60">
<small class="text-muted">
Scan diterima berapa menit <strong>sebelum</strong> kegiatan mulai
</small>
</div>
<div class="form-group" style="margin:0">
<label style="font-weight:600;font-size:14px">
<i class="fas fa-clock"></i>
Toleransi Sesudah Kegiatan (menit)
</label>
<input type="number" name="tol_sesudah" class="form-control"
value="10" min="0" max="60">
<small class="text-muted">
Scan diterima berapa menit <strong>setelah</strong> kegiatan selesai
</small>
</div>
</div>
<div class="form-group" style="margin-bottom:20px">
<div style="display:flex;align-items:center;gap:10px;
background:#F8FAFC;border:1px solid #E2E8F0;
border-radius:8px;padding:12px">
<input type="checkbox" name="isi_alpa" value="1"
id="isiAlpa" checked
style="width:18px;height:18px;cursor:pointer">
<label for="isiAlpa" style="margin:0;cursor:pointer;font-weight:500">
Isi <strong>Alpa</strong> otomatis untuk santri yang tidak scan
<span style="color:#6B7280;font-size:12px;display:block;font-weight:400">
Jika tidak dicentang, santri yang tidak scan tidak akan diisi apapun
</span>
</label>
</div>
</div>
{{-- Strategi penanganan konflik --}}
<div class="form-group" style="margin-bottom:20px">
<label style="font-weight:600;font-size:14px;margin-bottom:8px;display:block">
<i class="fas fa-exchange-alt" style="color:#DC2626"></i>
Jika ada konflik dengan data absen yang sudah ada:
</label>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px">
<label style="background:#DCFCE7;border:2px solid #86EFAC;border-radius:8px;padding:12px;cursor:pointer;display:flex;align-items:flex-start;gap:8px;margin:0"
id="lbl_mesin">
<input type="radio" name="conflict_strategy" value="mesin" checked
style="margin-top:3px;width:16px;height:16px">
<div>
<div style="font-weight:700;color:#166534">Utamakan Data Mesin</div>
<div style="font-size:11px;color:#374151;margin-top:2px">
Timpa semua data lama dengan hasil mesin. Paling umum dipakai.
</div>
</div>
</label>
<label style="background:#DBEAFE;border:2px solid #93C5FD;border-radius:8px;padding:12px;cursor:pointer;display:flex;align-items:flex-start;gap:8px;margin:0"
id="lbl_exist">
<input type="radio" name="conflict_strategy" value="exist"
style="margin-top:3px;width:16px;height:16px">
<div>
<div style="font-weight:700;color:#1D4ED8">Pertahankan Data Lama</div>
<div style="font-size:11px;color:#374151;margin-top:2px">
Data Manual/RFID yang sudah ada tidak diubah.
</div>
</div>
</label>
<label style="background:#FEF9C3;border:2px solid #FDE68A;border-radius:8px;padding:12px;cursor:pointer;display:flex;align-items:flex-start;gap:8px;margin:0"
id="lbl_manual">
<input type="radio" name="conflict_strategy" value="manual"
style="margin-top:3px;width:16px;height:16px">
<div>
<div style="font-weight:700;color:#92400E">Pilih Manual per Sel</div>
<div style="font-size:11px;color:#374151;margin-top:2px">
Review tiap konflik satu per satu di halaman preview.
</div>
</div>
</label>
</div>
</div>
<div style="display:flex;gap:10px;align-items:center">
<button type="submit" class="btn btn-primary"
style="padding:10px 28px;font-size:15px">
<i class="fas fa-search"></i> Preview Data Import
</button>
<a href="{{ route('admin.mesin.mapping-santri.index') }}"
class="btn btn-secondary" style="padding:10px 20px">
<i class="fas fa-link"></i> Kelola Mapping Santri
</a>
</div>
</form>
</div>
{{-- Riwayat --}}
@if($riwayat->count() > 0)
<div class="content-box" style="margin-top:14px">
<h4 style="margin:0 0 12px">
<i class="fas fa-history"></i> Riwayat Import Terakhir
</h4>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Waktu</th>
<th>Jumlah Scan</th>
<th>Berhasil</th>
<th>Konflik Selesai</th>
<th>Duplikat</th>
<th>Tanpa Mapping</th>
<th>Oleh</th>
</tr>
</thead>
<tbody>
@foreach($riwayat as $r)
<tr>
<td>{{ $r->created_at->format('d/m/Y H:i') }}</td>
<td>{{ number_format($r->jumlah_scan) }}</td>
<td>
<span class="badge badge-success">{{ $r->berhasil }}</span>
</td>
<td>
@if($r->konflik_selesai > 0)
<span class="badge badge-warning">{{ $r->konflik_selesai }}</span>
@else <span style="color:#9CA3AF">-</span>
@endif
</td>
<td>
@if($r->dilewati > 0)
<span class="badge badge-secondary">{{ $r->dilewati }}</span>
@else <span style="color:#9CA3AF">-</span>
@endif
</td>
<td>
@if($r->no_santri > 0)
<span class="badge badge-danger">{{ $r->no_santri }}</span>
@else <span style="color:#9CA3AF">-</span>
@endif
</td>
<td style="color:#6B7280">{{ $r->user->name ?? '-' }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endif
@endsection

View File

@ -0,0 +1,492 @@
{{-- resources/views/admin/mesin/import/preview.blade.php --}}
@extends('layouts.app')
@section('title', 'Preview Import Absensi')
@section('content')
@php
use Carbon\Carbon;
$statusStyle = [
'Hadir' => ['bg'=>'#DCFCE7','c'=>'#166534','ic'=>'✅'],
'Terlambat' => ['bg'=>'#FEF9C3','c'=>'#92400E','ic'=>'⏰'],
'Alpa' => ['bg'=>'#FEE2E2','c'=>'#991B1B','ic'=>'❌'],
'Pulang' => ['bg'=>'#FFF7ED','c'=>'#9A3412','ic'=>'🏠'],
'Izin' => ['bg'=>'#F3E8FF','c'=>'#6B21A8','ic'=>'📋'],
'Sakit' => ['bg'=>'#E0F2FE','c'=>'#0C4A6E','ic'=>'🏥'],
];
// ── 1. Kolom kegiatan: UNIK, diurutkan waktu_mulai ───────────────────────────
$kegiatanCols = collect($hasilEnriched)
->flatMap(fn($h) => $h['rows'])
->unique('kegiatan_id')
->sortBy('waktu_mulai')
->values()
->map(fn($r) => [
'kegiatan_id' => $r['kegiatan_id'],
'nama' => $r['nama_kegiatan'],
'waktu_mulai' => $r['waktu_mulai'],
]);
// ── 2. Susun data: [tanggal][id_santri_or_mesin] = data ──────────────────────
$byTanggalSantri = [];
$santriList = []; // untuk urutan santri konsisten
foreach ($hasilEnriched as $h) {
$tgl = $h['tanggal'];
$key = $h['id_santri'] ?? ('__'.$h['id_mesin']);
$byTanggalSantri[$tgl][$key] = $h;
if (!isset($santriList[$key])) {
$santriList[$key] = [
'nama' => $h['nama_web'] ?? $h['nama_mesin'],
'kelas' => $h['kelas'] ?? '-',
'status' => $h['match_status'],
];
}
}
ksort($byTanggalSantri);
// ── 3. Statistik ─────────────────────────────────────────────────────────────
$allRows = collect($hasilEnriched)->flatMap(fn($h) => $h['rows']);
$totalKonflik = $allRows->where('is_conflict', true)->count();
$hadir = $allRows->where('status_final','Hadir')->count();
$terlambat = $allRows->where('status_final','Terlambat')->count();
$alpa = $allRows->where('status_final','Alpa')->count();
$notMapped = collect($hasilEnriched)->where('match_status','NOT_MAPPED')->count();
@endphp
<style>
/* Sticky top bar */
.top-bar {
position: sticky; top: 0; z-index: 50;
background: #0F172A; color: #F1F5F9;
padding: 8px 16px; display: flex; align-items: center;
gap: 10px; flex-wrap: wrap; box-shadow: 0 2px 8px rgba(0,0,0,.3);
font-size: 13px;
}
.chip {
border-radius: 8px; padding: 4px 10px;
text-align: center; min-width: 56px;
font-size: 11px; line-height: 1.3;
}
.chip .n { font-size: 17px; font-weight: 700; display: block }
.btn-act {
border: none; border-radius: 6px; padding: 6px 12px;
cursor: pointer; font-weight: 600; font-size: 12px;
white-space: nowrap;
}
.btn-save {
border: none; border-radius: 8px; padding: 8px 20px;
cursor: pointer; font-weight: 700; font-size: 13px;
color: #fff; white-space: nowrap; transition: background .2s;
}
/* Matrix table */
.wrap { overflow-x: auto }
.mx { border-collapse: collapse; font-size: 12px; width: 100% }
.mx th, .mx td {
border: 1px solid #E5E7EB; padding: 0;
white-space: nowrap;
}
/* Sticky kolom tanggal + nama */
.col-tgl {
position: sticky; left: 0; z-index: 4;
background: #F8FAFC; min-width: 90px;
border-right: 2px solid #CBD5E1;
padding: 6px 10px; font-size: 11px;
}
.col-nama {
position: sticky; left: 90px; z-index: 4;
background: #F8FAFC; min-width: 130px;
border-right: 2px solid #CBD5E1;
padding: 6px 10px;
}
/* Header kegiatan (rotate) */
.th-wrap {
writing-mode: vertical-rl;
transform: rotate(180deg);
display: flex; align-items: center; justify-content: flex-end;
gap: 2px; height: 80px; padding: 5px 6px;
}
/* Status pill */
.pill {
display: inline-block; border-radius: 8px;
padding: 2px 6px; font-size: 10px; font-weight: 700;
}
/* Konflik cell */
.conf-cell { background: #FFF5F5 !important; border: 2px solid #FCA5A5 !important }
.conf-wrap { display: flex; flex-direction: column }
.conf-opt {
padding: 4px 8px; cursor: pointer;
font-size: 11px; display: flex; align-items: center; gap: 4px;
border-bottom: 1px solid #F1F5F9; transition: background .12s;
}
.conf-opt:last-child { border-bottom: none }
.conf-opt:hover { background: #F8FAFC }
.sel-m { background: #DCFCE7 !important }
.sel-e { background: #DBEAFE !important }
/* Date separator row */
.date-sep td {
background: #1E293B; color: #94A3B8; font-weight: 700;
font-size: 11px; padding: 5px 12px; border-bottom: 2px solid #334155;
}
/* Alternating santri rows */
.row-alt { background: #FAFAFA }
/* Sticky header */
.th-sticky {
position: sticky; top: 52px; z-index: 6;
background: #1E293B;
}
.th-tgl { position: sticky; left: 0; z-index: 8 }
.th-nama { position: sticky; left: 90px; z-index: 8 }
</style>
<form action="{{ route('admin.mesin.import.store') }}" method="POST" id="frm">
@csrf
<input type="hidden" name="conflict_strategy" value="manual" id="stratInput">
{{-- Error flash --}}
@if(session('error'))
<div style="background:#FEE2E2;border:1px solid #FCA5A5;border-left:4px solid #DC2626;
padding:12px 16px;font-size:13px;color:#991B1B">
<strong> Error:</strong> {{ session('error') }}
</div>
@endif
{{-- ── TOP BAR ──────────────────────────────────────────────────────────────── --}}
<div class="top-bar">
<div style="flex:1;min-width:160px">
<div style="color:#64748B;font-size:10px;text-transform:uppercase;letter-spacing:1px">
Preview Import
</div>
<div style="font-weight:700;font-size:14px;margin-top:1px">
{{ count($santriList) }} santri
@if($totalKonflik > 0)
· <span style="color:#FCA5A5" id="lbl">
<span id="cnt">{{ $totalKonflik }}</span> konflik perlu diselesaikan
</span>
@else
· <span style="color:#86EFAC"> Siap disimpan</span>
@endif
</div>
</div>
{{-- Stat chips --}}
<div class="chip" style="background:#DCFCE7;color:#166534">
<span class="n">{{ $hadir }}</span>Hadir
</div>
<div class="chip" style="background:#FEF9C3;color:#92400E">
<span class="n">{{ $terlambat }}</span>Telat
</div>
<div class="chip" style="background:#FEE2E2;color:#991B1B">
<span class="n">{{ $alpa }}</span>Alpa
</div>
@if($totalKonflik > 0)
<div class="chip" style="background:#FEE2E2;color:#DC2626">
<span class="n" id="chip">{{ $totalKonflik }}</span>Konflik
</div>
@endif
@if($notMapped > 0)
<div class="chip" style="background:#FFF3E8;color:#C05621">
<span class="n">{{ $notMapped }}</span>Blm Map
</div>
@endif
{{-- Conflict actions --}}
@if($totalKonflik > 0)
<div style="display:flex;flex-direction:column;gap:3px;font-size:11px;color:#94A3B8">
Konflik:
</div>
<button type="button" class="btn-act" style="background:#DCFCE7;color:#166534"
onclick="resolveAll('m');document.getElementById('stratInput').value='mesin'">👆 Mesin</button>
<button type="button" class="btn-act" style="background:#DBEAFE;color:#1D4ED8"
onclick="resolveAll('e');document.getElementById('stratInput').value='exist'">🔒 Lama</button>
@endif
{{-- Save --}}
<button type="button" class="btn-save" id="saveBtn"
style="background:{{ $totalKonflik > 0 ? '#64748B' : 'linear-gradient(135deg,#166534,#22C55E)' }}"
{{ $totalKonflik > 0 ? 'disabled' : '' }}
onclick="submitForm()">
@if($totalKonflik > 0) Selesaikan konflik dulu
@else 💾 Simpan ke Database @endif
</button>
<a href="{{ route('admin.mesin.import.index') }}"
class="btn-act" style="background:#374151;color:#F1F5F9;text-decoration:none">
Kembali
</a>
</div>
{{-- Legenda --}}
<div style="display:flex;gap:8px;flex-wrap:wrap;padding:8px 16px;
background:#F8FAFC;border-bottom:1px solid #E5E7EB;font-size:11px">
<span style="color:#6B7280;font-weight:600">Status:</span>
@foreach($statusStyle as $st => $s)
<span class="pill" style="background:{{$s['bg']}};color:{{$s['c']}}">
{{ $s['ic'] }} {{ $st }}
</span>
@endforeach
<span style="color:#9CA3AF;margin-left:4px">| = tidak ada data</span>
<span style="border:2px solid #FCA5A5;border-radius:4px;padding:1px 6px;color:#991B1B">
Konflik
</span>
<span style="color:#9CA3AF">= ada data berbeda, pilih salah satu</span>
@if($notMapped > 0)
<span style="margin-left:auto">
⚠️ {{ $notMapped }} belum dipetakan
<a href="{{ route('admin.mesin.mapping-santri.index') }}" target="_blank">
Lengkapi Mapping
</a>
</span>
@endif
</div>
{{-- ── MATRIX TABLE ──────────────────────────────────────────────────────────── --}}
<div class="wrap">
<table class="mx">
{{-- Sticky header --}}
<thead>
<tr>
{{-- Tanggal header --}}
<th class="th-sticky th-tgl"
style="min-width:90px;padding:8px 10px;text-align:left;
color:#94A3B8;font-size:10px;border-right:2px solid #334155">
Tanggal
</th>
{{-- Nama header --}}
<th class="th-sticky th-nama"
style="min-width:130px;padding:8px 10px;text-align:left;
color:#94A3B8;font-size:10px;border-right:2px solid #334155">
Santri
</th>
{{-- Kolom kegiatan UNIK, diurutkan waktu --}}
@foreach($kegiatanCols as $kg)
<th class="th-sticky" style="min-width:70px;vertical-align:bottom">
<div class="th-wrap">
<span style="color:#F1F5F9;font-size:10px;font-weight:600">
{{ $kg['nama'] }}
</span>
<span style="color:#64748B;font-size:9px">
{{ $kg['waktu_mulai'] }}
</span>
</div>
</th>
@endforeach
</tr>
</thead>
{{-- Body: iterasi per tanggal, lalu per santri --}}
<tbody>
@foreach($byTanggalSantri as $tgl => $santriRows)
@php
$tglCarbon = Carbon::parse($tgl);
$tglLabel = $tglCarbon->locale('id')->isoFormat('ddd, D MMM');
$isOdd = ($loop->index % 2 === 1);
@endphp
{{-- Setiap tanggal: satu baris per santri --}}
@foreach($santriList as $key => $info)
@php
$data = $santriRows[$key] ?? null;
$rowBg = ($loop->index % 2 === 0) ? 'white' : '#FAFAFA';
if ($data && $data['match_status'] === 'NOT_MAPPED') $rowBg = '#FFF5F5';
@endphp
<tr style="background:{{ $rowBg }}">
{{-- Kolom Tanggal (hanya tampil di baris pertama per tanggal) --}}
<td class="col-tgl" style="background:{{ $rowBg }}">
@if($loop->first)
<strong style="color:#1E293B">{{ $tglLabel }}</strong>
@endif
</td>
{{-- Kolom Nama --}}
<td class="col-nama" style="background:{{ $rowBg }}">
@if($info['status'] === 'NOT_MAPPED')
<div style="font-size:10px;font-weight:700;color:#DC2626"> BELUM MAP</div>
<div style="font-size:10px;color:#9CA3AF">{{ $info['nama'] }}</div>
@else
<div style="font-weight:600;color:#1F2937;font-size:12px">
{{ $info['nama'] }}
</div>
<div style="font-size:10px;color:#9CA3AF">{{ $info['kelas'] }}</div>
@endif
</td>
{{-- Kolom per kegiatan --}}
@foreach($kegiatanCols as $kg)
@php
$row = $data
? collect($data['rows'])->firstWhere('kegiatan_id', $kg['kegiatan_id'])
: null;
$sf = $row['status_final'] ?? null;
$st = $sf ? ($statusStyle[$sf] ?? null) : null;
$isConf = $row['is_conflict'] ?? false;
$key2 = "{$kg['kegiatan_id']}_{$data['id_santri']}_{$tgl}";
@endphp
<td style="padding:0;text-align:center;vertical-align:middle;min-width:70px"
class="{{ $isConf ? 'conf-cell' : '' }}">
@if(!$data || !$row || $sf === null)
{{-- Tidak ada data --}}
<span style="color:#D1D5DB"></span>
@elseif($isConf)
{{-- ── KONFLIK: 2 pilihan ── --}}
@php
$ex = $row['existing'];
$exSt = $statusStyle[$ex['status']] ?? ['bg'=>'#F9FAFB','c'=>'#6B7280','ic'=>'?'];
@endphp
<div class="conf-wrap" data-key="{{ $key2 }}">
{{-- Pilihan mesin --}}
<div class="conf-opt" data-ch="m" onclick="pick('{{ $key2 }}','m',this)">
<input type="radio" name="conflict_choices[{{ $key2 }}]"
value="mesin" id="cm_{{ $key2 }}" style="display:none">
<span>👆</span>
<div>
<span class="pill" style="background:{{$st['bg']}};color:{{$st['c']}}">
{{$st['ic']}} {{$sf}}
</span>
<div style="font-size:9px;color:#6B7280">
Mesin·{{ $row['jam_scan'] ?? '-' }}
</div>
</div>
</div>
{{-- Pilihan lama --}}
<div class="conf-opt" data-ch="e" onclick="pick('{{ $key2 }}','e',this)">
<input type="radio" name="conflict_choices[{{ $key2 }}]"
value="exist" id="ce_{{ $key2 }}" style="display:none">
<span>🔒</span>
<div>
<span class="pill" style="background:{{$exSt['bg']}};color:{{$exSt['c']}}">
{{$exSt['ic']}} {{$ex['status']}}
</span>
<div style="font-size:9px;color:#6B7280">
{{ $ex['metode'] ?? 'Manual' }}
@if($ex['waktu']) ·{{ substr($ex['waktu'],0,5) }}@endif
</div>
</div>
</div>
<div style="background:#FEE2E2;padding:1px 4px;font-size:9px;
color:#DC2626;font-weight:700;text-align:center">
pilih
</div>
</div>
@else
{{-- ── Normal ── --}}
<div style="padding:5px 3px">
<span class="pill" style="background:{{$st['bg']}};color:{{$st['c']}}">
{{$st['ic']}} {{$sf}}
</span>
@if($row['jam_scan'])
<div style="font-size:9px;color:#9CA3AF;margin-top:1px">
{{ $row['jam_scan'] }}
@if(($row['selisih_menit'] ?? 0) > 0)
<span style="color:#F59E0B">+{{ $row['selisih_menit'] }}m</span>
@endif
</div>
@endif
</div>
@endif
</td>
@endforeach
</tr>
@endforeach
@endforeach
</tbody>
{{-- Repeat header di bawah untuk tabel panjang --}}
<tfoot>
<tr>
<th style="background:#1E293B;color:#94A3B8;font-size:10px;
padding:6px 10px;border-right:2px solid #334155;
position:sticky;left:0">Tanggal</th>
<th style="background:#1E293B;color:#94A3B8;font-size:10px;
padding:6px 10px;border-right:2px solid #334155;
position:sticky;left:90px">Santri</th>
@foreach($kegiatanCols as $kg)
<th style="background:#1E293B;color:#94A3B8;font-size:9px;padding:4px 6px">
{{ $kg['nama'] }}
</th>
@endforeach
</tr>
</tfoot>
</table>
</div>
{{-- Footer stats --}}
<div style="padding:8px 16px;background:#F8FAFC;border-top:1px solid #E5E7EB;
font-size:11px;color:#6B7280;display:flex;gap:12px;flex-wrap:wrap">
<span>📊 {{ count($santriList) }} santri · {{ count($byTanggalSantri) }} hari</span>
<span> {{ $hadir }} Hadir</span>
<span> {{ $terlambat }} Terlambat</span>
<span> {{ $alpa }} Alpa</span>
@if($totalKonflik > 0)
<span style="color:#DC2626"> {{ $totalKonflik }} Konflik</span>
@endif
<span style="margin-left:auto">
Toleransi: {{ $tolSebelum }}m sebelum / {{ $tolSesudah }}m sesudah
</span>
</div>
</form>
<script>
const totalK = {{ $totalKonflik }};
const done = new Set();
function pick(key, ch, el) {
const wrap = el.closest('.conf-wrap');
wrap.querySelectorAll('.conf-opt').forEach(o => {
o.classList.remove('sel-m','sel-e');
});
el.classList.add(ch === 'm' ? 'sel-m' : 'sel-e');
const radio = document.getElementById((ch==='m'?'cm_':'ce_') + key);
if (radio) radio.checked = true;
done.add(key);
updateUI();
}
function resolveAll(ch) {
document.querySelectorAll('.conf-wrap').forEach(wrap => {
const key = wrap.dataset.key;
const opt = wrap.querySelector('[data-ch="'+ch+'"]');
if (opt) pick(key, ch, opt);
});
}
function submitForm() {
const btn = document.getElementById('saveBtn');
btn.disabled = true;
btn.style.background = '#64748B';
btn.textContent = '⏳ Menyimpan...';
document.getElementById('frm').submit();
}
function updateUI() {
const rem = totalK - done.size;
const btn = document.getElementById('saveBtn');
const lbl = document.getElementById('lbl');
const chip = document.getElementById('chip');
if (rem <= 0) {
btn.disabled = false;
btn.style.background = 'linear-gradient(135deg,#166534,#22C55E)';
btn.textContent = '💾 Simpan ke Database';
if (lbl) lbl.innerHTML = '<span style="color:#86EFAC">✅ Semua konflik selesai</span>';
if (chip) chip.textContent = '0';
} else {
btn.disabled = true;
btn.style.background = '#64748B';
btn.textContent = '⏳ Selesaikan ' + rem + ' konflik';
if (lbl) lbl.innerHTML = '<span style="color:#FCA5A5">⚡ <span id="cnt">'+rem+'</span> konflik</span>';
if (chip) chip.textContent = rem;
}
}
</script>
@endsection

View File

@ -0,0 +1,229 @@
{{-- resources/views/admin/mesin/mapping-santri/index.blade.php --}}
@extends('layouts.app')
@section('title', 'Mapping ID Fingerprint')
@section('content')
<div class="page-header" style="display:flex;align-items:center;justify-content:space-between">
<h2><i class="fas fa-link"></i> Mapping ID Fingerprint</h2>
<a href="{{ route('admin.kegiatan.index') }}" class="btn btn-sm btn-secondary">
<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-times-circle"></i> {{ session('error') }}</div>
@endif
{{-- Auto-import dari INFO.XLS --}}
<div class="content-box" style="margin-bottom:14px">
<h4 style="margin:0 0 6px;font-size:15px">
<i class="fas fa-magic" style="color:#166534"></i> Auto-Import dari INFO.XLS
</h4>
<p style="margin:0 0 12px;color:#6B7280;font-size:13px">
Upload INFO.XLS dari mesin Eppos sistem otomatis cocokkan nama dan buat mapping.
Nama yang tidak cocok otomatis ke dropdown untuk dipilih manual.
</p>
<form action="{{ route('admin.mesin.mapping-santri.import-info') }}"
method="POST" enctype="multipart/form-data"
style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
@csrf
<input type="file" name="file_info" accept=".xls,.xlsx" required
class="form-control" style="max-width:320px">
<button type="submit" class="btn btn-success">
<i class="fas fa-magic"></i> Auto-Import
</button>
</form>
</div>
{{-- Statistik --}}
@php
$total = $mappings->count();
$terpetakan = $mappings->filter(fn($m) => !empty($m->id_santri))->count();
$belum = $total - $terpetakan;
@endphp
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:14px">
<div style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:10px;padding:14px;text-align:center">
<div style="font-size:28px;font-weight:700;color:#1F2937">{{ $total }}</div>
<div style="font-size:12px;color:#6B7280">Total ID Mesin</div>
</div>
<div style="background:#DCFCE7;border:1px solid #BBF7D0;border-radius:10px;padding:14px;text-align:center">
<div style="font-size:28px;font-weight:700;color:#166534">{{ $terpetakan }}</div>
<div style="font-size:12px;color:#166534">Terpetakan</div>
</div>
<div style="background:{{ $belum > 0 ? '#FEE2E2' : '#DCFCE7' }};border:1px solid {{ $belum > 0 ? '#FECACA' : '#BBF7D0' }};border-radius:10px;padding:14px;text-align:center">
<div style="font-size:28px;font-weight:700;color:{{ $belum > 0 ? '#991B1B' : '#166534' }}">{{ $belum }}</div>
<div style="font-size:12px;color:{{ $belum > 0 ? '#991B1B' : '#166534' }}">
{{ $belum > 0 ? 'Belum Dipetakan' : 'Semua Terpetakan' }}
</div>
</div>
</div>
{{-- Peringatan jika ada yang belum --}}
@if($belum > 0)
<div style="background:#FEF9C3;border:1px solid #FDE68A;border-left:4px solid #F59E0B;
border-radius:8px;padding:12px 16px;margin-bottom:14px;font-size:13px">
<strong>âš  {{ $belum }} ID mesin belum dipetakan ke santri.</strong>
Data scan santri tersebut tidak akan tersimpan saat import absensi.
Pilih santri yang sesuai dari dropdown di bawah.
</div>
@endif
{{-- Tabel Mapping --}}
<div class="content-box" style="padding:0;overflow:hidden;margin-bottom:14px">
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th style="width:70px;text-align:center">ID Mesin</th>
<th>Nama di Mesin</th>
<th style="width:90px">Dept/Kel</th>
<th>Santri Web yang Dipetakan</th>
<th style="width:110px;text-align:center">Status</th>
<th style="width:70px;text-align:center">Hapus</th>
</tr>
</thead>
<tbody>
@forelse($mappings as $m)
<tr style="background:{{ empty($m->id_santri) ? '#FFFBEB' : 'white' }}">
{{-- ID Mesin --}}
<td style="text-align:center">
<strong style="font-family:monospace;font-size:15px;color:#1D4ED8">
{{ $m->id_mesin }}
</strong>
</td>
{{-- Nama di Mesin --}}
<td>
<div style="font-weight:600;color:#1F2937">{{ $m->nama_mesin ?? '-' }}</div>
@if($m->catatan)
<div style="font-size:11px;color:#9CA3AF">{{ $m->catatan }}</div>
@endif
</td>
{{-- Dept --}}
<td style="color:#6B7280;font-size:12px">{{ $m->dept_mesin ?? '-' }}</td>
{{-- Dropdown Santri --}}
<td>
<form action="{{ route('admin.mesin.mapping-santri.update', $m->id) }}"
method="POST" style="margin:0">
@csrf @method('PUT')
<div style="display:flex;align-items:center;gap:8px">
<select name="id_santri"
class="form-control"
style="font-size:13px;
border-color:{{ empty($m->id_santri) ? '#FCA5A5' : '#D1D5DB' }};
background:{{ empty($m->id_santri) ? '#FFF5F5' : 'white' }}"
onchange="this.form.submit()">
<option value="">-- Pilih Santri --</option>
@foreach($santris as $s)
<option value="{{ $s->id_santri }}"
{{ $m->id_santri == $s->id_santri ? 'selected' : '' }}>
{{ $s->nama_lengkap }}
</option>
@endforeach
</select>
@if(!empty($m->id_santri))
<i class="fas fa-check-circle" style="color:#22C55E;font-size:16px" title="Sudah dipetakan"></i>
@else
<i class="fas fa-exclamation-circle" style="color:#EF4444;font-size:16px" title="Belum dipetakan"></i>
@endif
</div>
</form>
</td>
{{-- Status --}}
<td style="text-align:center">
@if(!empty($m->id_santri))
<span style="background:#DCFCE7;color:#166534;border-radius:12px;
padding:3px 10px;font-size:11px;font-weight:700">
Terpetakan
</span>
@else
<span style="background:#FEE2E2;color:#991B1B;border-radius:12px;
padding:3px 10px;font-size:11px;font-weight:700">
âš  Belum
</span>
@endif
</td>
{{-- Hapus --}}
<td style="text-align:center">
<form action="{{ route('admin.mesin.mapping-santri.destroy', $m->id) }}"
method="POST"
onsubmit="return confirm('Hapus mapping ID Mesin {{ $m->id_mesin }} ({{ $m->nama_mesin }})?')">
@csrf @method('DELETE')
<button type="submit"
class="btn btn-sm btn-danger"
style="padding:4px 10px">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="6" style="text-align:center;padding:40px;color:#9CA3AF">
<i class="fas fa-inbox" style="font-size:32px;display:block;margin-bottom:8px"></i>
Belum ada mapping. Upload INFO.XLS di atas untuk mulai.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
{{-- Tambah Manual --}}
<div class="content-box">
<h4 style="margin:0 0 6px;font-size:15px">
<i class="fas fa-plus-circle" style="color:#1D4ED8"></i> Tambah Mapping Manual
</h4>
<p style="margin:0 0 12px;color:#6B7280;font-size:13px">
Untuk santri yang baru daftar ke mesin setelah INFO.XLS diekspor,
atau santri yang nama di mesin sangat berbeda dari nama di sistem.
</p>
<form action="{{ route('admin.mesin.mapping-santri.store') }}" method="POST">
@csrf
<div style="display:grid;grid-template-columns:120px 160px 1fr auto;gap:10px;align-items:end">
<div class="form-group" style="margin:0">
<label style="font-size:12px;font-weight:600">ID Mesin <span style="color:red">*</span></label>
<input type="text" name="id_mesin" class="form-control"
placeholder="cth: 8" required value="{{ old('id_mesin') }}"
style="font-family:monospace;font-size:15px;font-weight:700">
@error('id_mesin')
<p style="color:#EF4444;font-size:11px;margin:3px 0 0">{{ $message }}</p>
@enderror
</div>
<div class="form-group" style="margin:0">
<label style="font-size:12px;font-weight:600">Nama di Mesin</label>
<input type="text" name="nama_mesin" class="form-control"
placeholder="cth: ilham" value="{{ old('nama_mesin') }}">
</div>
<div class="form-group" style="margin:0">
<label style="font-size:12px;font-weight:600">Santri Web</label>
<select name="id_santri" class="form-control">
<option value="">-- Pilih Santri (bisa diisi nanti) --</option>
@foreach($santris as $s)
<option value="{{ $s->id_santri }}"
{{ old('id_santri') == $s->id_santri ? 'selected' : '' }}>
{{ $s->nama_lengkap }}
</option>
@endforeach
</select>
</div>
<div>
<button type="submit" class="btn btn-primary" style="white-space:nowrap;padding:9px 18px">
<i class="fas fa-plus"></i> Tambah
</button>
</div>
</div>
</form>
</div>
@endsection

View File

@ -1,4 +1,4 @@
{{-- resources/views/admin/pembayaran-spp/index.blade.php --}}
{{-- resources/views/admin/pembayaran-spp/index.blade.php --}}
@extends('layouts.app')
@section('title', 'Pembayaran SPP')
@ -20,7 +20,7 @@
<div class="content-box">
{{-- ── Filter ── --}}
{{-- ── Filter ── --}}
<div style="background:#f8f9fa;padding:14px;border-radius:8px;margin-bottom:14px;">
<form method="GET" action="{{ route('admin.pembayaran-spp.index') }}" style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;">
<input type="hidden" name="tab" value="{{ $tab }}">
@ -66,7 +66,7 @@
</form>
</div>
{{-- ── KPI Cards ── --}}
{{-- ── KPI Cards ── --}}
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin-bottom:14px;">
<div class="kpi-card" style="background:linear-gradient(135deg,#667eea,#764ba2);">
<div><div class="kpi-label">Total Santri Aktif</div><div class="kpi-val">{{ $totalSantriAll }}</div><div class="kpi-sub">Periode ini</div></div>
@ -90,7 +90,7 @@
</div>
</div>
{{-- ── Tab Navigation ── --}}
{{-- ── Tab Navigation ── --}}
<div style="display:flex;gap:6px;margin-bottom:14px;border-bottom:2px solid #e0e0e0;flex-wrap:wrap;">
<a href="{{ route('admin.pembayaran-spp.index', array_merge(request()->except('tab'),['tab'=>'belum-bayar'])) }}"
class="spp-tab {{ $tab==='belum-bayar'?'spp-tab-danger':'spp-tab-outline-danger' }}">
@ -109,7 +109,7 @@ class="spp-tab {{ $tab==='sudah-bayar'?'spp-tab-success':'spp-tab-outline-succes
</a>
</div>
{{-- ── Action Buttons ── --}}
{{-- ── Action Buttons ── --}}
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;flex-wrap:wrap;gap:8px;">
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<a href="{{ route('admin.pembayaran-spp.generate') }}" class="btn btn-warning btn-sm"><i class="fas fa-cogs"></i> Generate SPP</a>
@ -118,12 +118,13 @@ class="spp-tab {{ $tab==='sudah-bayar'?'spp-tab-success':'spp-tab-outline-succes
</div>
<div style="font-size:11px;color:#666;">
Periode: <strong>{{ $bulanIndo[$bulan]??'' }} {{ $tahun }}</strong>
@if($tab==='sudah-bayar') &nbsp;·&nbsp; <i class="fas fa-sort-amount-down"></i> Terbaru bayar di atas @endif
@if($tab==='sudah-bayar') &nbsp;·&nbsp; <i class="fas fa-sort-amount-down"></i> Terbaru bayar di atas @endif
</div>
</div>
{{-- ── Table ── --}}
{{-- ── Table ── --}}
<div style="overflow-x:auto;">
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -194,7 +195,7 @@ class="spp-tab {{ $tab==='sudah-bayar'?'spp-tab-success':'spp-tab-outline-succes
</td>
@endif
{{-- ── Aksi (1 baris, tombol ikon) ── --}}
{{-- ── Aksi (1 baris, tombol ikon) ── --}}
<td class="text-center" style="white-space:nowrap;">
@if($item['pembayaran'])
{{-- Riwayat selalu ada --}}
@ -250,8 +251,9 @@ class="btn btn-sm btn-primary" title="Buat Tagihan"><i class="fas fa-plus"></i>
</tbody>
</table>
</div>
</div>
{{-- ── Pagination ── --}}
{{-- ── Pagination ── --}}
@if($totalPages>1)
<div style="margin-top:14px;display:flex;justify-content:center;align-items:center;gap:10px;">
@if($currentPage>1)
@ -265,7 +267,7 @@ class="btn btn-sm btn-primary" title="Buat Tagihan"><i class="fas fa-plus"></i>
@endif
</div>
{{-- ── Modal Catat Cicilan ── --}}
{{-- ── Modal Catat Cicilan ── --}}
<div id="modalCicilan" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:9999;align-items:center;justify-content:center;">
<div style="background:#fff;border-radius:12px;padding:24px;width:100%;max-width:400px;box-shadow:0 20px 60px rgba(0,0,0,.3);margin:16px;">
<h4 style="margin:0 0 4px;"><i class="fas fa-coins" style="color:#9c27b0;"></i> Catat Cicilan</h4>

View File

@ -1,4 +1,4 @@
{{-- resources/views/admin/pembayaran-spp/riwayat.blade.php --}}
{{-- resources/views/admin/pembayaran-spp/riwayat.blade.php --}}
@extends('layouts.app')
@section('title', 'Riwayat Pembayaran SPP')
@ -56,6 +56,7 @@
</h4>
<div style="overflow-x: auto;">
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -116,6 +117,7 @@ class="btn btn-sm btn-warning"
</tbody>
</table>
</div>
</div>
<!-- Pagination -->
@if($pembayaranSpp->hasPages())

View File

@ -1,4 +1,4 @@
@extends('layouts.app')
@extends('layouts.app')
@section('title', 'Pembinaan & Sanksi')
@ -37,6 +37,8 @@
<strong>Info:</strong> Konten akan ditampilkan sesuai urutan. Drag atau ubah nomor urutan untuk mengatur tampilan.
</div>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -107,6 +109,8 @@ class="btn btn-sm btn-warning"
@endforeach
</tbody>
</table>
</div>
@else
<div class="empty-state">
<i class="fas fa-folder-open"></i>

View File

@ -1,4 +1,4 @@
{{-- resources/views/admin/riwayat_pelanggaran/index.blade.php --}}
{{-- resources/views/admin/riwayat_pelanggaran/index.blade.php --}}
@extends('layouts.app')
@section('title', 'Riwayat Pelanggaran')
@ -155,6 +155,7 @@ class="form-control"
</div>
@if($data->isNotEmpty())
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -233,6 +234,7 @@ class="btn btn-sm btn-danger"
@endforeach
</tbody>
</table>
</div>
<!-- Pagination -->
<div style="margin-top: 14px;">

View File

@ -1,4 +1,4 @@
{{-- resources/views/admin/riwayat_pelanggaran/riwayat_santri.blade.php --}}
{{-- resources/views/admin/riwayat_pelanggaran/riwayat_santri.blade.php --}}
@extends('layouts.app')
@section('title', 'Riwayat Pelanggaran - ' . $santri->nama_lengkap)
@ -68,6 +68,7 @@
</div>
@if($riwayat->isNotEmpty())
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
@ -145,6 +146,7 @@ class="btn btn-sm btn-danger"
@endforeach
</tbody>
</table>
</div>
<!-- Pagination -->
<div style="margin-top: 14px;">

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