From 7ce8586b8ca015a474c75e407c3711dfa7da5a09 Mon Sep 17 00:00:00 2001 From: HelgaFaisa <158024195+HelgaFaisa@users.noreply.github.com> Date: Mon, 2 Mar 2026 10:17:09 +0700 Subject: [PATCH] siswa --- DOKUMENTASI_RBAC.md | 249 +++ DOKUMENTASI_REDESIGN_JADWAL_ABSENSI.md | 215 +++ sim-pkpps/.env.example | 2 +- .../Console/Commands/CleanSantriAccounts.php | 107 ++ .../Admin/AbsensiKegiatanController.php | 231 ++- .../Controllers/Admin/CapaianController.php | 825 ++++----- .../Controllers/Admin/KartuRfidController.php | 123 +- .../Controllers/Admin/KegiatanController.php | 531 +++--- .../Controllers/Admin/KelasController.php | 477 ++---- .../Admin/LaporanKegiatanController.php | 8 +- .../Admin/PembayaranSppController.php | 492 +++--- .../Controllers/Admin/UangSakuController.php | 320 ++-- .../Http/Controllers/Admin/UserController.php | 412 +++-- .../Api/ApiAbsensiKegiatanController.php | 6 +- .../Controllers/Api/ApiAuthController.php | 183 +- .../Controllers/Api/ApiBeritaController.php | 4 +- .../Controllers/Api/ApiCapaianController.php | 31 +- .../Api/ApiKepulanganController.php | 89 +- .../Api/ApiKesehatanController.php | 6 +- .../Api/ApiPengajuanKepulanganController.php | 23 +- .../Http/Controllers/Api/ApiSppController.php | 8 +- .../Controllers/Api/ApiUangSakuController.php | 4 +- .../Api/PelanggaranApiController.php | 6 +- .../Controllers/Auth/AdminAuthController.php | 38 +- .../Auth/AdminForgotPasswordController.php | 242 +++ .../Controllers/Auth/SantriAuthController.php | 69 +- .../Http/Controllers/DashboardController.php | 255 +-- .../RiwayatKegiatanSantriController.php | 443 +++-- .../Santri/SantriBeritaController.php | 18 +- .../Santri/SantriCapaianController.php | 267 ++- .../Santri/SantriCapaianInputController.php | 180 ++ .../Santri/SantriKepulanganController.php | 150 +- .../Santri/SantriKesehatanController.php | 168 +- .../Santri/SantriPelanggaranController.php | 66 +- .../Santri/SantriPembinaanController.php | 72 + .../Santri/SantriProfileController.php | 92 +- .../Santri/SantriUangSakuController.php | 137 +- sim-pkpps/app/Http/Kernel.php | 8 +- .../app/Http/Middleware/CheckSantriAuth.php | 42 + .../Middleware/RedirectIfAuthenticated.php | 22 +- sim-pkpps/app/Http/Middleware/Role.php | 50 +- sim-pkpps/app/Mail/OtpMail.php | 43 + sim-pkpps/app/Models/AbsensiKegiatan.php | 4 + sim-pkpps/app/Models/PasswordResetOtp.php | 30 + sim-pkpps/app/Models/PembayaranSpp.php | 228 ++- sim-pkpps/app/Models/Santri.php | 20 +- sim-pkpps/app/Models/SantriAccount.php | 63 + sim-pkpps/app/Models/User.php | 51 +- .../app/Providers/AppServiceProvider.php | 4 +- .../app/Services/CapaianAccessService.php | 119 ++ sim-pkpps/bootstrap/cache/.gitignore | 2 - sim-pkpps/bootstrap/cache/packages.php | 74 + sim-pkpps/bootstrap/cache/services.php | 257 +++ sim-pkpps/composer.json | 4 +- sim-pkpps/composer.lock | 721 +++++++- sim-pkpps/config/app.php | 2 +- sim-pkpps/config/auth.php | 8 + sim-pkpps/config/sanctum.php | 2 +- ...26_02_24_000001_update_users_role_enum.php | 44 + ..._terlambat_pulang_to_absensi_kegiatans.php | 26 + ...25_100001_create_santri_accounts_table.php | 39 + ...02_remove_santri_wali_from_users_table.php | 43 + ...3436_add_khatam_to_santris_status_enum.php | 26 + ...00000_create_password_reset_otps_table.php | 25 + sim-pkpps/database/seeders/DatabaseSeeder.php | 9 +- sim-pkpps/database/seeders/SantriSeeder.php | 329 ++++ sim-pkpps/public/css/app.css | 910 +++++++--- .../admin/auth/forgot_password.blade.php | 311 ++++ .../views/admin/auth/login.blade.php | 610 ++++--- .../views/admin/auth/register.blade.php | 394 ++++- .../views/admin/auth/reset_password.blade.php | 521 ++++++ .../views/admin/auth/verify_otp.blade.php | 419 +++++ .../views/admin/berita/create.blade.php | 8 +- .../views/admin/berita/edit.blade.php | 10 +- .../views/admin/berita/index.blade.php | 10 +- .../views/admin/berita/show.blade.php | 12 +- .../views/admin/berita/statistik.blade.php | 358 ++-- .../admin/capaian/akses-santri.blade.php | 277 +++ .../views/admin/capaian/create.blade.php | 10 +- .../views/admin/capaian/dashboard.blade.php | 938 ++++------ .../admin/capaian/detail-materi.blade.php | 8 +- .../views/admin/capaian/edit.blade.php | 8 +- .../views/admin/capaian/index.blade.php | 15 +- .../admin/capaian/riwayat-santri.blade.php | 8 +- .../views/admin/capaian/show.blade.php | 14 +- .../admin/dashboard/_alert-panel.blade.php | 84 +- .../dashboard/_jadwal-kegiatan.blade.php | 138 +- .../admin/dashboard/_kpi-cards.blade.php | 27 +- .../admin/dashboard/_ringkasan-spp.blade.php | 278 ++- .../admin/dashboard/_tren-kehadiran.blade.php | 16 +- .../views/admin/dashboardAdmin.blade.php | 197 ++- .../kategori_pelanggaran/create.blade.php | 19 +- .../admin/kategori_pelanggaran/edit.blade.php | 2 +- .../kategori_pelanggaran/index.blade.php | 11 +- .../admin/kategori_pelanggaran/show.blade.php | 6 +- .../admin/kegiatan/absensi/edit.blade.php | 97 ++ .../admin/kegiatan/absensi/index.blade.php | 199 --- .../admin/kegiatan/absensi/input.blade.php | 465 +++-- .../admin/kegiatan/absensi/rekap.blade.php | 120 +- .../admin/kegiatan/data/create.blade.php | 169 +- .../admin/kegiatan/data/dashboard.blade.php | 1505 +++++------------ .../kegiatan/data/dashboard.blade.php.bak | 1155 +++++++++++++ .../views/admin/kegiatan/data/edit.blade.php | 172 +- .../views/admin/kegiatan/data/index.blade.php | 340 ++-- .../data/partials/detail-modal.blade.php | 261 ++- .../views/admin/kegiatan/data/show.blade.php | 3 +- .../admin/kegiatan/kartu/cetak.blade.php | 692 +++----- .../admin/kegiatan/kartu/daftar.blade.php | 2 +- .../admin/kegiatan/kartu/index.blade.php | 6 +- .../admin/kegiatan/kategori/create.blade.php | 2 +- .../admin/kegiatan/kategori/index.blade.php | 7 +- .../laporan/analisis-kegiatan.blade.php | 13 +- .../kegiatan/laporan/detail-santri.blade.php | 14 +- .../admin/kegiatan/laporan/index.blade.php | 1122 ++++-------- .../kegiatan/laporan/pdf-template.blade.php | 188 -- .../laporan/santri-perlu-perhatian.blade.php | 4 +- .../kegiatan/riwayat/detail-santri.blade.php | 11 +- .../admin/kegiatan/riwayat/edit.blade.php | 1 + .../admin/kegiatan/riwayat/index.blade.php | 341 ++-- .../admin/kegiatan/riwayat/show.blade.php | 510 +++--- .../views/admin/kelas/create.blade.php | 5 +- .../views/admin/kelas/edit.blade.php | 2 +- .../views/admin/kelas/index.blade.php | 18 +- .../admin/kelas/kelompok/create.blade.php | 10 +- .../admin/kelas/kelompok/index.blade.php | 22 +- .../admin/kelas/kenaikan/index.blade.php | 261 ++- .../admin/kelas/kenaikan/preview.blade.php | 2 +- .../views/admin/kelas/show.blade.php | 31 +- .../views/admin/kepulangan/create.blade.php | 38 +- .../views/admin/kepulangan/edit.blade.php | 6 +- .../views/admin/kepulangan/index.blade.php | 62 +- .../admin/kepulangan/over-limit.blade.php | 46 +- .../admin/kepulangan/pengajuan.blade.php | 26 +- .../views/admin/kepulangan/settings.blade.php | 36 +- .../views/admin/kepulangan/show.blade.php | 44 +- .../admin/kepulangan/surat-pdf.blade.php | 633 ++----- .../kesehatan-santri/cetak-surat.blade.php | 309 ++-- .../admin/kesehatan-santri/create.blade.php | 24 +- .../admin/kesehatan-santri/edit.blade.php | 4 +- .../admin/kesehatan-santri/index.blade.php | 16 +- .../admin/kesehatan-santri/riwayat.blade.php | 14 +- .../admin/kesehatan-santri/show.blade.php | 36 +- .../views/admin/keuangan/index.blade.php | 6 +- .../views/admin/keuangan/laporan.blade.php | 2 +- .../klasifikasi_pelanggaran/create.blade.php | 2 +- .../klasifikasi_pelanggaran/edit.blade.php | 2 +- .../klasifikasi_pelanggaran/index.blade.php | 4 +- .../klasifikasi_pelanggaran/show.blade.php | 6 +- .../views/admin/materi/create.blade.php | 2 +- .../views/admin/materi/index.blade.php | 6 +- .../views/admin/materi/show.blade.php | 2 +- .../pembayaran-spp/cetak-bukti.blade.php | 96 +- .../admin/pembayaran-spp/create.blade.php | 41 +- .../views/admin/pembayaran-spp/edit.blade.php | 6 + .../admin/pembayaran-spp/generate.blade.php | 4 +- .../admin/pembayaran-spp/index.blade.php | 605 ++++--- .../admin/pembayaran-spp/riwayat.blade.php | 12 +- .../admin/pembinaan_sanksi/create.blade.php | 22 +- .../admin/pembinaan_sanksi/edit.blade.php | 4 +- .../admin/pembinaan_sanksi/index.blade.php | 16 +- .../admin/pembinaan_sanksi/show.blade.php | 12 +- .../riwayat_pelanggaran/create.blade.php | 4 +- .../admin/riwayat_pelanggaran/edit.blade.php | 6 +- .../admin/riwayat_pelanggaran/index.blade.php | 10 +- .../riwayat_santri.blade.php | 14 +- .../admin/riwayat_pelanggaran/show.blade.php | 16 +- .../views/admin/santri/edit.blade.php | 4 +- .../views/admin/santri/form.blade.php | 84 +- .../views/admin/santri/index.blade.php | 20 +- .../views/admin/santri/show.blade.php | 6 +- .../views/admin/semester/index.blade.php | 2 +- .../views/admin/semester/show.blade.php | 2 +- .../views/admin/uang-saku/create.blade.php | 86 +- .../views/admin/uang-saku/edit.blade.php | 2 +- .../views/admin/uang-saku/index.blade.php | 314 +++- .../views/admin/uang-saku/riwayat.blade.php | 16 +- .../views/admin/uang-saku/show.blade.php | 2 +- .../admin/users/admin_accounts.blade.php | 82 + .../views/admin/users/admin_form.blade.php | 92 + .../admin/users/create_account.blade.php | 103 +- .../admin/users/santri_accounts.blade.php | 110 +- .../views/admin/users/wali_accounts.blade.php | 118 +- .../views/auth/auth_layout.blade.php | 2 +- sim-pkpps/resources/views/dashboard.blade.php | 438 ++++- .../resources/views/emails/otp.blade.php | 72 + .../resources/views/errors/500.blade.php | 16 +- .../views/layouts/admin-sidebar.blade.php | 253 ++- .../resources/views/layouts/app.blade.php | 34 +- .../layouts/santri-wali-sidebar.blade.php | 2 +- .../views/santri/auth/login.blade.php | 402 ++++- .../views/santri/berita/index.blade.php | 145 +- .../views/santri/berita/show.blade.php | 181 +- .../views/santri/capaian/index.blade.php | 1361 +++++++++++---- .../views/santri/capaian/input.blade.php | 561 ++++++ .../views/santri/capaian/show.blade.php | 509 ++++-- .../views/santri/dashboardSantri.blade.php | 32 +- .../views/santri/kegiatan/index.blade.php | 1007 +++++++---- .../views/santri/kegiatan/show.blade.php | 376 ++-- .../views/santri/kepulangan/index.blade.php | 259 +-- .../views/santri/kepulangan/show.blade.php | 360 ++-- .../views/santri/kesehatan/index.blade.php | 345 ++-- .../views/santri/kesehatan/show.blade.php | 232 ++- .../views/santri/pelanggaran/index.blade.php | 301 +++- .../santri/pelanggaran/kategori.blade.php | 162 +- .../views/santri/pelanggaran/show.blade.php | 315 +++- .../views/santri/pembinaan/index.blade.php | 112 ++ .../views/santri/pembinaan/show.blade.php | 200 +++ .../views/santri/profil/edit.blade.php | 137 -- .../views/santri/profil/index.blade.php | 193 ++- .../views/santri/uang-saku/index.blade.php | 6 +- .../views/santri/uang-saku/show.blade.php | 158 +- .../views/vendor/pagination/custom.blade.php | 2 +- sim-pkpps/routes/api.php | 1 + sim-pkpps/routes/web.php | 762 +++++---- .../PXrYfIXNI72JHE9S1NZbUFoyth9vqFvKL6KyoCmo | 1 + .../W9dMX0RfDLWFrZS2HbxtDWBffJrA83krW0lfpO89 | 1 - .../Y54Vu4cy67sOplMv4hXd3z1pBYUxI4wkIimSOCU0 | 1 - .../bMt1MAzDp3hTYDJU21Sdr95boItoZ6tg72l2C84K | 1 + .../srDuPSz27z6DXbYXTxnnfLux53MNHbzWxJJ1p8fB | 1 + .../vuUULo2XyKVPJR3ExFEy5RBUSwH4LKelsHtJQtty | 1 + .../01b0e5cbf2905c7d5dca9a9491253f10.php | 285 ++++ .../06fca957364bb0817f0642de2283c8f2.php | 24 - .../072da14fbb17a3a780dd86ec6fb2a220.php | 272 --- .../073cce1e4275a70a155262cc88a8b34c.php | 424 +++++ .../075d452fbecb627adfa4fe88bdcc349d.php | 192 +++ .../0b444879160a4358a991decaae9d874a.php | 30 +- .../0de4b336ab2f30a867e60d193f16a139.php | 74 + .../110d795af2097eabaa249a0a3180facc.php | 1486 +++++----------- .../1b526580b5a04160d92877aaefed413d.php | 235 +++ .../1bcee449d95fd7f4af3e834ca3f373a2.php | 325 ++++ .../1f84328a43f16c0ffe12040f0b03985b.php | 178 +- .../212bf5dca28f0dcc9f56b52f7942f096.php | 286 ++++ .../212db3ecdbae68eaa4799a7f40fb56c4.php | 106 ++ .../25e18337d272f9d0e44384a50ffc0f2e.php | 303 +++- .../298812f3fe81e3128e867c854e89bc12.php | 132 -- .../313421d53bce467b3badbcdb3a6a679a.php | 1109 ++++-------- .../31a477eb975396d9e3dfe350a914f63d.php | 172 ++ .../35ee7d3140dd2cb469794c46a4c597db.php | 225 +++ .../42e0614621fd9cbad7786f12bdf5b5c9.php | 131 ++ .../42f2440b5041e2c50e56ee5815767d08.php | 117 -- .../43f661f38fbd4f777442ca71c4728fc9.php | 138 -- .../452c34a3ceda4cc1b53f0fe5d3c51b99.php | 149 ++ .../452f946ed1770bfcc5196cad0e925feb.php | 2 +- .../46ab65c9284ea5ee3a8118881424f90c.php | 94 + .../46bb60633fcb00aaf0cad4b6e821a574.php | 22 +- .../4c2e61e495793889f762efcce89628d8.php | 150 ++ .../544582142bd9e4d4f7bbbcaa26d145c0.php | 291 ---- .../563c872ad4366bc1346c69477cba06bb.php | 303 ++++ .../607a71cb495c0d383120d3708d480a58.php | 90 + .../61b2b3d52902c76d6d7da1d77d4437fc.php | 198 +++ .../63b4c8088e9d5267c951fcd038ca0865.php | 111 ++ .../66e2096cb3c35849e326885249110f62.php | 283 ++++ .../695de351b8780267f68a6fd7b87f5cf4.php | 175 ++ .../6aa14f1db90c01f76f48db151799fa79.php | 8 +- .../77c75f40397716bf408e123abf899979.php | 15 +- .../84c709f0b9be2a14b454115d6ed83ed6.php | 2 +- .../87643fe3b359872f8ce3c66a3684eab2.php | 66 +- .../8ad222870a8f90b78a42e5732b483452.php | 117 +- .../976af14efec5a4c421facc1124c343ce.php | 183 ++ .../995c109454d8c33c3f0f95e2c6a9b380.php | 195 --- .../9b446f86a5179b2559bd927fbd573416.php | 110 +- .../9b58324ce92b42a8f9c43eb5d7bb3bd2.php | 341 ++-- .../9e6b554cfe7514dae505b2a53e813665.php | 758 +++++++++ .../9f6c966bd42e04489d0204182b4da92f.php | 44 +- .../a2c2df15d8045affb5587e5e17d7ec54.php | 110 -- .../a35bdf7f5682e1163e4e422bf7ed3156.php | 86 + .../a35f413aa8a908440b9e4ff5ddc0661f.php | 107 -- .../a3a0962d896f35dffe41a6f25b643d59.php | 108 ++ .../a55ee0bd4bb0ff61d001bcba38d0f720.php | 22 +- .../a57ca5aebaa00c1108d7def34e6d05ac.php | 26 +- .../a61ae242bc62fe3cd747ce05f2dd3b6f.php | 143 -- .../a866f51eee86f2c6a47752a971068445.php | 18 +- .../aabb507314ec42ae5256d820d4e0537c.php | 6 +- .../b130696dd20b50a4932a7b40f32091c1.php | 460 +++++ .../b189c66d92090748551bf01fbdf8d452.php | 373 ++++ .../b403b03cb1c55b487864ba0976faeb7c.php | 65 +- .../b57c8ebaae757897909cf3bf9bada182.php | 8 +- .../b589ad70c41b0134b2606cd7ad069430.php | 84 +- .../b69985eddcba7aac0c7eb4717aa1fb3f.php | 8 +- .../b7b759c2606e969dbccc40ee62c8d61c.php | 566 +++++++ .../b97350e57d8d92217317f5f41c8d18a5.php | 558 ++++++ .../bbf90b40396229b9af5ccf996fe33770.php | 229 --- .../bf740845a4b1e169ca4b9cfa2f601c79.php | 281 ++- .../c3c6dc2be7f59812542546330f0a67c6.php | 253 +++ .../c5e1a22ff4ed6201da5132bb8f82a4a3.php | 392 ----- .../c9c645ef9ca26f7f8150d4f3aae0f194.php | 271 +++ .../ca86f9a367836ddcd7e72ac784957920.php | 755 +++++++++ .../d4e7e4904877ce0931546d0340a9f3aa.php | 381 ----- .../da2024b11925d6ab85a546fcc453c861.php | 138 +- .../e126e1062982b7622152c3d631f3ccc4.php | 232 +++ .../e4596b8fb6973ba3dcdcaa2fc4b535f5.php | 95 ++ .../e696522374dbc0de12cb6db367c6eb8c.php | 922 ++++------ .../e74e8e013b6b8f514e13c2f787a411bb.php | 231 +++ .../e7a3eb2c7c53a4310ad6e32050798b86.php | 389 +++++ .../e9890b3d59ea4f35d70423e626925754.php | 620 ++++--- .../ebc7a691e6b8897799515b881de4297a.php | 199 --- .../ef3052ff8b6e3fc5621ceb75a6dd6413.php | 1077 ++++++++++++ .../f00f9dae67317e274040c9fcaec81e32.php | 363 ---- .../f2968196d7f31ca7e2832869d2e2aef0.php | 247 ++- .../f465b0f88dac56f9ba36f3334d1955e4.php | 107 -- .../f5f9ca518d99a1b7d925ef13ad0bbbe1.php | 315 ++++ .../f61d0f67d6967e731c17960b0dff77d3.php | 191 ++- .../f7d8d067fc835ce54b0dc2d499afcbbd.php | 618 ++++--- .../f8ddcf549eb0df0ba7c7d10975ba0892.php | 280 --- .../f9de4ee507702ddee56d80d4870eb11b.php | 168 -- sim_mobile/assets/images/logo.png | Bin 0 -> 261516 bytes sim_mobile/lib/core/api/api_service.dart | 19 + sim_mobile/lib/core/widgets/berita_image.dart | 6 +- .../features/absensi/pages/absensi_page.dart | 122 +- .../absensi/pages/detail_minggu_page.dart | 134 +- .../absensi/pages/riwayat_bulan_page.dart | 118 +- .../widgets/absensi_timeline_item.dart | 110 +- .../absensi/widgets/summary_card.dart | 16 +- sim_mobile/lib/features/auth/login_page.dart | 531 +++--- .../features/berita/berita_detail_page.dart | 28 +- .../lib/features/berita/berita_page.dart | 46 +- .../presentation/pages/capaian_page.dart | 491 +++--- .../pages/detail_capaian_page.dart | 144 +- .../presentation/pages/materi_list_page.dart | 48 +- .../pages/semester_report_page.dart | 211 ++- .../presentation/widgets/kelas_badge.dart | 56 +- .../widgets/kelas_list_modal.dart | 48 +- .../features/dashboard/dashboard_page.dart | 1000 +++++++---- .../pages/kepulangan_detail_page.dart | 118 +- .../presentation/pages/kepulangan_page.dart | 40 +- .../pages/pengajuan_kepulangan_page.dart | 72 +- .../widgets/durasi_preview_widget.dart | 40 +- .../presentation/widgets/kepulangan_card.dart | 74 +- .../presentation/widgets/kuota_indicator.dart | 56 +- .../widgets/kuota_warning_widget.dart | 30 +- .../kesehatan/kesehatan_detail_page.dart | 89 +- .../features/kesehatan/kesehatan_page.dart | 78 +- sim_mobile/lib/features/main_shell.dart | 104 ++ .../pelanggaran/kategori_pelanggaran_tab.dart | 116 +- .../pelanggaran/pelanggaran_page.dart | 10 +- .../pelanggaran/pembinaan_sanksi_tab.dart | 68 +- .../riwayat_pelanggaran_detail_page.dart | 211 +-- .../pelanggaran/riwayat_pelanggaran_tab.dart | 130 +- .../lib/features/profil/profil_page.dart | 195 +-- .../lib/features/splash/splash_screen.dart | 89 +- sim_mobile/lib/features/spp/spp_page.dart | 142 +- .../features/uang_saku/uang_saku_page.dart | 166 +- sim_mobile/lib/main.dart | 21 +- sim_mobile/pubspec.lock | 140 +- sim_mobile/pubspec.yaml | 4 + 345 files changed, 39070 insertions(+), 21896 deletions(-) create mode 100644 DOKUMENTASI_RBAC.md create mode 100644 DOKUMENTASI_REDESIGN_JADWAL_ABSENSI.md create mode 100644 sim-pkpps/app/Console/Commands/CleanSantriAccounts.php create mode 100644 sim-pkpps/app/Http/Controllers/Auth/AdminForgotPasswordController.php create mode 100644 sim-pkpps/app/Http/Controllers/Santri/SantriCapaianInputController.php create mode 100644 sim-pkpps/app/Http/Controllers/Santri/SantriPembinaanController.php create mode 100644 sim-pkpps/app/Http/Middleware/CheckSantriAuth.php create mode 100644 sim-pkpps/app/Mail/OtpMail.php create mode 100644 sim-pkpps/app/Models/PasswordResetOtp.php create mode 100644 sim-pkpps/app/Models/SantriAccount.php create mode 100644 sim-pkpps/app/Services/CapaianAccessService.php delete mode 100644 sim-pkpps/bootstrap/cache/.gitignore create mode 100644 sim-pkpps/bootstrap/cache/packages.php create mode 100644 sim-pkpps/bootstrap/cache/services.php create mode 100644 sim-pkpps/database/migrations/2026_02_24_000001_update_users_role_enum.php create mode 100644 sim-pkpps/database/migrations/2026_02_25_083524_add_terlambat_pulang_to_absensi_kegiatans.php create mode 100644 sim-pkpps/database/migrations/2026_02_25_100001_create_santri_accounts_table.php create mode 100644 sim-pkpps/database/migrations/2026_02_25_100002_remove_santri_wali_from_users_table.php create mode 100644 sim-pkpps/database/migrations/2026_02_28_153436_add_khatam_to_santris_status_enum.php create mode 100644 sim-pkpps/database/migrations/2026_03_01_000000_create_password_reset_otps_table.php create mode 100644 sim-pkpps/database/seeders/SantriSeeder.php create mode 100644 sim-pkpps/resources/views/admin/auth/forgot_password.blade.php create mode 100644 sim-pkpps/resources/views/admin/auth/reset_password.blade.php create mode 100644 sim-pkpps/resources/views/admin/auth/verify_otp.blade.php create mode 100644 sim-pkpps/resources/views/admin/capaian/akses-santri.blade.php create mode 100644 sim-pkpps/resources/views/admin/kegiatan/absensi/edit.blade.php delete mode 100644 sim-pkpps/resources/views/admin/kegiatan/absensi/index.blade.php create mode 100644 sim-pkpps/resources/views/admin/kegiatan/data/dashboard.blade.php.bak delete mode 100644 sim-pkpps/resources/views/admin/kegiatan/laporan/pdf-template.blade.php create mode 100644 sim-pkpps/resources/views/admin/users/admin_accounts.blade.php create mode 100644 sim-pkpps/resources/views/admin/users/admin_form.blade.php create mode 100644 sim-pkpps/resources/views/emails/otp.blade.php create mode 100644 sim-pkpps/resources/views/santri/capaian/input.blade.php create mode 100644 sim-pkpps/resources/views/santri/pembinaan/index.blade.php create mode 100644 sim-pkpps/resources/views/santri/pembinaan/show.blade.php delete mode 100644 sim-pkpps/resources/views/santri/profil/edit.blade.php create mode 100644 sim-pkpps/storage/framework/sessions/PXrYfIXNI72JHE9S1NZbUFoyth9vqFvKL6KyoCmo delete mode 100644 sim-pkpps/storage/framework/sessions/W9dMX0RfDLWFrZS2HbxtDWBffJrA83krW0lfpO89 delete mode 100644 sim-pkpps/storage/framework/sessions/Y54Vu4cy67sOplMv4hXd3z1pBYUxI4wkIimSOCU0 create mode 100644 sim-pkpps/storage/framework/sessions/bMt1MAzDp3hTYDJU21Sdr95boItoZ6tg72l2C84K create mode 100644 sim-pkpps/storage/framework/sessions/srDuPSz27z6DXbYXTxnnfLux53MNHbzWxJJ1p8fB create mode 100644 sim-pkpps/storage/framework/sessions/vuUULo2XyKVPJR3ExFEy5RBUSwH4LKelsHtJQtty create mode 100644 sim-pkpps/storage/framework/views/01b0e5cbf2905c7d5dca9a9491253f10.php delete mode 100644 sim-pkpps/storage/framework/views/06fca957364bb0817f0642de2283c8f2.php delete mode 100644 sim-pkpps/storage/framework/views/072da14fbb17a3a780dd86ec6fb2a220.php create mode 100644 sim-pkpps/storage/framework/views/073cce1e4275a70a155262cc88a8b34c.php create mode 100644 sim-pkpps/storage/framework/views/075d452fbecb627adfa4fe88bdcc349d.php create mode 100644 sim-pkpps/storage/framework/views/0de4b336ab2f30a867e60d193f16a139.php create mode 100644 sim-pkpps/storage/framework/views/1b526580b5a04160d92877aaefed413d.php create mode 100644 sim-pkpps/storage/framework/views/1bcee449d95fd7f4af3e834ca3f373a2.php create mode 100644 sim-pkpps/storage/framework/views/212bf5dca28f0dcc9f56b52f7942f096.php create mode 100644 sim-pkpps/storage/framework/views/212db3ecdbae68eaa4799a7f40fb56c4.php delete mode 100644 sim-pkpps/storage/framework/views/298812f3fe81e3128e867c854e89bc12.php create mode 100644 sim-pkpps/storage/framework/views/31a477eb975396d9e3dfe350a914f63d.php create mode 100644 sim-pkpps/storage/framework/views/35ee7d3140dd2cb469794c46a4c597db.php create mode 100644 sim-pkpps/storage/framework/views/42e0614621fd9cbad7786f12bdf5b5c9.php delete mode 100644 sim-pkpps/storage/framework/views/42f2440b5041e2c50e56ee5815767d08.php delete mode 100644 sim-pkpps/storage/framework/views/43f661f38fbd4f777442ca71c4728fc9.php create mode 100644 sim-pkpps/storage/framework/views/452c34a3ceda4cc1b53f0fe5d3c51b99.php create mode 100644 sim-pkpps/storage/framework/views/46ab65c9284ea5ee3a8118881424f90c.php create mode 100644 sim-pkpps/storage/framework/views/4c2e61e495793889f762efcce89628d8.php delete mode 100644 sim-pkpps/storage/framework/views/544582142bd9e4d4f7bbbcaa26d145c0.php create mode 100644 sim-pkpps/storage/framework/views/563c872ad4366bc1346c69477cba06bb.php create mode 100644 sim-pkpps/storage/framework/views/607a71cb495c0d383120d3708d480a58.php create mode 100644 sim-pkpps/storage/framework/views/61b2b3d52902c76d6d7da1d77d4437fc.php create mode 100644 sim-pkpps/storage/framework/views/63b4c8088e9d5267c951fcd038ca0865.php create mode 100644 sim-pkpps/storage/framework/views/66e2096cb3c35849e326885249110f62.php create mode 100644 sim-pkpps/storage/framework/views/695de351b8780267f68a6fd7b87f5cf4.php create mode 100644 sim-pkpps/storage/framework/views/976af14efec5a4c421facc1124c343ce.php delete mode 100644 sim-pkpps/storage/framework/views/995c109454d8c33c3f0f95e2c6a9b380.php create mode 100644 sim-pkpps/storage/framework/views/9e6b554cfe7514dae505b2a53e813665.php delete mode 100644 sim-pkpps/storage/framework/views/a2c2df15d8045affb5587e5e17d7ec54.php create mode 100644 sim-pkpps/storage/framework/views/a35bdf7f5682e1163e4e422bf7ed3156.php delete mode 100644 sim-pkpps/storage/framework/views/a35f413aa8a908440b9e4ff5ddc0661f.php create mode 100644 sim-pkpps/storage/framework/views/a3a0962d896f35dffe41a6f25b643d59.php delete mode 100644 sim-pkpps/storage/framework/views/a61ae242bc62fe3cd747ce05f2dd3b6f.php create mode 100644 sim-pkpps/storage/framework/views/b130696dd20b50a4932a7b40f32091c1.php create mode 100644 sim-pkpps/storage/framework/views/b189c66d92090748551bf01fbdf8d452.php create mode 100644 sim-pkpps/storage/framework/views/b7b759c2606e969dbccc40ee62c8d61c.php create mode 100644 sim-pkpps/storage/framework/views/b97350e57d8d92217317f5f41c8d18a5.php delete mode 100644 sim-pkpps/storage/framework/views/bbf90b40396229b9af5ccf996fe33770.php create mode 100644 sim-pkpps/storage/framework/views/c3c6dc2be7f59812542546330f0a67c6.php delete mode 100644 sim-pkpps/storage/framework/views/c5e1a22ff4ed6201da5132bb8f82a4a3.php create mode 100644 sim-pkpps/storage/framework/views/c9c645ef9ca26f7f8150d4f3aae0f194.php create mode 100644 sim-pkpps/storage/framework/views/ca86f9a367836ddcd7e72ac784957920.php delete mode 100644 sim-pkpps/storage/framework/views/d4e7e4904877ce0931546d0340a9f3aa.php create mode 100644 sim-pkpps/storage/framework/views/e126e1062982b7622152c3d631f3ccc4.php create mode 100644 sim-pkpps/storage/framework/views/e4596b8fb6973ba3dcdcaa2fc4b535f5.php create mode 100644 sim-pkpps/storage/framework/views/e74e8e013b6b8f514e13c2f787a411bb.php create mode 100644 sim-pkpps/storage/framework/views/e7a3eb2c7c53a4310ad6e32050798b86.php delete mode 100644 sim-pkpps/storage/framework/views/ebc7a691e6b8897799515b881de4297a.php create mode 100644 sim-pkpps/storage/framework/views/ef3052ff8b6e3fc5621ceb75a6dd6413.php delete mode 100644 sim-pkpps/storage/framework/views/f00f9dae67317e274040c9fcaec81e32.php delete mode 100644 sim-pkpps/storage/framework/views/f465b0f88dac56f9ba36f3334d1955e4.php create mode 100644 sim-pkpps/storage/framework/views/f5f9ca518d99a1b7d925ef13ad0bbbe1.php delete mode 100644 sim-pkpps/storage/framework/views/f8ddcf549eb0df0ba7c7d10975ba0892.php delete mode 100644 sim-pkpps/storage/framework/views/f9de4ee507702ddee56d80d4870eb11b.php create mode 100644 sim_mobile/assets/images/logo.png create mode 100644 sim_mobile/lib/features/main_shell.dart diff --git a/DOKUMENTASI_RBAC.md b/DOKUMENTASI_RBAC.md new file mode 100644 index 0000000..ba8a990 --- /dev/null +++ b/DOKUMENTASI_RBAC.md @@ -0,0 +1,249 @@ +# Dokumentasi RBAC (Role-Based Access Control) β€” SIM-PKPPS + +## 1. Ringkasan + +Sistem RBAC telah diimplementasikan untuk memisahkan hak akses 3 jenis admin: + +| Role | Deskripsi | +|------|-----------| +| **super_admin** | Akses penuh ke semua fitur, termasuk keuangan & SPP | +| **akademik** | Fokus data akademik: santri, kelas, kegiatan, materi, pelanggaran, berita | +| **pamong** | Fokus pengasuhan: uang saku, absensi, kesehatan, kepulangan | + +Role lain (`santri`, `wali`) tidak terpengaruh β€” tetap berjalan seperti sebelumnya. + +--- + +## 2. Akun Test + +| Role | Username / Email | Password | +|------|-----------------|----------| +| super_admin | `helga.faisa06@gmail.com` | `12345678` | +| akademik | `akademik@test.com` | `password123` | +| pamong | `pamong@test.com` | `password123` | + +Login di: **http://127.0.0.1:8000/admin/login** + +> **Penting:** Jika mengalami redirect loop di browser, bersihkan cookies terlebih dahulu (Ctrl+Shift+Delete β†’ Cookies). + +--- + +## 3. Matriks Hak Akses + +### Legenda +- βœ… = Akses penuh (CRUD) +- πŸ‘ = Hanya lihat (Read Only) +- ❌ = Tidak bisa akses + +| Fitur | super_admin | akademik | pamong | +|-------|:-----------:|:--------:|:------:| +| **Dashboard** | βœ… (semua data) | βœ… (tanpa SPP & uang saku) | βœ… (tanpa SPP) | +| **Data Santri** | βœ… | βœ… | πŸ‘ | +| **Kelas & Kelompok** | βœ… | βœ… | ❌ | +| **Kenaikan Kelas** | βœ… | βœ… | ❌ | +| **Kegiatan** | βœ… | βœ… | πŸ‘ | +| **Jadwal Kegiatan** | βœ… | βœ… | πŸ‘ | +| **Absensi Kegiatan** | βœ… | βœ… | βœ… | +| **Kartu RFID** | βœ… | βœ… | ❌ | +| **Capaian Santri** | βœ… | βœ… | βœ… | +| **Materi & Semester** | βœ… | βœ… | ❌ | +| **Pelanggaran** | βœ… | βœ… | ❌ | +| **Pembinaan & Sanksi** | βœ… | βœ… | ❌ | +| **Berita** | βœ… | βœ… | ❌ | +| **Kategori Kegiatan** | βœ… | βœ… | ❌ | +| **Rekap Absensi** | βœ… | βœ… | ❌ | +| **Riwayat Kegiatan** | βœ… | βœ… | ❌ | +| **Laporan Kegiatan** | βœ… | βœ… | ❌ | +| **Kesehatan Santri** | βœ… | βœ… | βœ… | +| **Kepulangan** | βœ… | βœ… | βœ… | +| **Uang Saku** | βœ… | ❌ | βœ… | +| **Keuangan Pondok** | βœ… | ❌ | ❌ | +| **Pembayaran SPP** | βœ… | ❌ | ❌ | +| **Manajemen User (santri/wali)** | βœ… | ❌ | ❌ | +| **Manajemen Akun Admin** | βœ… | ❌ | ❌ | + +--- + +## 4. File yang Dimodifikasi / Dibuat + +### Migration +| File | Status | +|------|--------| +| `database/migrations/2026_02_24_000001_update_users_role_enum.php` | **BARU** β€” Migrasi enum role | + +### Model +| File | Perubahan | +|------|-----------| +| `app/Models/User.php` | Tambah method: `isSuperAdmin()`, `isAkademik()`, `isPamong()`, `hasRole(...$roles)`. Update `isAdmin()` | + +### Middleware +| File | Perubahan | +|------|-----------| +| `app/Http/Middleware/Role.php` | **FIX KRITIS**: Signature `string $roles` β†’ `string ...$roles` (variadic). Hapus `explode()`. Redirect by role. | +| `app/Http/Middleware/RedirectIfAuthenticated.php` | Redirect sesuai role (adminβ†’admin.dashboard, santriβ†’santri.dashboard) | +| `app/Http/Middleware/Authenticate.php` | Dibersihkan (debug log dihapus) | +| `app/Http/Kernel.php` | Disable `AuthenticateSession` dan `ClearStuckSession` dari web middleware group | + +### Controller +| File | Perubahan | +|------|-----------| +| `app/Http/Controllers/Auth/AdminAuthController.php` | Register default = `super_admin`. Hapus session invalidation sebelum login. | +| `app/Http/Controllers/DashboardController.php` | Data dashboard kondisional per role. Fix nama kolom uang_saku. | +| `app/Http/Controllers/Admin/UserController.php` | Tambah 6 method CRUD untuk akun admin (akademik/pamong) | + +### Routes +| File | Perubahan | +|------|-----------| +| `routes/web.php` | Restrukturisasi menjadi 5 middleware group berdasarkan role | + +### Views +| File | Status | +|------|--------| +| `resources/views/layouts/admin-sidebar.blade.php` | **REWRITE** β€” Menu kondisional per role | +| `resources/views/admin/dashboardAdmin.blade.php` | Update β€” Seksi SPP/uang saku kondisional | +| `resources/views/admin/dashboard/_kpi-cards.blade.php` | Update β€” KPI "Belum Ada Wali" hanya super_admin | +| `resources/views/admin/dashboard/_alert-panel.blade.php` | Update β€” Alert SPP hanya super_admin | +| `resources/views/layouts/app.blade.php` | Update β€” `isAdmin()` menggantikan `role === 'admin'` | +| `resources/views/admin/users/admin_accounts.blade.php` | **BARU** β€” Daftar akun admin | +| `resources/views/admin/users/admin_form.blade.php` | **BARU** β€” Form create/edit akun admin | + +--- + +## 5. Langkah-Langkah yang Dilakukan + +### Langkah 1: Migrasi Database +```bash +php artisan migrate +``` +Migrasi mengubah enum `role` di tabel `users`: +- **Sebelum:** `admin`, `santri`, `wali` +- **Sesudah:** `super_admin`, `akademik`, `pamong`, `santri`, `wali` +- Semua user yang sebelumnya `admin` otomatis menjadi `super_admin`. + +### Langkah 2: Update Model User +Ditambahkan helper method di `User.php`: +```php +public function isSuperAdmin() { return $this->role === 'super_admin'; } +public function isAkademik() { return $this->role === 'akademik'; } +public function isPamong() { return $this->role === 'pamong'; } +public function isAdmin() { return in_array($this->role, ['super_admin', 'akademik', 'pamong']); } +public function hasRole() { return in_array($this->role, func_get_args()); } +``` + +### Langkah 3: Fix Middleware Role (Variadic Parameter) +**Root cause** dari redirect loop: Laravel memanggil middleware `role:super_admin,akademik,pamong` dengan 3 argumen terpisah, bukan 1 string. Signature harus menggunakan **variadic** (`...`): + +```php +// SALAH (hanya tangkap argumen pertama): +public function handle(Request $request, Closure $next, string $roles) + +// BENAR (tangkap semua argumen): +public function handle(Request $request, Closure $next, string ...$roles) +``` + +### Langkah 4: Restrukturisasi Routes +`routes/web.php` dibagi menjadi 5 middleware group: + +| Group | Middleware | Isi | +|-------|-----------|-----| +| 1 | `role:super_admin,akademik,pamong` | Dashboard, Logout | +| 2 | `role:super_admin` | Keuangan, SPP, Manajemen User | +| 3 | `role:super_admin,akademik` | Santri CUD, Kelas, Kegiatan CUD, Pelanggaran, Berita, dll | +| 4 | `role:super_admin,akademik,pamong` | Santri Read, Kegiatan Read, Absensi, Capaian, Kesehatan, Kepulangan | +| 5 | `role:super_admin,pamong` | Uang Saku | + +### Langkah 5: Update Sidebar & Dashboard +- Sidebar menampilkan menu sesuai role user yang login +- Dashboard menampilkan data sesuai hak akses role + +### Langkah 6: CRUD Akun Admin +Super admin dapat membuat akun akademik/pamong via UI: +- **URL:** `/admin/users/admin` +- Hanya super_admin yang bisa mengakses +- Tidak bisa membuat akun super_admin baru via UI (untuk keamanan) + +--- + +## 6. Cara Membuat Akun Admin Baru + +### Via UI (Recommended) +1. Login sebagai **super_admin** +2. Buka menu **Data Master β†’ Akun Admin** +3. Klik **Tambah Akun Admin** +4. Isi form (email, nama, password, pilih role akademik/pamong) +5. Klik **Simpan** + +### Via Tinker (Manual) +```bash +php artisan tinker +``` +```php +use App\Models\User; +use Illuminate\Support\Facades\Hash; + +User::create([ + 'name' => 'Nama User', + 'email' => 'user@example.com', + 'username' => 'user@example.com', + 'password' => Hash::make('password123'), + 'role' => 'akademik', // atau 'pamong' +]); +``` + +--- + +## 7. Troubleshooting + +### Redirect Loop (ERR_TOO_MANY_REDIRECTS) +1. **Bersihkan cookies browser** (Ctrl+Shift+Delete β†’ Cookies) +2. Jalankan: + ```bash + php artisan cache:clear + php artisan config:clear + php artisan route:clear + ``` +3. Hapus session files: + ```bash + # Di PowerShell: + Remove-Item storage/framework/sessions/* -Force + ``` + +### Error 500 Setelah Login +- Periksa `storage/logs/laravel.log` untuk detail error +- Pastikan semua migrasi sudah dijalankan: `php artisan migrate:status` + +### User Tidak Bisa Login +- Pastikan kolom `username` terisi (login menggunakan `username`, bukan `email`) +- Untuk update username yang kosong: + ```bash + php artisan tinker + ``` + ```php + User::whereNull('username')->orWhere('username', '')->get()->each(fn($u) => $u->update(['username' => $u->email])); + ``` + +--- + +## 8. Arsitektur Middleware + +``` +Request + β”‚ + β”œβ”€ web middleware group (Kernel.php) + β”‚ β”œβ”€ EncryptCookies + β”‚ β”œβ”€ AddQueuedCookiesToResponse + β”‚ β”œβ”€ StartSession + β”‚ β”œβ”€ ShareErrorsFromSession + β”‚ β”œβ”€ VerifyCsrfToken + β”‚ └─ SubstituteBindings + β”‚ + β”œβ”€ auth middleware (Authenticate.php) + β”‚ └─ Redirect ke /admin/login jika belum login + β”‚ + └─ role middleware (Role.php) + └─ Cek apakah user->role termasuk dalam daftar yang diizinkan + β”œβ”€ Ya β†’ lanjut ke controller + └─ Tidak β†’ redirect ke dashboard dengan pesan error +``` + +> **Catatan:** `AuthenticateSession` dan `ClearStuckSession` telah di-disable dari web middleware group karena menyebabkan konflik session. diff --git a/DOKUMENTASI_REDESIGN_JADWAL_ABSENSI.md b/DOKUMENTASI_REDESIGN_JADWAL_ABSENSI.md new file mode 100644 index 0000000..99f5cbc --- /dev/null +++ b/DOKUMENTASI_REDESIGN_JADWAL_ABSENSI.md @@ -0,0 +1,215 @@ +# Dokumentasi Redesign Halaman Jadwal & Absensi Kegiatan + +## 🎯 Perubahan yang Dilakukan + +### 1. **Halaman Jadwal Kegiatan** (`admin.kegiatan.data.index`) +**Lokasi**: `sim-pkpps/resources/views/admin/kegiatan/data/index.blade.php` + +#### Perubahan Tampilan: +βœ… **Dari**: Tabel flat dengan filter dropdown di atas +βœ… **Ke**: 7 tab horizontal (Senin-Ahad) dengan card grid per hari + +#### Fitur Utama: +- **Tab Navigation**: 7 tab horizontal untuk setiap hari dalam seminggu +- **Auto-Select Tab**: Tab hari ini otomatis terpilih saat pertama kali membuka halaman +- **Card Layout**: Kegiatan ditampilkan sebagai card, bukan baris tabel +- **Filter per Tab**: Dropdown filter kelas & kategori di dalam setiap tab +- **Tab Switching JavaScript**: Berpindah tab tanpa reload (URL state preserved dengan `pushState`) + +#### Struktur Card Kegiatan: +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Nama Kegiatan [Badge] β”‚ +β”‚ πŸ• 08:00 - 10:00 β”‚ +β”‚ [Kelas1] [Kelas2] [+2 lainnya]β”‚ +β”‚ πŸ“– Materi: ... β”‚ +β”‚ β”‚ +β”‚ [Input Absensi] [Detail] β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +#### CSS Responsif: +- Grid auto-fill dengan minimum width 320px +- Horizontal scroll pada tab navigation untuk mobile +- Hover effects & animations (fadeIn, translateY) + +--- + +### 2. **Halaman Input Absensi** (`admin.absensi-kegiatan.index`) +**Lokasi**: `sim-pkpps/resources/views/admin/kegiatan/absensi/index.blade.php` + +#### Perubahan Tampilan: +βœ… **Dari**: Filter dropdown biasa + tabel list kegiatan +βœ… **Ke**: Date picker dengan header tanggal + card grid dengan status badge + +#### Fitur Utama: +- **Date Picker Section**: Background gradient hijau dengan header tanggal lengkap +- **Nama Hari Otomatis**: Menampilkan "Jumat, 8 Desember 2024" berdasarkan tanggal dipilih +- **Filter dalam Date Picker**: Kategori & Kelas digabung dalam satu section +- **Status Badge**: Menampilkan "Sudah Input" (hijau) atau "Belum Input" (merah) +- **Progress Bar**: Jika sudah ada data absensi, tampilkan persentase kehadiran +- **Query Otomatis Hari**: Sistem otomatis filter kegiatan berdasarkan hari dari tanggal dipilih + +#### Struktur Card Kegiatan: +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Nama Kegiatan [Badge] β”‚ [Status] β”‚ +β”‚ πŸ• 08:00 - 10:00 β”‚ β”‚ +β”‚ [Kelas1] [Kelas2] [Kelas3] β”‚ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”β”‚ β”‚ +β”‚ β”‚ Kehadiran 15/20 (75%) β”‚β”‚ β”‚ +β”‚ β”‚ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘ β”‚β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜β”‚ β”‚ +β”‚ β”‚ β”‚ +β”‚ [Input Absensi] [Rekap] β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +#### Logika Backend (View Only): +```php +// Map hari Indonesia ke hari sistem +$hariDipilih = Carbon::parse($tanggal)->locale('id')->isoFormat('dddd'); +$hariMap = ['Senin' => 'Senin', 'Minggu' => 'Ahad', ...]; +$hariFilter = $hariMap[$hariDipilih] ?? 'Senin'; + +// Filter kegiatan berdasarkan hari dari tanggal dipilih +$query = $kegiatans->where('hari', $hariFilter); + +// Cek apakah sudah ada data absensi +$absensiExists = AbsensiKegiatan::where('kegiatan_id', $kegiatan->kegiatan_id) + ->whereDate('tanggal', $tanggal) + ->exists(); + +// Hitung persentase kehadiran +$absensiData = AbsensiKegiatan::where('kegiatan_id', $kegiatan->kegiatan_id) + ->whereDate('tanggal', $tanggal) + ->get(); +$hadirCount = $absensiData->where('status', 'Hadir')->count(); +$persenKehadiran = round(($hadirCount / $totalSantri) * 100); +``` + +--- + +## 🎨 Palette Warna + +| Elemen | Warna | Hex Code | +|--------|-------|----------| +| Primary Green | Eucalyptus Green | `#6FBA9D` | +| Dark Green | Darker Shade | `#5EA98C` | +| Light Green | Background | `#E8F7F2` | +| Page Background | Very Light | `#F8FBF9` | +| Status Sudah (Green) | Success | `#D1FAE5` / `#065F46` | +| Status Belum (Red) | Error | `#FEE2E2` / `#991B1B` | +| Blue Button | Info | `#3B82F6` | + +--- + +## πŸ“¦ Tidak Ada Perubahan Controller + +βœ… **Semua perubahan hanya pada VIEW layer** +βœ… Controller logic tetap sama: +- `App\Http\Controllers\Admin\KegiatanController@jadwal` β†’ index.blade.php +- `App\Http\Controllers\Admin\AbsensiKegiatanController@index` β†’ absensi/index.blade.php + +βœ… Model relationships tetap digunakan: +- `$kegiatan->kelasKegiatan` (many-to-many via `kelas_kegiatan`) +- `$kegiatan->kategori` (belongsTo) +- `AbsensiKegiatan::where()` queries + +--- + +## πŸš€ Testing Checklist + +### Halaman Jadwal (`/admin/kegiatan/jadwal`) +- [ ] Tab navigation berfungsi (klik untuk switch) +- [ ] Tab hari ini otomatis terpilih +- [ ] Filter kelas & kategori submit dengan GET parameter +- [ ] Card menampilkan semua informasi kegiatan +- [ ] Tombol "Input Absensi" redirect ke input page +- [ ] Tombol "Detail" redirect ke detail page +- [ ] Responsive di mobile (tab horizontal scroll) + +### Halaman Absensi (`/admin/absensi-kegiatan`) +- [ ] Date picker default ke hari ini +- [ ] Nama hari + tanggal tampil di header +- [ ] Filter kategori & kelas berfungsi +- [ ] Status badge menampilkan "Sudah Input" / "Belum Input" dengan benar +- [ ] Progress bar muncul jika sudah ada data absensi +- [ ] Persentase kehadiran dihitung dengan benar (hadir/total) +- [ ] Tombol "Input Absensi" membawa parameter `tanggal` dalam URL +- [ ] Tombol "Rekap" redirect ke rekap page +- [ ] Empty state muncul jika tidak ada kegiatan di hari tersebut + +--- + +## πŸ”§ Teknologi & Dependencies + +**View Engine**: Laravel Blade +**Styling**: Inline CSS (no external library) +**JavaScript**: Vanilla JS (tab switching, no jQuery) +**PHP Helpers**: Carbon (date formatting dengan locale Indonesia) +**Icons**: Font Awesome 5 + +**Browser Compatibility**: +- Chrome/Edge: βœ… Full support +- Firefox: βœ… Full support +- Safari: βœ… CSS Grid supported +- Mobile: βœ… Responsive grid & horizontal scroll + +--- + +## πŸ“ Catatan Teknis + +### URL Parameters yang Digunakan: + +**Jadwal**: +``` +GET /admin/kegiatan/jadwal?hari=Senin&kelas_id=1&kategori_id=2 +``` + +**Absensi**: +``` +GET /admin/absensi-kegiatan?tanggal=2024-12-06&kategori_id=1&id_kelas=2 +``` + +### Mapping Hari Minggu β†’ Ahad: +```php +$hariMap = [ + 'Senin' => 'Senin', + 'Selasa' => 'Selasa', + 'Rabu' => 'Rabu', + 'Kamis' => 'Kamis', + 'Jumat' => 'Jumat', + 'Sabtu' => 'Sabtu', + 'Minggu' => 'Ahad' // Penting untuk database +]; +``` + +### Animation Classes: +```css +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} +``` + +--- + +## βœ… Hasil Akhir + +- **Jadwal**: Tab-based layout dengan 7 hari, auto-select hari ini, card grid per tab +- **Absensi**: Date picker dengan header tanggal, card dengan status badge & progress bar +- **UI/UX**: Clean, modern, responsif, dengan animasi smooth +- **Performance**: Lightweight CSS tanpa library eksternal +- **Code Quality**: Clean Blade syntax, reusable CSS classes + +**Total Files Modified**: 2 files +**Lines Changed**: ~600 lines (redesign complete) +**No Breaking Changes**: Semua route & controller logic tetap sama + +--- + +Dibuat: {{ now()->format('d F Y H:i') }} +Developer: GitHub Copilot +Project: SIM-PKPPS (Sistem Informasi Manajemen Pesantren) diff --git a/sim-pkpps/.env.example b/sim-pkpps/.env.example index ea0665b..a366649 100644 --- a/sim-pkpps/.env.example +++ b/sim-pkpps/.env.example @@ -11,7 +11,7 @@ LOG_LEVEL=debug DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 -DB_DATABASE=laravel +DB_DATABASE=sim_santri DB_USERNAME=root DB_PASSWORD= diff --git a/sim-pkpps/app/Console/Commands/CleanSantriAccounts.php b/sim-pkpps/app/Console/Commands/CleanSantriAccounts.php new file mode 100644 index 0000000..8571bfa --- /dev/null +++ b/sim-pkpps/app/Console/Commands/CleanSantriAccounts.php @@ -0,0 +1,107 @@ +option('dry-run'); + + if ($dryRun) { + $this->info('=== DRY RUN MODE (tidak ada perubahan yang disimpan) ==='); + } + + $accounts = SantriAccount::with('santri')->get(); + $updated = 0; + $skipped = 0; + $errors = []; + + foreach ($accounts as $account) { + $santri = $account->santri; + + // -- Skip jika santri tidak ditemukan -- + if (!$santri) { + $errors[] = "Account ID {$account->id} (id_santri={$account->id_santri}): Data santri tidak ditemukan."; + $skipped++; + continue; + } + + // -- Skip jika NIS kosong -- + if (empty($santri->nis)) { + $errors[] = "Account ID {$account->id} ({$santri->nama_lengkap}): NIS kosong, dilewati."; + $skipped++; + continue; + } + + // -- Tentukan username yang benar -- + if ($account->role === 'wali') { + $usernameBenar = $santri->nama_orang_tua ?: $santri->nama_lengkap; + } else { + $usernameBenar = $santri->nama_lengkap; + } + + // -- Cek apakah username sudah benar -- + $usernameChanged = ($account->username !== $usernameBenar); + + if ($usernameChanged) { + // -- Pastikan username unik -- + $existing = SantriAccount::where('username', $usernameBenar) + ->where('id', '!=', $account->id) + ->exists(); + if ($existing) { + $usernameBenar = $usernameBenar . '_' . $santri->nis; + } + } + + if ($usernameChanged) { + $this->line(" [{$account->role}] {$santri->nama_lengkap}: username '{$account->username}' -> '{$usernameBenar}'"); + } + + if (!$dryRun) { + $account->username = $usernameBenar; + $account->password = Hash::make($santri->nis); + $account->save(); + } + + $updated++; + } + + $this->newLine(); + $this->info("Selesai! Updated: {$updated}, Skipped: {$skipped}"); + + if (count($errors) > 0) { + $this->newLine(); + $this->warn('Masalah ditemukan:'); + foreach ($errors as $err) { + $this->warn(" - {$err}"); + } + } + + if ($dryRun) { + $this->newLine(); + $this->comment('Jalankan tanpa --dry-run untuk menyimpan perubahan.'); + } + + return Command::SUCCESS; + } +} diff --git a/sim-pkpps/app/Http/Controllers/Admin/AbsensiKegiatanController.php b/sim-pkpps/app/Http/Controllers/Admin/AbsensiKegiatanController.php index 814fb5d..75c9890 100644 --- a/sim-pkpps/app/Http/Controllers/Admin/AbsensiKegiatanController.php +++ b/sim-pkpps/app/Http/Controllers/Admin/AbsensiKegiatanController.php @@ -5,6 +5,8 @@ use App\Http\Controllers\Controller; use App\Models\AbsensiKegiatan; use App\Models\Kegiatan; +use App\Models\Kelas; +use App\Models\Kepulangan; use App\Models\Santri; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -12,49 +14,11 @@ class AbsensiKegiatanController extends Controller { /** - * Daftar kegiatan untuk absensi + * Daftar kegiatan untuk absensi β€” diarahkan ke Dashboard Kegiatan (tidak redundan) */ public function index(Request $request) { - // Query dengan eager loading untuk optimasi - $query = Kegiatan::with(['kategori', 'kelasKegiatan']) - ->select('id', 'kegiatan_id', 'kategori_id', 'nama_kegiatan', 'hari', 'waktu_mulai', 'waktu_selesai'); - - // Filter Hari - if ($request->filled('hari')) { - $query->where('hari', $request->hari); - } - - // Filter Kategori - if ($request->filled('kategori_id')) { - $query->where('kategori_id', $request->kategori_id); - } - - // Filter Kelas - if ($request->filled('id_kelas')) { - $query->whereHas('kelasKegiatan', function($q) use ($request) { - $q->where('kelas.id', $request->id_kelas); - }); - } - - // Search - if ($request->filled('search')) { - $search = $request->search; - $query->where(function($q) use ($search) { - $q->where('nama_kegiatan', 'like', "%{$search}%") - ->orWhere('kegiatan_id', 'like', "%{$search}%"); - }); - } - - // Pagination dengan 15 item per page - $kegiatans = $query->orderBy('hari')->orderBy('waktu_mulai')->paginate(15)->appends(request()->query()); - - // Data untuk filter - $hariList = ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu', 'Ahad']; - $kategoris = \App\Models\KategoriKegiatan::select('kategori_id', 'nama_kategori')->get(); - $kelasList = \App\Models\Kelas::with('kelompok')->orderBy('urutan')->get(); - - return view('admin.kegiatan.absensi.index', compact('kegiatans', 'hariList', 'kategoris', 'kelasList')); + return redirect()->route('admin.kegiatan.jadwal'); } /** @@ -63,42 +27,45 @@ public function index(Request $request) public function inputAbsensi($kegiatan_id) { // Get kegiatan dengan relasi kategori dan kelas - $kegiatan = Kegiatan::with(['kategori', 'kelasKegiatan']) + $kegiatan = Kegiatan::with(['kategori', 'kelasKegiatan.kelompok']) ->where('kegiatan_id', $kegiatan_id) ->firstOrFail(); $tanggal = request('tanggal', now()->format('Y-m-d')); - // Get santri sesuai kelas kegiatan + // Build santri grouped by kegiatan kelas + $santriGrouped = collect(); + if ($kegiatan->isForAllClasses()) { - // Kegiatan umum: ambil SEMUA santri aktif - $santris = Santri::where('status', 'Aktif') - ->with('kelasSantri.kelas') + // Kegiatan umum: ambil SEMUA santri aktif, group by primary kelas + $allSantris = Santri::where('status', 'Aktif') + ->with(['kelasSantri.kelas', 'kelasPrimary.kelas']) ->orderBy('nama_lengkap') ->get(); + + $santriGrouped = $allSantris->groupBy(function($s) { + $primary = $s->kelasPrimary; + return $primary && $primary->kelas ? $primary->kelas->nama_kelas : 'Tanpa Kelas'; + })->sortKeys(); } else { - // Kegiatan khusus: ambil santri yang kelasnya match - $kelasIds = $kegiatan->kelasKegiatan->pluck('id')->toArray(); - - // Coba ambil santri dari sistem kelas baru - $santris = Santri::where('status', 'Aktif') - ->whereHas('kelasSantri', function($query) use ($kelasIds) { - $query->whereIn('id_kelas', $kelasIds); - }) - ->with('kelasSantri.kelas') - ->orderBy('nama_lengkap') - ->get(); - - // Fallback: Jika tidak ada santri (belum migrasi), gunakan old column kelas - if ($santris->isEmpty()) { - $kelasNames = $kegiatan->kelasKegiatan->pluck('nama_kelas')->toArray(); - $santris = Santri::where('status', 'Aktif') - ->whereIn('kelas', $kelasNames) - ->with('kelasSantri.kelas') + // Kegiatan khusus: group by kegiatan kelas + foreach ($kegiatan->kelasKegiatan as $kelas) { + $santriInKelas = Santri::where('status', 'Aktif') + ->whereHas('kelasSantri', function($q) use ($kelas) { + $q->where('id_kelas', $kelas->id); + }) + ->with(['kelasSantri.kelas', 'kelasPrimary.kelas']) ->orderBy('nama_lengkap') ->get(); + + if ($santriInKelas->count() > 0) { + $santriGrouped[$kelas->nama_kelas] = $santriInKelas; + } } } + + // Flatten for total count + $santris = $santriGrouped->flatten()->unique('id_santri'); // Ambil data absensi yang sudah ada $absensiData = AbsensiKegiatan::where('kegiatan_id', $kegiatan_id) @@ -106,6 +73,13 @@ public function inputAbsensi($kegiatan_id) ->pluck('status', 'id_santri') ->toArray(); + // Cek santri yang sedang pulang + $santriSedangPulang = Kepulangan::where('status', 'Disetujui') + ->where('tanggal_pulang', '<=', $tanggal) + ->where('tanggal_kembali', '>=', $tanggal) + ->pluck('id_santri') + ->toArray(); + // Info kelas kegiatan untuk view $kegiatanInfo = [ 'is_umum' => $kegiatan->isForAllClasses(), @@ -113,24 +87,42 @@ public function inputAbsensi($kegiatan_id) 'jumlah_kelas' => $kegiatan->kelasKegiatan->count(), ]; - return view('admin.kegiatan.absensi.input', compact('kegiatan', 'santris', 'absensiData', 'tanggal', 'kegiatanInfo')); + return view('admin.kegiatan.absensi.input', compact('kegiatan', 'santris', 'santriGrouped', 'absensiData', 'tanggal', 'kegiatanInfo', 'santriSedangPulang')); } /** - * Simpan absensi manual + * Simpan absensi manual (hanya santri yang dikirim form) */ public function simpanAbsensi(Request $request) { $validated = $request->validate([ 'kegiatan_id' => 'required|exists:kegiatans,kegiatan_id', 'tanggal' => 'required|date', - 'absensi' => 'required|array', - 'absensi.*' => 'required|in:Hadir,Izin,Sakit,Alpa', + 'absensi' => 'nullable|array', + 'absensi.*' => 'nullable|in:Hadir,Izin,Sakit,Alpa,Terlambat,Pulang', ]); + // Cek santri yang sedang pulang + $santriSedangPulang = Kepulangan::where('status', 'Disetujui') + ->where('tanggal_pulang', '<=', $request->tanggal) + ->where('tanggal_kembali', '>=', $request->tanggal) + ->pluck('id_santri') + ->toArray(); + + $absensiInput = $request->absensi ?? []; + DB::beginTransaction(); try { - foreach ($request->absensi as $id_santri => $status) { + $saved = 0; + foreach ($absensiInput as $id_santri => $status) { + // Skip jika kosong (santri dilewati) + if (empty($status)) { + continue; + } + + // Paksa status Pulang untuk santri yang sedang pulang + $finalStatus = in_array($id_santri, $santriSedangPulang) ? 'Pulang' : $status; + AbsensiKegiatan::updateOrCreate( [ 'kegiatan_id' => $request->kegiatan_id, @@ -138,22 +130,66 @@ public function simpanAbsensi(Request $request) 'tanggal' => $request->tanggal, ], [ - 'status' => $status, + 'status' => $finalStatus, 'metode_absen' => 'Manual', 'waktu_absen' => now()->format('H:i:s'), ] ); + $saved++; } DB::commit(); - return redirect()->route('admin.absensi-kegiatan.index') - ->with('success', 'Absensi berhasil disimpan.'); + return redirect()->route('admin.kegiatan.index') + ->with('success', "Absensi berhasil disimpan ({$saved} santri)."); } catch (\Exception $e) { DB::rollBack(); return back()->with('error', 'Gagal menyimpan absensi: ' . $e->getMessage()); } } + /** + * Edit single absensi record + */ + public function editAbsensi($id) + { + $absensi = AbsensiKegiatan::with(['santri', 'kegiatan.kategori'])->findOrFail($id); + return view('admin.kegiatan.absensi.edit', compact('absensi')); + } + + /** + * Update single absensi record + */ + public function updateAbsensi(Request $request, $id) + { + $absensi = AbsensiKegiatan::findOrFail($id); + + $validated = $request->validate([ + 'status' => 'required|in:Hadir,Izin,Sakit,Alpa,Terlambat,Pulang', + ]); + + $absensi->update([ + 'status' => $validated['status'], + 'waktu_absen' => now()->format('H:i:s'), + ]); + + return redirect()->route('admin.absensi-kegiatan.rekap', $absensi->kegiatan_id) + ->with('success', 'Status absensi ' . $absensi->santri->nama_lengkap . ' berhasil diperbarui.'); + } + + /** + * Hapus single absensi record + */ + public function hapusAbsensi($id) + { + $absensi = AbsensiKegiatan::findOrFail($id); + $kegiatanId = $absensi->kegiatan_id; + $nama = $absensi->santri->nama_lengkap; + $absensi->delete(); + + return redirect()->route('admin.absensi-kegiatan.rekap', $kegiatanId) + ->with('success', 'Data absensi ' . $nama . ' berhasil dihapus.'); + } + /** * Rekap absensi kegiatan */ @@ -161,7 +197,7 @@ public function rekapAbsensi(Request $request, $kegiatan_id) { $kegiatan = Kegiatan::with(['kategori', 'kelasKegiatan'])->where('kegiatan_id', $kegiatan_id)->firstOrFail(); - $query = AbsensiKegiatan::with('santri') + $query = AbsensiKegiatan::with(['santri.kelasSantri.kelas']) ->where('kegiatan_id', $kegiatan_id); // Filter tanggal @@ -175,18 +211,61 @@ public function rekapAbsensi(Request $request, $kegiatan_id) ->whereYear('tanggal', date('Y', strtotime($request->bulan))); } + // Filter kelas + if ($request->filled('kelas_id')) { + $query->whereHas('santri.kelasSantri', function($q) use ($request) { + $q->where('id_kelas', $request->kelas_id); + }); + } + $absensis = $query->orderBy('tanggal', 'desc') ->orderBy('waktu_absen', 'desc') - ->paginate(20); + ->get(); + + // Build kelas list for filter dropdown + if ($kegiatan->isForAllClasses()) { + $kelasFilterList = Kelas::active()->ordered()->get(); + } else { + $kelasFilterList = $kegiatan->kelasKegiatan; + } + + // Grup per kelas berdasarkan kegiatan kelas + if ($kegiatan->isForAllClasses()) { + $absensiPerKelas = $absensis->groupBy(function ($item) { + return $item->santri->kelas_name ?? 'Belum Ada Kelas'; + })->sortKeys(); + } else { + $absensiPerKelas = collect(); + foreach ($kegiatan->kelasKegiatan as $kelas) { + $kelasAbsensis = $absensis->filter(function ($item) use ($kelas) { + return $item->santri->kelasSantri->contains('id_kelas', $kelas->id); + }); + if ($kelasAbsensis->count() > 0) { + $absensiPerKelas[$kelas->nama_kelas] = $kelasAbsensis; + } + } + } // Statistik - $stats = AbsensiKegiatan::where('kegiatan_id', $kegiatan_id) - ->select('status', DB::raw('count(*) as total')) + $statsQuery = AbsensiKegiatan::where('kegiatan_id', $kegiatan_id); + if ($request->filled('tanggal')) { + $statsQuery->whereDate('tanggal', $request->tanggal); + } + if ($request->filled('bulan')) { + $statsQuery->whereMonth('tanggal', date('m', strtotime($request->bulan))) + ->whereYear('tanggal', date('Y', strtotime($request->bulan))); + } + if ($request->filled('kelas_id')) { + $statsQuery->whereHas('santri.kelasSantri', function($q) use ($request) { + $q->where('id_kelas', $request->kelas_id); + }); + } + $stats = $statsQuery->select('status', DB::raw('count(*) as total')) ->groupBy('status') ->pluck('total', 'status') ->toArray(); - return view('admin.kegiatan.absensi.rekap', compact('kegiatan', 'absensis', 'stats')); + return view('admin.kegiatan.absensi.rekap', compact('kegiatan', 'absensis', 'absensiPerKelas', 'stats', 'kelasFilterList')); } /** diff --git a/sim-pkpps/app/Http/Controllers/Admin/CapaianController.php b/sim-pkpps/app/Http/Controllers/Admin/CapaianController.php index 98474d7..1941abb 100644 --- a/sim-pkpps/app/Http/Controllers/Admin/CapaianController.php +++ b/sim-pkpps/app/Http/Controllers/Admin/CapaianController.php @@ -9,380 +9,278 @@ use App\Models\Semester; use App\Models\Kelas; use App\Models\SantriKelas; +use App\Services\CapaianAccessService; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; class CapaianController extends Controller { - /** - * Display a listing of capaian (per santri dengan total progress) - */ + // ===================================================================== + // INDEX β€” daftar santri + total progress + // ===================================================================== public function index(Request $request) { - // Data untuk filter - $semesters = Semester::orderBy('tahun_ajaran', 'desc')->get(); + $semesters = Semester::orderBy('tahun_ajaran', 'desc')->get(); $semesterAktif = Semester::aktif()->first(); - - // Get filter parameters - $selectedKelas = $request->input('id_kelas'); + + $selectedKelas = $request->input('id_kelas'); $selectedSemester = $request->input('id_semester', $semesterAktif?->id_semester); - $search = $request->input('search'); - - // Dynamic kelas list dari database + $search = $request->input('search'); + $kelasList = Kelas::active()->ordered()->with('kelompok')->get(); - - // Query santri dengan filter (eager load kelas untuk accessor) + $query = Santri::where('status', 'Aktif') ->with(['kelasPrimary.kelas.kelompok']); - - // Filter berdasarkan kelas jika dipilih (by ID) + if ($selectedKelas) { $query->kelas($selectedKelas); } - - // Filter berdasarkan search (nama atau NIS) if ($search) { - $query->where(function($q) use ($search) { + $query->where(function ($q) use ($search) { $q->where('nama_lengkap', 'like', "%{$search}%") ->orWhere('nis', 'like', "%{$search}%"); }); } - + $santris = $query->orderBy('nama_lengkap')->get(); - - // Hitung total progress per santri - $santriData = $santris->map(function($santri) use ($selectedSemester) { + + $santriData = $santris->map(function ($santri) use ($selectedSemester) { $capaians = Capaian::where('id_santri', $santri->id_santri) - ->when($selectedSemester, function($q) use ($selectedSemester) { - $q->where('id_semester', $selectedSemester); - }) + ->when($selectedSemester, fn($q) => $q->where('id_semester', $selectedSemester)) ->get(); - - // Hanya hitung materi yang sudah ada progressnya (persentase > 0%) + $capaiansBerisi = $capaians->where('persentase', '>', 0); - $totalProgress = $capaiansBerisi->isEmpty() ? 0 : $capaiansBerisi->avg('persentase'); - $totalMateri = $capaiansBerisi->count(); - + $totalProgress = $capaiansBerisi->isEmpty() ? 0 : $capaiansBerisi->avg('persentase'); + return [ - 'santri' => $santri, + 'santri' => $santri, 'total_progress' => round($totalProgress, 2), - 'total_materi' => $totalMateri, - 'capaians' => $capaians + 'total_materi' => $capaiansBerisi->count(), + 'capaians' => $capaians, ]; })->sortBy('total_progress')->values(); - return view('admin.capaian.index', compact('santriData', 'semesters', 'kelasList', 'selectedKelas', 'selectedSemester', 'search')); + return view('admin.capaian.index', compact( + 'santriData', 'semesters', 'kelasList', + 'selectedKelas', 'selectedSemester', 'search' + )); } - /** - * Show the form for creating new capaian - */ + // ===================================================================== + // CREATE / STORE + // ===================================================================== public function create(Request $request) { - // Get santri list - $santris = Santri::aktif() - ->select('id', 'id_santri', 'nis', 'nama_lengkap') - ->with(['kelasPrimary.kelas']) - ->orderBy('nama_lengkap') - ->get(); - - // Get semester aktif + $santris = Santri::aktif()->select('id', 'id_santri', 'nis', 'nama_lengkap') + ->with(['kelasPrimary.kelas'])->orderBy('nama_lengkap')->get(); $semesterAktif = Semester::aktif()->first(); - $semesters = Semester::orderBy('tahun_ajaran', 'desc')->get(); + $semesters = Semester::orderBy('tahun_ajaran', 'desc')->get(); - // Jika ada pre-selected santri $selectedSantri = null; - $materiOptions = []; - + $materiOptions = []; + if ($request->filled('id_santri')) { $selectedSantri = Santri::where('id_santri', $request->id_santri) - ->with(['kelasSantri.kelas']) - ->first(); + ->with(['kelasSantri.kelas'])->first(); if ($selectedSantri) { - // Get materi sesuai semua kelas santri (via relasi) - $kelasNames = $selectedSantri->kelasSantri - ->map(fn($sk) => $sk->kelas?->nama_kelas) - ->filter()->unique()->toArray(); + $kelasNames = $selectedSantri->kelasSantri + ->map(fn($sk) => $sk->kelas?->nama_kelas)->filter()->unique()->toArray(); $materiOptions = Materi::whereIn('kelas', $kelasNames ?: ['']) - ->orderBy('kategori') - ->orderBy('nama_kitab') - ->get(); + ->orderBy('kategori')->orderBy('nama_kitab')->get(); } } - return view('admin.capaian.create', compact('santris', 'semesters', 'semesterAktif', 'selectedSantri', 'materiOptions')); + return view('admin.capaian.create', compact( + 'santris', 'semesters', 'semesterAktif', 'selectedSantri', 'materiOptions' + )); } - /** - * Get materi by santri kelas (AJAX) - */ public function getMateriByKelas(Request $request) { $santri = Santri::where('id_santri', $request->id_santri) - ->with(['kelasSantri.kelas']) - ->first(); - - if (!$santri) { - return response()->json(['error' => 'Santri tidak ditemukan'], 404); - } + ->with(['kelasSantri.kelas'])->first(); + + if (!$santri) return response()->json(['error' => 'Santri tidak ditemukan'], 404); - // Get materi sesuai semua kelas santri $kelasNames = $santri->kelasSantri - ->map(fn($sk) => $sk->kelas?->nama_kelas) - ->filter()->unique()->toArray(); + ->map(fn($sk) => $sk->kelas?->nama_kelas)->filter()->unique()->toArray(); $materis = Materi::whereIn('kelas', $kelasNames ?: ['']) ->select('id', 'id_materi', 'kategori', 'nama_kitab', 'halaman_mulai', 'halaman_akhir', 'total_halaman') - ->orderBy('kategori') - ->orderBy('nama_kitab') - ->get(); + ->orderBy('kategori')->orderBy('nama_kitab')->get(); - return response()->json([ - 'kelas' => $santri->kelas, - 'materis' => $materis - ]); + return response()->json(['kelas' => $santri->kelas, 'materis' => $materis]); } - /** - * Get detail materi (AJAX) - */ public function getDetailMateri(Request $request) { $materi = Materi::where('id_materi', $request->id_materi)->first(); - - if (!$materi) { - return response()->json(['error' => 'Materi tidak ditemukan'], 404); - } + if (!$materi) return response()->json(['error' => 'Materi tidak ditemukan'], 404); - // Check existing capaian $existingCapaian = null; if ($request->filled('id_santri') && $request->filled('id_semester')) { $existingCapaian = Capaian::where('id_santri', $request->id_santri) ->where('id_materi', $request->id_materi) - ->where('id_semester', $request->id_semester) - ->first(); + ->where('id_semester', $request->id_semester)->first(); } - return response()->json([ - 'materi' => $materi, - 'existing_capaian' => $existingCapaian - ]); + return response()->json(['materi' => $materi, 'existing_capaian' => $existingCapaian]); } - /** - * Store a newly created capaian (atau update jika sudah ada) - */ public function store(Request $request) { $validated = $request->validate([ - 'id_santri' => 'required|exists:santris,id_santri', - 'id_materi' => 'required|exists:materi,id_materi', - 'id_semester' => 'required|exists:semester,id_semester', + 'id_santri' => 'required|exists:santris,id_santri', + 'id_materi' => 'required|exists:materi,id_materi', + 'id_semester' => 'required|exists:semester,id_semester', 'halaman_selesai' => 'required|string', - 'catatan' => 'nullable|string', - 'tanggal_input' => 'required|date', - ], [ - 'id_santri.required' => 'Santri wajib dipilih.', - 'id_materi.required' => 'Materi wajib dipilih.', - 'id_semester.required' => 'Semester wajib dipilih.', - 'halaman_selesai.required' => 'Halaman yang selesai wajib diisi.', - 'tanggal_input.required' => 'Tanggal input wajib diisi.', + 'catatan' => 'nullable|string', + 'tanggal_input' => 'required|date', ]); - // Check apakah capaian sudah ada (auto-created atau manual) $existing = Capaian::where('id_santri', $validated['id_santri']) ->where('id_materi', $validated['id_materi']) - ->where('id_semester', $validated['id_semester']) - ->first(); + ->where('id_semester', $validated['id_semester'])->first(); if ($existing) { - // Update existing capaian $existing->update([ 'halaman_selesai' => $validated['halaman_selesai'], - 'catatan' => $validated['catatan'], - 'tanggal_input' => $validated['tanggal_input'], + 'catatan' => $validated['catatan'], + 'tanggal_input' => $validated['tanggal_input'], ]); - return redirect()->route('admin.capaian.show', $existing) ->with('success', 'Capaian berhasil diperbarui.'); } - // Create new capaian jika belum ada $capaian = Capaian::create($validated); - return redirect()->route('admin.capaian.show', $capaian) ->with('success', 'Capaian berhasil ditambahkan.'); } - /** - * Display the specified capaian - */ + // ===================================================================== + // SHOW / EDIT / UPDATE / DESTROY + // ===================================================================== public function show(Capaian $capaian) { $capaian->load(['santri.kelasPrimary.kelas', 'materi', 'semester']); - return view('admin.capaian.show', compact('capaian')); } - /** - * Show the form for editing the specified capaian - */ public function edit(Capaian $capaian) { $capaian->load(['santri.kelasPrimary.kelas', 'materi', 'semester']); $semesters = Semester::orderBy('tahun_ajaran', 'desc')->get(); - return view('admin.capaian.edit', compact('capaian', 'semesters')); } - /** - * Update the specified capaian - */ public function update(Request $request, Capaian $capaian) { $validated = $request->validate([ 'halaman_selesai' => 'required|string', - 'catatan' => 'nullable|string', - 'tanggal_input' => 'required|date', - ], [ - 'halaman_selesai.required' => 'Halaman yang selesai wajib diisi.', - 'tanggal_input.required' => 'Tanggal input wajib diisi.', + 'catatan' => 'nullable|string', + 'tanggal_input' => 'required|date', ]); $capaian->update($validated); - return redirect()->route('admin.capaian.show', $capaian) ->with('success', 'Capaian berhasil diperbarui.'); } - /** - * Remove the specified capaian - */ public function destroy(Capaian $capaian) { $santriNama = $capaian->santri->nama_lengkap; $materiNama = $capaian->materi->nama_kitab; - $capaian->delete(); - return redirect()->route('admin.capaian.index') ->with('success', "Capaian {$santriNama} untuk materi {$materiNama} berhasil dihapus."); } - /** - * Show riwayat capaian per santri - */ + // ===================================================================== + // RIWAYAT SANTRI + // ===================================================================== public function riwayatSantri($id_santri, Request $request) { $santri = Santri::where('id_santri', $id_santri) - ->with('kelasPrimary.kelas') - ->firstOrFail(); - - $query = Capaian::with(['materi', 'semester']) - ->bySantri($id_santri); + ->with('kelasPrimary.kelas')->firstOrFail(); - // Filter semester - if ($request->filled('id_semester')) { - $query->bySemester($request->id_semester); - } + $query = Capaian::with(['materi', 'semester'])->bySantri($id_santri); - // Filter search (nama materi) + if ($request->filled('id_semester')) $query->bySemester($request->id_semester); if ($request->filled('search')) { $search = $request->search; - $query->whereHas('materi', function($q) use ($search) { - $q->where('nama_kitab', 'like', "%{$search}%"); - }); + $query->whereHas('materi', fn($q) => $q->where('nama_kitab', 'like', "%{$search}%")); } - $capaians = $query->orderBy('created_at', 'desc') - ->paginate(15) - ->appends(request()->query()); - - // Statistik - $totalCapaian = $capaians->total(); + $capaians = $query->orderBy('created_at', 'desc')->paginate(15)->appends(request()->query()); + $totalCapaian = $capaians->total(); $rataRataPersentase = Capaian::bySantri($id_santri)->avg('persentase') ?? 0; - - // Statistik per kategori + $statistikKategori = Capaian::bySantri($id_santri) ->join('materi', 'capaian.id_materi', '=', 'materi.id_materi') ->select('materi.kategori', DB::raw('AVG(capaian.persentase) as rata_rata')) - ->groupBy('materi.kategori') - ->get() - ->pluck('rata_rata', 'kategori') - ->toArray(); + ->groupBy('materi.kategori')->get() + ->pluck('rata_rata', 'kategori')->toArray(); $semesters = Semester::orderBy('tahun_ajaran', 'desc')->get(); - return view('admin.capaian.riwayat-santri', compact('santri', 'capaians', 'totalCapaian', 'rataRataPersentase', 'statistikKategori', 'semesters')); + return view('admin.capaian.riwayat-santri', compact( + 'santri', 'capaians', 'totalCapaian', 'rataRataPersentase', 'statistikKategori', 'semesters' + )); } - /** - * Calculate persentase (AJAX untuk preview) - */ + // ===================================================================== + // CALCULATE PERSENTASE (AJAX) + // ===================================================================== public function calculatePersentase(Request $request) { - $halamanSelesai = $request->halaman_selesai; - $idMateri = $request->id_materi; - - if (empty($halamanSelesai) || empty($idMateri)) { + if (empty($request->halaman_selesai) || empty($request->id_materi)) { return response()->json(['persentase' => 0, 'jumlah' => 0]); } - try { - $persentase = Capaian::calculatePersentase($halamanSelesai, $idMateri); - $pages = Capaian::parseHalamanSelesai($halamanSelesai); - $jumlah = count($pages); - + $persentase = Capaian::calculatePersentase($request->halaman_selesai, $request->id_materi); + $pages = Capaian::parseHalamanSelesai($request->halaman_selesai); return response()->json([ 'persentase' => number_format($persentase, 2), - 'jumlah' => $jumlah, - 'pages' => $pages + 'jumlah' => count($pages), + 'pages' => $pages, ]); } catch (\Exception $e) { return response()->json(['error' => $e->getMessage()], 400); } } - /** - * Dashboard capaian dengan visualisasi lengkap - */ + // ===================================================================== + // DASHBOARD + // ===================================================================== public function dashboard(Request $request) { - // === FILTERS === - $kelas = $request->input('kelas'); - $idSemester = $request->input('id_semester'); - $semesterAktif = Semester::aktif()->first(); - $selectedSemester = $idSemester ?: ($semesterAktif ? $semesterAktif->id_semester : null); + // --- Filters --- + $kelas = $request->input('kelas'); + $idSemester = $request->input('id_semester'); + $filterSantri = $request->input('filter_santri', 'all'); + $semesterAktif = Semester::aktif()->first(); + $selectedSemester = $idSemester ?: ($semesterAktif?->id_semester); - // === BASE DATA === - $semesters = Semester::orderBy('tahun_ajaran', 'desc')->orderBy('periode', 'desc')->get(); + $semesters = Semester::orderBy('tahun_ajaran', 'desc')->orderBy('periode', 'desc')->get(); $allSemestersOrdered = Semester::orderBy('tahun_ajaran')->orderBy('periode')->get(); - $materis = Materi::orderBy('kategori')->orderBy('nama_kitab')->get(); - - // Dynamic kelas list - HANYA kelas yang ada santri PRIMARY-nya - $primaryKelasIds = SantriKelas::where('is_primary', true) - ->distinct() - ->pluck('id_kelas'); - - $kelasModels = Kelas::active() - ->whereIn('id', $primaryKelasIds) - ->ordered() - ->with('kelompok') - ->get(); - - $kelasList = $kelasModels->pluck('nama_kelas')->unique()->values()->toArray(); + $materis = Materi::orderBy('kategori')->orderBy('nama_kitab')->get(); + + // Kelas yang punya santri primary + $primaryKelasIds = SantriKelas::where('is_primary', true)->distinct()->pluck('id_kelas'); + $kelasModels = Kelas::active()->whereIn('id', $primaryKelasIds)->ordered()->with('kelompok')->get(); + $kelasList = $kelasModels->pluck('nama_kelas')->unique()->values()->toArray(); $santrisAktif = Santri::where('status', 'Aktif') ->with(['kelasPrimary.kelas']) ->when($kelas, fn($q) => $q->primaryKelasByName($kelas)) ->orderBy('nama_lengkap')->get(); + $santrisKhatam = Santri::where('status', 'Khatam') ->with(['kelasPrimary.kelas']) ->when($kelas, fn($q) => $q->primaryKelasByName($kelas)) ->orderBy('nama_lengkap')->get(); - // === ALL CAPAIAN (eager loaded once, filter by PRIMARY kelas only) === + // Load semua capaian sekali saja $allCapaian = Capaian::with(['santri.kelasPrimary.kelas', 'materi', 'semester']) ->when($kelas, fn($q) => $q->whereHas('santri', fn($sq) => $sq->primaryKelasByName($kelas))) ->get(); @@ -391,88 +289,77 @@ public function dashboard(Request $request) ? $allCapaian->where('id_semester', $selectedSemester) : $allCapaian; - // === 1. KPI SUMMARY === - $totalCapaian = $filteredCapaian->count(); + // --- KPI --- $totalSantriAktif = $santrisAktif->count(); - $rataRataProgress = $filteredCapaian->avg('persentase') ?? 0; - $capaianSelesai = $filteredCapaian->where('persentase', '>=', 100)->count(); + $rataRataProgress = round($filteredCapaian->avg('persentase') ?? 0, 1); $statistikKategori = []; - foreach (['Al-Qur\'an', 'Hadist', 'Materi Tambahan'] as $kat) { + foreach (["Al-Qur'an", 'Hadist', 'Materi Tambahan'] as $kat) { $katCap = $filteredCapaian->filter(fn($c) => $c->materi && $c->materi->kategori === $kat); $statistikKategori[$kat] = [ - 'count' => $katCap->count(), - 'avg' => round($katCap->avg('persentase') ?? 0, 2), + 'count' => $katCap->count(), + 'avg' => round($katCap->avg('persentase') ?? 0, 2), 'selesai' => $katCap->where('persentase', '>=', 100)->count(), ]; } $distribusiProgress = [ - '0-25%' => $filteredCapaian->where('persentase', '>=', 0)->where('persentase', '<=', 25)->count(), + '0-25%' => $filteredCapaian->where('persentase', '>=', 0)->where('persentase', '<=', 25)->count(), '26-50%' => $filteredCapaian->where('persentase', '>', 25)->where('persentase', '<=', 50)->count(), '51-75%' => $filteredCapaian->where('persentase', '>', 50)->where('persentase', '<=', 75)->count(), '76-99%' => $filteredCapaian->where('persentase', '>', 75)->where('persentase', '<', 100)->count(), - '100%' => $filteredCapaian->where('persentase', '>=', 100)->count(), + '100%' => $filteredCapaian->where('persentase', '>=', 100)->count(), ]; - // === 2. REKAP PER KELAS (Ranking + Khatam) === + // --- Rekap per kelas (Ranking tab) --- $rekapKelas = []; foreach ($kelasList as $k) { - $kelasCapaian = $filteredCapaian->filter(fn($c) => $c->santri && $c->santri->kelas === $k && $c->santri->status === 'Aktif'); + $kelasCapaian = $filteredCapaian->filter( + fn($c) => $c->santri && $c->santri->kelas === $k && $c->santri->status === 'Aktif' + ); $santriIds = $kelasCapaian->pluck('id_santri')->unique(); - $ranking = []; + $ranking = []; foreach ($santriIds as $sid) { - $sc = $kelasCapaian->where('id_santri', $sid); - $santri = $sc->first()->santri; - $kelasMateris = $materis->where('kelas', $k); + $sc = $kelasCapaian->where('id_santri', $sid); + $santri = $sc->first()->santri; + $kelasMateris = $materis->where('kelas', $k); $totalMateriKelas = $kelasMateris->count(); - $selesai = $sc->where('persentase', '>=', 100)->count(); - $avgProg = $sc->avg('persentase') ?? 0; - $isFullKhatam = $totalMateriKelas > 0 && $selesai >= $totalMateriKelas; - - // Breakdown per kategori - $alquran = $sc->filter(fn($c) => $c->materi->kategori == 'Al-Qur\'an')->avg('persentase') ?? 0; - $hadist = $sc->filter(fn($c) => $c->materi->kategori == 'Hadist')->avg('persentase') ?? 0; - $tambahan = $sc->filter(fn($c) => $c->materi->kategori == 'Materi Tambahan')->avg('persentase') ?? 0; + $selesai = $sc->where('persentase', '>=', 100)->count(); + $avgProg = round($sc->avg('persentase') ?? 0, 2); + $isFullKhatam = $totalMateriKelas > 0 && $selesai >= $totalMateriKelas; $ranking[] = [ - 'santri' => $santri, - 'avg_progress' => round($avgProg, 2), - 'total_materi' => $sc->count(), - 'selesai' => $selesai, + 'santri' => $santri, + 'avg_progress' => $avgProg, + 'selesai' => $selesai, 'total_materi_kelas' => $totalMateriKelas, - 'is_full_khatam' => $isFullKhatam, - 'alquran' => round($alquran, 1), - 'hadist' => round($hadist, 1), - 'tambahan' => round($tambahan, 1), + 'is_full_khatam' => $isFullKhatam, + 'alquran' => round($sc->filter(fn($c) => $c->materi->kategori == "Al-Qur'an")->avg('persentase') ?? 0, 1), + 'hadist' => round($sc->filter(fn($c) => $c->materi->kategori == 'Hadist')->avg('persentase') ?? 0, 1), + 'tambahan' => round($sc->filter(fn($c) => $c->materi->kategori == 'Materi Tambahan')->avg('persentase') ?? 0, 1), ]; } usort($ranking, fn($a, $b) => $b['avg_progress'] <=> $a['avg_progress']); $khatamSantris = Santri::primaryKelasByName($k)->where('status', 'Khatam')->get(); - - // Summary stats per kelas - $totalSantri = count($ranking); - $avgProgress = $totalSantri > 0 ? collect($ranking)->avg('avg_progress') : 0; - $totalSelesai = collect($ranking)->sum('selesai'); - $santriTuntas = collect($ranking)->where('avg_progress', '>=', 100)->count(); + $totalSantri = count($ranking); $rekapKelas[$k] = [ - 'ranking' => $ranking, - 'khatam' => $khatamSantris, + 'ranking' => $ranking, + 'khatam' => $khatamSantris, 'total_aktif' => Santri::primaryKelasByName($k)->where('status', 'Aktif')->count(), - 'summary' => [ - 'total_santri' => $totalSantri, - 'avg_progress' => round($avgProgress, 1), - 'total_selesai' => $totalSelesai, - 'santri_tuntas' => $santriTuntas, + 'summary' => [ + 'total_santri' => $totalSantri, + 'avg_progress' => $totalSantri > 0 ? round(collect($ranking)->avg('avg_progress'), 1) : 0, + 'total_selesai' => collect($ranking)->sum('selesai'), + 'santri_tuntas' => collect($ranking)->where('avg_progress', '>=', 100)->count(), ], ]; } - // === 3. SEMESTER COMPARISON (Line Chart data) === - $semesterLabels = $allSemestersOrdered->pluck('nama_semester')->toArray(); + // --- Semester comparison (Line chart) --- + $semesterLabels = $allSemestersOrdered->pluck('nama_semester')->toArray(); $semesterComparison = []; foreach ($kelasList as $k) { $dataPoints = []; @@ -484,77 +371,43 @@ public function dashboard(Request $request) $semesterComparison[$k] = $dataPoints; } - // === 4. SEMESTER-OVER-SEMESTER GROWTH === - $sosGrowth = []; - $santriIdsForGrowth = $filteredCapaian->pluck('id_santri')->unique()->take(25); - foreach ($santriIdsForGrowth as $sid) { - $santri = $santrisAktif->where('id_santri', $sid)->first(); - if (!$santri) continue; - - $semProgress = []; - foreach ($allSemestersOrdered as $sem) { - $semCap = $allCapaian->where('id_santri', $sid)->where('id_semester', $sem->id_semester); - $semProgress[] = round($semCap->avg('persentase') ?? 0, 2); - } - - $growth = []; - for ($i = 0; $i < count($semProgress); $i++) { - $growth[] = $i > 0 ? round($semProgress[$i] - $semProgress[$i - 1], 2) : 0; - } - - $sosGrowth[] = [ - 'nama' => $santri->nama_lengkap, - 'id_santri' => $sid, - 'kelas' => $santri->kelas, - 'progress' => $semProgress, - 'growth' => $growth, - 'current' => end($semProgress) ?: 0, - ]; - } - usort($sosGrowth, fn($a, $b) => $b['current'] <=> $a['current']); - - // === 5. MATERI COMPLETION RATE PER SEMESTER === + // --- Materi completion rate --- + $filteredMateris = $kelas ? $materis->where('kelas', $kelas) : $materis; $materiCompletionRate = []; - $filteredMateris = $kelas ? $materis->where('kelas', $kelas) : $materis; foreach ($filteredMateris as $materi) { $rates = []; foreach ($allSemestersOrdered as $sem) { $semMatCap = $allCapaian->where('id_materi', $materi->id_materi) ->where('id_semester', $sem->id_semester); - $total = $semMatCap->count(); + $total = $semMatCap->count(); $selesai = $semMatCap->where('persentase', '>=', 100)->count(); $rates[$sem->id_semester] = $total > 0 ? round(($selesai / $total) * 100, 1) : null; } - $materiCompletionRate[] = [ - 'materi' => $materi, - 'rates' => $rates, - ]; + $materiCompletionRate[] = ['materi' => $materi, 'rates' => $rates]; } - // === 7. BOTTLENECK ANALYSIS === + // --- Bottleneck --- $bottleneckMateri = []; foreach ($filteredMateris as $materi) { $matCap = $filteredCapaian->where('id_materi', $materi->id_materi); if ($matCap->isEmpty()) continue; - $avgProg = $matCap->avg('persentase') ?? 0; - $totalS = $matCap->count(); - $stuckS = $matCap->where('persentase', '<', 50)->count(); + $totalS = $matCap->count(); + $stuckS = $matCap->where('persentase', '<', 50)->count(); $stuckPct = $totalS > 0 ? round(($stuckS / $totalS) * 100, 1) : 0; - $bottleneckMateri[] = [ - 'materi' => $materi, - 'avg_progress' => round($avgProg, 2), - 'total_santri' => $totalS, - 'stuck_santri' => $stuckS, + 'materi' => $materi, + 'avg_progress' => round($matCap->avg('persentase') ?? 0, 2), + 'total_santri' => $totalS, + 'stuck_santri' => $stuckS, 'stuck_percentage' => $stuckPct, ]; } usort($bottleneckMateri, fn($a, $b) => $b['stuck_percentage'] <=> $a['stuck_percentage']); $bottleneckMateri = array_slice($bottleneckMateri, 0, 10); - // === 8. PROJECTED GRADUATION TIMELINE === - $projectedGraduation = []; - foreach ($santrisAktif->take(25) as $santri) { + // --- Projected Graduation (per kelas, tab Progress Santri) --- + $projectedByKelas = []; + foreach ($santrisAktif as $santri) { $santriCap = $allCapaian->where('id_santri', $santri->id_santri); if ($santriCap->isEmpty()) continue; @@ -562,13 +415,16 @@ public function dashboard(Request $request) foreach ($allSemestersOrdered as $sem) { $semCap = $santriCap->where('id_semester', $sem->id_semester); if ($semCap->isNotEmpty()) { - $progressPerSem[] = ['sem' => $sem->nama_semester, 'avg' => round($semCap->avg('persentase'), 2)]; + $progressPerSem[] = [ + 'sem' => $sem->nama_semester, + 'avg' => round($semCap->avg('persentase'), 2), + ]; } } - $currentProgress = round($santriCap->avg('persentase') ?? 0, 2); - // Calculate growth rate - $growthRate = 0; + $currentProgress = round($santriCap->avg('persentase') ?? 0, 2); + $growthRate = 0; + if (count($progressPerSem) >= 2) { $diffs = []; for ($i = 1; $i < count($progressPerSem); $i++) { @@ -579,48 +435,47 @@ public function dashboard(Request $request) $growthRate = $progressPerSem[0]['avg']; } - $remaining = 100 - $currentProgress; - $semestersToGrad = ($growthRate > 0 && $currentProgress < 100) ? ceil($remaining / $growthRate) : ($currentProgress >= 100 ? 0 : null); + $remaining = 100 - $currentProgress; + $semestersToGrad = ($growthRate > 0 && $currentProgress < 100) + ? ceil($remaining / $growthRate) + : ($currentProgress >= 100 ? 0 : null); - $projectedGraduation[] = [ - 'santri' => $santri, - 'current_progress' => $currentProgress, - 'growth_rate' => $growthRate, + $item = [ + 'santri' => $santri, + 'current_progress' => $currentProgress, + 'growth_rate' => $growthRate, 'semesters_to_grad' => $semestersToGrad, - 'history' => $progressPerSem, + 'history' => $progressPerSem, ]; - } - usort($projectedGraduation, fn($a, $b) => $b['current_progress'] <=> $a['current_progress']); - // === 9. SEMESTER SUMMARY REPORT === + $kelasKey = $santri->kelas ?? 'Tanpa Kelas'; + $projectedByKelas[$kelasKey][] = $item; + } + foreach ($projectedByKelas as &$kItems) { + usort($kItems, fn($a, $b) => $b['current_progress'] <=> $a['current_progress']); + } + unset($kItems); + + // --- Semester Summary Report --- $semesterSummary = null; if ($selectedSemester) { - $selectedSem = $semesters->where('id_semester', $selectedSemester)->first(); - $semCap = $allCapaian->where('id_semester', $selectedSemester); - - $currentIdx = $allSemestersOrdered->search(fn($s) => $s->id_semester === $selectedSemester); - $prevSemester = $currentIdx > 0 ? $allSemestersOrdered[$currentIdx - 1] : null; - $prevSemCap = $prevSemester ? $allCapaian->where('id_semester', $prevSemester->id_semester) : collect(); - - $avgProgressSem = $semCap->avg('persentase') ?? 0; + $selectedSem = $semesters->where('id_semester', $selectedSemester)->first(); + $semCap = $allCapaian->where('id_semester', $selectedSemester); + $currentIdx = $allSemestersOrdered->search(fn($s) => $s->id_semester === $selectedSemester); + $prevSemester = $currentIdx > 0 ? $allSemestersOrdered[$currentIdx - 1] : null; + $prevSemCap = $prevSemester + ? $allCapaian->where('id_semester', $prevSemester->id_semester) + : collect(); + $avgProgressSem = $semCap->avg('persentase') ?? 0; $avgProgressPrev = $prevSemCap->isNotEmpty() ? ($prevSemCap->avg('persentase') ?? 0) : 0; - $kenaikan = $avgProgressSem - $avgProgressPrev; - // Santri fully complete all materi - $santriFullKhatam = 0; - $santriIds = $semCap->pluck('id_santri')->unique(); - foreach ($santriIds as $sid) { - $sCap = $semCap->where('id_santri', $sid); - if ($sCap->isNotEmpty() && $sCap->every(fn($c) => $c->persentase >= 100)) { - $santriFullKhatam++; - } - } - - // Santri remedial (avg < 30%) + $santriIds = $semCap->pluck('id_santri')->unique(); + $santriFullKhatam = 0; $santriRemedialCount = 0; - $santriRemedialList = []; + $santriRemedialList = []; foreach ($santriIds as $sid) { $sCap = $semCap->where('id_santri', $sid); + if ($sCap->every(fn($c) => $c->persentase >= 100)) $santriFullKhatam++; if (($sCap->avg('persentase') ?? 0) < 30) { $santriRemedialCount++; $s = $santrisAktif->where('id_santri', $sid)->first(); @@ -628,106 +483,124 @@ public function dashboard(Request $request) } } - // Materi paling banyak dikhatamkan $materiKhatamList = $semCap->where('persentase', '>=', 100) ->groupBy('id_materi') ->map(fn($g) => ['count' => $g->count(), 'materi' => $g->first()->materi]) ->sortByDesc('count')->take(5)->values(); - // Materi paling sedikit progress $materiMinList = $semCap->groupBy('id_materi') ->map(fn($g) => ['avg' => round($g->avg('persentase'), 2), 'materi' => $g->first()->materi]) ->sortBy('avg')->take(5)->values(); $semesterSummary = [ - 'semester' => $selectedSem, - 'prev_semester' => $prevSemester, - 'total_santri' => $santriIds->count(), - 'avg_progress' => round($avgProgressSem, 2), - 'avg_progress_prev' => round($avgProgressPrev, 2), - 'kenaikan' => round($kenaikan, 2), - 'santri_khatam' => $santriFullKhatam, + 'semester' => $selectedSem, + 'prev_semester' => $prevSemester, + 'total_santri' => $santriIds->count(), + 'avg_progress' => round($avgProgressSem, 2), + 'avg_progress_prev' => round($avgProgressPrev, 2), + 'kenaikan' => round($avgProgressSem - $avgProgressPrev, 2), + 'santri_khatam' => $santriFullKhatam, 'santri_remedial_count' => $santriRemedialCount, - 'santri_remedial' => $santriRemedialList, - 'materi_khatam' => $materiKhatamList, - 'materi_min' => $materiMinList, + 'santri_remedial' => $santriRemedialList, + 'materi_khatam' => $materiKhatamList, + 'materi_min' => $materiMinList, ]; } + // --- Santri Ringkasan (overview tab) --- + $santriProgressList = []; + foreach ($santrisAktif as $santri) { + $sc = $filteredCapaian->where('id_santri', $santri->id_santri); + $avg = $sc->isNotEmpty() ? round($sc->avg('persentase'), 1) : 0; + $santriProgressList[] = ['santri' => $santri, 'avg' => $avg]; + } + usort($santriProgressList, fn($a, $b) => $b['avg'] <=> $a['avg']); + + $DISPLAY_LIMIT = 8; + $santriRingkasan = match ($filterSantri) { + 'top' => array_slice($santriProgressList, 0, $DISPLAY_LIMIT), + 'perhatian' => array_slice(array_reverse($santriProgressList), 0, $DISPLAY_LIMIT), + default => array_slice($santriProgressList, 0, $DISPLAY_LIMIT), + }; + $totalSantriFiltered = count($santriProgressList); + + // --- Status akses input capaian santri --- + $capaianAccessOpen = CapaianAccessService::isOpen(); + $capaianAccessConfig = CapaianAccessService::getConfig(); + return view('admin.capaian.dashboard', compact( 'semesters', 'allSemestersOrdered', 'selectedSemester', 'semesterAktif', 'kelas', 'kelasList', 'kelasModels', 'santrisAktif', 'santrisKhatam', 'materis', - 'totalCapaian', 'totalSantriAktif', 'rataRataProgress', 'capaianSelesai', + 'totalSantriAktif', 'rataRataProgress', 'statistikKategori', 'distribusiProgress', 'rekapKelas', 'semesterLabels', 'semesterComparison', - 'sosGrowth', 'materiCompletionRate', 'bottleneckMateri', - 'projectedGraduation', - 'semesterSummary' + 'projectedByKelas', + 'semesterSummary', + 'santriRingkasan', 'totalSantriFiltered', 'filterSantri', + 'capaianAccessOpen', 'capaianAccessConfig' )); } - /** - * Tandai santri sebagai Khatam - */ + // ===================================================================== + // TANDAI / BATAL KHATAM β€” FIX: DB::table agar tidak truncate enum + // ===================================================================== public function tandaiKhatam($id_santri) { $santri = Santri::where('id_santri', $id_santri)->firstOrFail(); - $santri->update(['status' => 'Khatam']); - return redirect()->back()->with('success', "Santri {$santri->nama_lengkap} berhasil ditandai sebagai Khatam."); + DB::table('santris') + ->where('id', $santri->id) + ->update(['status' => 'Khatam', 'updated_at' => now()]); + return redirect()->back() + ->with('success', "Santri {$santri->nama_lengkap} berhasil ditandai sebagai Khatam."); } - /** - * Batalkan status Khatam - */ public function batalKhatam($id_santri) { $santri = Santri::where('id_santri', $id_santri)->firstOrFail(); - $santri->update(['status' => 'Aktif']); - return redirect()->back()->with('success', "Status Khatam santri {$santri->nama_lengkap} berhasil dibatalkan."); + DB::table('santris') + ->where('id', $santri->id) + ->update(['status' => 'Aktif', 'updated_at' => now()]); + return redirect()->back() + ->with('success', "Status Khatam santri {$santri->nama_lengkap} berhasil dibatalkan."); } - /** - * Export Rapor Per Santri Per Semester - */ + // ===================================================================== + // EXPORT RAPOR + // ===================================================================== public function exportRapor($id_santri, $id_semester) { - $santri = Santri::where('id_santri', $id_santri) - ->with('kelasPrimary.kelas') - ->firstOrFail(); + $santri = Santri::where('id_santri', $id_santri)->with('kelasPrimary.kelas')->firstOrFail(); $semester = Semester::where('id_semester', $id_semester)->firstOrFail(); $capaians = Capaian::where('id_santri', $id_santri) ->where('id_semester', $id_semester) - ->with('materi') - ->orderBy('created_at') - ->get(); + ->with('materi')->orderBy('created_at')->get(); - // Previous semester for comparison - $allSem = Semester::orderBy('tahun_ajaran')->orderBy('periode')->get(); - $curIdx = $allSem->search(fn($s) => $s->id_semester === $id_semester); + $allSem = Semester::orderBy('tahun_ajaran')->orderBy('periode')->get(); + $curIdx = $allSem->search(fn($s) => $s->id_semester === $id_semester); $prevSemester = $curIdx > 0 ? $allSem[$curIdx - 1] : null; $prevCapaians = $prevSemester - ? Capaian::where('id_santri', $id_santri)->where('id_semester', $prevSemester->id_semester)->with('materi')->get() + ? Capaian::where('id_santri', $id_santri) + ->where('id_semester', $prevSemester->id_semester) + ->with('materi')->get() : collect(); - // Stats $avgProgress = $capaians->avg('persentase') ?? 0; - $avgPrev = $prevCapaians->avg('persentase') ?? 0; - $selesai = $capaians->where('persentase', '>=', 100)->count(); + $avgPrev = $prevCapaians->avg('persentase') ?? 0; + $selesai = $capaians->where('persentase', '>=', 100)->count(); $totalMateri = $capaians->count(); - // Per kategori $perKategori = []; - foreach (['Al-Qur\'an', 'Hadist', 'Materi Tambahan'] as $kat) { - $katCap = $capaians->filter(fn($c) => $c->materi && $c->materi->kategori === $kat); + foreach (["Al-Qur'an", 'Hadist', 'Materi Tambahan'] as $kat) { + $katCap = $capaians->filter(fn($c) => $c->materi && $c->materi->kategori === $kat); $katPrev = $prevCapaians->filter(fn($c) => $c->materi && $c->materi->kategori === $kat); $perKategori[$kat] = [ - 'avg' => round($katCap->avg('persentase') ?? 0, 2), - 'prev' => round($katPrev->avg('persentase') ?? 0, 2), - 'count' => $katCap->count(), + 'avg' => round($katCap->avg('persentase') ?? 0, 2), + 'prev' => round($katPrev->avg('persentase') ?? 0, 2), + 'count' => $katCap->count(), 'selesai' => $katCap->where('persentase', '>=', 100)->count(), ]; } @@ -738,106 +611,79 @@ public function exportRapor($id_santri, $id_semester) )); } - /** - * Detail capaian per materi (semua santri) - */ + // ===================================================================== + // DETAIL MATERI + // ===================================================================== public function detailMateri($id_materi, Request $request) { - $materi = Materi::where('id_materi', $id_materi)->firstOrFail(); - - $idSemester = $request->input('id_semester'); - $semesterAktif = Semester::aktif()->first(); - $selectedSemester = $idSemester ?: ($semesterAktif ? $semesterAktif->id_semester : null); + $materi = Materi::where('id_materi', $id_materi)->firstOrFail(); + $semesterAktif = Semester::aktif()->first(); + $selectedSemester = $request->input('id_semester', $semesterAktif?->id_semester); - // Get all capaian untuk materi ini $capaians = Capaian::where('id_materi', $id_materi) - ->when($selectedSemester, function($q) use ($selectedSemester) { - return $q->where('id_semester', $selectedSemester); - }) + ->when($selectedSemester, fn($q) => $q->where('id_semester', $selectedSemester)) ->with(['santri.kelasPrimary.kelas', 'semester']) - ->orderBy('persentase', 'desc') - ->get(); + ->orderBy('persentase', 'desc')->get(); - // Statistik - $totalSantri = $capaians->count(); + $totalSantri = $capaians->count(); $rataRataPersentase = $capaians->avg('persentase') ?? 0; - $santriSelesai = $capaians->where('persentase', '>=', 100)->count(); - $santriMulai = $capaians->where('persentase', '>', 0)->where('persentase', '<', 100)->count(); + $santriSelesai = $capaians->where('persentase', '>=', 100)->count(); + $santriMulai = $capaians->where('persentase', '>', 0)->where('persentase', '<', 100)->count(); - // Distribusi persentase $distribusi = [ - '0-25%' => $capaians->whereBetween('persentase', [0, 25])->count(), + '0-25%' => $capaians->whereBetween('persentase', [0, 25])->count(), '26-50%' => $capaians->whereBetween('persentase', [26, 50])->count(), '51-75%' => $capaians->whereBetween('persentase', [51, 75])->count(), '76-99%' => $capaians->whereBetween('persentase', [76, 99])->count(), - '100%' => $capaians->where('persentase', '>=', 100)->count(), + '100%' => $capaians->where('persentase', '>=', 100)->count(), ]; $semesters = Semester::orderBy('tahun_ajaran', 'desc')->get(); return view('admin.capaian.detail-materi', compact( - 'materi', - 'capaians', - 'totalSantri', - 'rataRataPersentase', - 'santriSelesai', - 'santriMulai', - 'distribusi', - 'semesters', - 'selectedSemester' + 'materi', 'capaians', 'totalSantri', 'rataRataPersentase', + 'santriSelesai', 'santriMulai', 'distribusi', 'semesters', 'selectedSemester' )); } - /** - * API untuk data grafik (AJAX) - */ + // ===================================================================== + // API GRAFIK (AJAX) + // ===================================================================== public function apiGrafikData(Request $request) { - $type = $request->input('type', 'kategori'); + $type = $request->input('type', 'kategori'); $idSemester = $request->input('id_semester'); - $kelas = $request->input('kelas'); + $kelas = $request->input('kelas'); $query = Capaian::with(['santri', 'materi']); - - if ($idSemester) { - $query->bySemester($idSemester); - } - + if ($idSemester) $query->bySemester($idSemester); if ($kelas) { - $query->whereHas('santri', function($q) use ($kelas) { - $q->kelasByName($kelas); - }); + $query->whereHas('santri', fn($q) => $q->kelasByName($kelas)); } $data = []; - switch ($type) { case 'kategori': $data = [ - 'labels' => ['Al-Qur\'an', 'Hadist', 'Materi Tambahan'], + 'labels' => ["Al-Qur'an", 'Hadist', 'Materi Tambahan'], 'datasets' => [[ - 'label' => 'Rata-rata Progress (%)', - 'data' => [ - $query->clone()->byKategori('Al-Qur\'an')->avg('persentase') ?? 0, + 'label' => 'Rata-rata Progress (%)', + 'data' => [ + $query->clone()->byKategori("Al-Qur'an")->avg('persentase') ?? 0, $query->clone()->byKategori('Hadist')->avg('persentase') ?? 0, $query->clone()->byKategori('Materi Tambahan')->avg('persentase') ?? 0, ], - 'backgroundColor' => [ - 'rgba(111, 186, 157, 0.8)', - 'rgba(129, 198, 232, 0.8)', - 'rgba(255, 213, 107, 0.8)', - ], + 'backgroundColor' => ['rgba(111,186,157,0.8)', 'rgba(129,198,232,0.8)', 'rgba(255,213,107,0.8)'], ]] ]; break; - case 'distribusi': $capaians = $query->get(); $data = [ - 'labels' => ['0-25%', '26-50%', '51-75%', '76-99%', '100%'], + 'labels' => ['0-25%', '26-50%', '51-75%', '76-99%', '100%'], 'datasets' => [[ - 'label' => 'Jumlah Santri', - 'data' => [ + 'label' => 'Jumlah Santri', + 'data' => [ $capaians->whereBetween('persentase', [0, 25])->count(), $capaians->whereBetween('persentase', [26, 50])->count(), $capaians->whereBetween('persentase', [51, 75])->count(), @@ -845,42 +691,33 @@ public function apiGrafikData(Request $request) $capaians->where('persentase', '>=', 100)->count(), ], 'backgroundColor' => [ - 'rgba(255, 139, 148, 0.8)', - 'rgba(255, 171, 145, 0.8)', - 'rgba(255, 213, 107, 0.8)', - 'rgba(129, 198, 232, 0.8)', - 'rgba(111, 186, 157, 0.8)', + 'rgba(255,139,148,0.8)', 'rgba(255,171,145,0.8)', + 'rgba(255,213,107,0.8)', 'rgba(129,198,232,0.8)', + 'rgba(111,186,157,0.8)', ], ]] ]; break; - case 'trend': - // Get data per semester - $semesters = Semester::orderBy('tahun_ajaran')->orderBy('periode')->get(); - $labels = []; + $semesters = Semester::orderBy('tahun_ajaran')->orderBy('periode')->get(); + $labels = []; $dataPoints = []; - foreach ($semesters as $semester) { - $labels[] = $semester->nama_semester; - $avg = Capaian::where('id_semester', $semester->id_semester) - ->when($kelas, function($q) use ($kelas) { - return $q->whereHas('santri', function($query) use ($kelas) { - $query->kelasByName($kelas); - }); - }) - ->avg('persentase') ?? 0; - $dataPoints[] = round($avg, 2); + $labels[] = $semester->nama_semester; + $dataPoints[] = round( + Capaian::where('id_semester', $semester->id_semester) + ->when($kelas, fn($q) => $q->whereHas('santri', fn($sq) => $sq->kelasByName($kelas))) + ->avg('persentase') ?? 0, 2 + ); } - $data = [ - 'labels' => $labels, + 'labels' => $labels, 'datasets' => [[ - 'label' => 'Rata-rata Progress (%)', - 'data' => $dataPoints, - 'borderColor' => 'rgba(111, 186, 157, 1)', - 'backgroundColor' => 'rgba(111, 186, 157, 0.2)', - 'tension' => 0.4, + 'label' => 'Rata-rata Progress (%)', + 'data' => $dataPoints, + 'borderColor' => 'rgba(111,186,157,1)', + 'backgroundColor' => 'rgba(111,186,157,0.2)', + 'tension' => 0.4, ]] ]; break; @@ -888,4 +725,60 @@ public function apiGrafikData(Request $request) return response()->json($data); } + + // ===================================================================== + // KELOLA AKSES INPUT CAPAIAN OLEH SANTRI + // ===================================================================== + + /** + * Halaman pengaturan akses input capaian santri. + * GET /admin/capaian/akses-santri + */ + public function kelolaAksesSantri() + { + $config = CapaianAccessService::getConfig(); + $isOpen = CapaianAccessService::isOpen(); + $sisaWaktu = CapaianAccessService::getSisaWaktu(); + $semesters = Semester::orderBy('tahun_ajaran', 'desc')->get(); + $semesterAktif = Semester::aktif()->first(); + + return view('admin.capaian.akses-santri', compact( + 'config', 'isOpen', 'sisaWaktu', 'semesters', 'semesterAktif' + )); + } + + /** + * Buka akses input capaian untuk santri. + * POST /admin/capaian/akses-santri/buka + */ + public function bukaAksesSantri(Request $request) + { + $request->validate([ + 'id_semester' => 'nullable|exists:semester,id_semester', + 'durasi_jam' => 'nullable|integer|min:1|max:720', + 'catatan' => 'nullable|string|max:255', + ]); + + CapaianAccessService::open([ + 'opened_by' => auth()->user()->name, + 'id_semester' => $request->id_semester, + 'durasi_jam' => $request->durasi_jam, + 'catatan' => $request->catatan, + ]); + + return redirect()->route('admin.capaian.akses-santri') + ->with('success', 'Akses input capaian untuk santri berhasil dibuka.'); + } + + /** + * Tutup akses input capaian. + * POST /admin/capaian/akses-santri/tutup + */ + public function tutupAksesSantri() + { + CapaianAccessService::close(); + + return redirect()->route('admin.capaian.akses-santri') + ->with('success', 'Akses input capaian untuk santri berhasil ditutup.'); + } } \ No newline at end of file diff --git a/sim-pkpps/app/Http/Controllers/Admin/KartuRfidController.php b/sim-pkpps/app/Http/Controllers/Admin/KartuRfidController.php index dfaedba..d969c63 100644 --- a/sim-pkpps/app/Http/Controllers/Admin/KartuRfidController.php +++ b/sim-pkpps/app/Http/Controllers/Admin/KartuRfidController.php @@ -5,18 +5,16 @@ use App\Http\Controllers\Controller; use App\Models\Santri; use Illuminate\Http\Request; -use Barryvdh\DomPDF\Facade\Pdf; +use Mpdf\Mpdf; +use Mpdf\Config\ConfigVariables; +use Mpdf\Config\FontVariables; class KartuRfidController extends Controller { - /** - * Halaman kelola kartu RFID - */ public function index(Request $request) { $query = Santri::where('status', 'Aktif'); - // Filter: Santri yang sudah/belum punya RFID if ($request->filled('filter')) { if ($request->filter == 'ada_rfid') { $query->whereNotNull('rfid_uid'); @@ -25,32 +23,28 @@ public function index(Request $request) } } - $santris = $query->select('id', 'id_santri', 'nama_lengkap', 'kelas', 'rfid_uid') + $santris = $query + ->select('id', 'id_santri', 'nis', 'nama_lengkap', 'rfid_uid', 'foto', 'status') + ->with(['kelasSantri.kelas']) ->orderBy('nama_lengkap') ->paginate(15); return view('admin.kegiatan.kartu.index', compact('santris')); } - /** - * Form daftarkan RFID ke santri - */ public function daftarRfid($id_santri) { $santri = Santri::where('id_santri', $id_santri)->firstOrFail(); return view('admin.kegiatan.kartu.daftar', compact('santri')); } - /** - * Simpan RFID UID ke santri - */ public function simpanRfid(Request $request, $id_santri) { - $validated = $request->validate([ + $request->validate([ 'rfid_uid' => 'required|string|max:50|unique:santris,rfid_uid', ], [ 'rfid_uid.required' => 'UID RFID wajib diisi.', - 'rfid_uid.unique' => 'UID RFID ini sudah terdaftar pada santri lain.', + 'rfid_uid.unique' => 'UID RFID ini sudah terdaftar pada santri lain.', ]); $santri = Santri::where('id_santri', $id_santri)->firstOrFail(); @@ -60,9 +54,6 @@ public function simpanRfid(Request $request, $id_santri) ->with('success', 'RFID berhasil didaftarkan untuk ' . $santri->nama_lengkap); } - /** - * Hapus RFID dari santri - */ public function hapusRfid($id_santri) { $santri = Santri::where('id_santri', $id_santri)->firstOrFail(); @@ -72,20 +63,104 @@ public function hapusRfid($id_santri) ->with('success', 'RFID berhasil dihapus dari ' . $santri->nama_lengkap); } - /** - * Cetak kartu RFID santri (PDF) - */ public function cetakKartu($id_santri) { - $santri = Santri::where('id_santri', $id_santri)->firstOrFail(); + $santri = Santri::where('id_santri', $id_santri) + ->with([ + 'kelasPrimary.kelas', + 'kelasSantri' => fn($q) => $q->orderByDesc('is_primary')->orderBy('id'), + 'kelasSantri.kelas', + ]) + ->firstOrFail(); if (!$santri->rfid_uid) { return back()->with('error', 'Santri belum memiliki RFID yang terdaftar.'); } - $pdf = Pdf::loadView('admin.kegiatan.kartu.cetak', compact('santri')); - $pdf->setPaper([0, 0, 243, 153], 'landscape'); // Ukuran kartu ID (85.6mm x 54mm) + // ── Siapkan data untuk view ────────────────────────────────────── + $namaSantri = strtoupper($santri->nama_lengkap ?? 'NAMA SANTRI'); + $initial = strtoupper(substr($santri->nama_lengkap ?? 'S', 0, 1)); + $nis = !empty($santri->nis) ? $santri->nis : '-'; + $uid = !empty($santri->rfid_uid) ? $santri->rfid_uid : '-'; - return $pdf->stream('Kartu_RFID_' . $santri->id_santri . '.pdf'); + // Kelas: pakai kelasPrimary, fallback ke first kelasSantri + $kelasNama = '-'; + if ($santri->kelasPrimary && $santri->kelasPrimary->kelas) { + $kelasNama = strtoupper($santri->kelasPrimary->kelas->nama_kelas); + } elseif ($santri->kelasSantri->first() && $santri->kelasSantri->first()->kelas) { + $kelasNama = strtoupper($santri->kelasSantri->first()->kelas->nama_kelas); + } + + // Logo β€” embed base64 (tidak butuh GD) + $logoBase64 = ''; + $logoMime = 'image/png'; + foreach ([ + public_path('images/logo.png'), + public_path('images/logo.jpg'), + public_path('img/logo.png'), + public_path('logo.png'), + ] as $lp) { + if (file_exists($lp)) { + $ext = strtolower(pathinfo($lp, PATHINFO_EXTENSION)); + $logoMime = $ext === 'png' ? 'image/png' : 'image/jpeg'; + $logoBase64 = base64_encode(file_get_contents($lp)); + break; + } + } + + // Foto santri β€” embed base64 (tidak butuh GD) + $fotoBase64 = ''; + $fotoMime = 'image/jpeg'; + if (!empty($santri->foto)) { + foreach ([ + storage_path('app/public/' . $santri->foto), + public_path('storage/' . $santri->foto), + public_path($santri->foto), + ] as $fp) { + if (file_exists($fp)) { + $ext = strtolower(pathinfo($fp, PATHINFO_EXTENSION)); + $fotoMime = in_array($ext, ['png', 'gif', 'webp']) ? 'image/' . $ext : 'image/jpeg'; + $fotoBase64 = base64_encode(file_get_contents($fp)); + break; + } + } + } + + // ── Render HTML dari blade ──────────────────────────────────────── + $html = view('admin.kegiatan.kartu.cetak', compact( + 'santri', + 'namaSantri', 'initial', 'nis', 'uid', 'kelasNama', + 'logoBase64', 'logoMime', + 'fotoBase64', 'fotoMime' + ))->render(); + + // ── Inisialisasi mPDF ───────────────────────────────────────────── + // Format: 54mm Γ— 85.6mm (ukuran kartu ID standar) + $mpdf = new Mpdf([ + 'mode' => 'utf-8', + 'format' => [54, 85.6], + 'orientation' => 'P', + 'margin_top' => 0, + 'margin_bottom' => 0, + 'margin_left' => 0, + 'margin_right' => 0, + 'margin_header' => 0, + 'margin_footer' => 0, + 'default_font' => 'dejavusans', + 'tempDir' => storage_path('app/mpdf_tmp'), + 'autoScriptToLang' => false, + 'autoLangToFont' => false, + // Aktifkan dukungan SVG (untuk foto bulat) + 'enableImports' => true, + ]); + + // Matikan page break otomatis + $mpdf->SetAutoPageBreak(false); + $mpdf->SetDisplayMode('fullpage'); + $mpdf->WriteHTML($html); + + return response($mpdf->Output('Kartu_RFID_' . $santri->id_santri . '.pdf', 'S')) + ->header('Content-Type', 'application/pdf') + ->header('Content-Disposition', 'inline; filename="Kartu_RFID_' . $santri->id_santri . '.pdf"'); } } \ No newline at end of file diff --git a/sim-pkpps/app/Http/Controllers/Admin/KegiatanController.php b/sim-pkpps/app/Http/Controllers/Admin/KegiatanController.php index 76a343c..4a0b113 100644 --- a/sim-pkpps/app/Http/Controllers/Admin/KegiatanController.php +++ b/sim-pkpps/app/Http/Controllers/Admin/KegiatanController.php @@ -19,357 +19,275 @@ class KegiatanController extends Controller */ public function index(Request $request) { - // Tentukan tanggal yang dipilih (default: hari ini, tapi bisa pilih tanggal lain) - $selectedDate = $request->filled('tanggal') - ? Carbon::parse($request->tanggal) + $selectedDate = $request->filled('tanggal') + ? Carbon::parse($request->tanggal) : Carbon::now(); - + $hariIndonesia = [ - 'Monday' => 'Senin', - 'Tuesday' => 'Selasa', + 'Monday' => 'Senin', + 'Tuesday' => 'Selasa', 'Wednesday' => 'Rabu', - 'Thursday' => 'Kamis', - 'Friday' => 'Jumat', - 'Saturday' => 'Sabtu', - 'Sunday' => 'Ahad' + 'Thursday' => 'Kamis', + 'Friday' => 'Jumat', + 'Saturday' => 'Sabtu', + 'Sunday' => 'Ahad', ]; - - $selectedHari = $hariIndonesia[$selectedDate->format('l')]; - - // Filter kelas (optional) + + $selectedHari = $hariIndonesia[$selectedDate->format('l')]; $selectedKelasId = $request->filled('kelas') ? $request->kelas : null; - - // Query kegiatan hari yang dipilih - $query = Kegiatan::with(['kategori', 'kelasKegiatan.kelompok', 'absensis' => function($q) use ($selectedDate) { + + $query = Kegiatan::with(['kategori', 'kelasKegiatan.kelompok', 'absensis' => function ($q) use ($selectedDate) { $q->whereDate('tanggal', $selectedDate->format('Y-m-d')); }])->where('hari', $selectedHari); - - // Filter by kelas if selected + if ($selectedKelasId) { if ($selectedKelasId === 'umum') { - // Kegiatan umum (tidak punya relasi kelas) $query->doesntHave('kelasKegiatan'); } else { - // Kegiatan untuk kelas tertentu - $query->whereHas('kelasKegiatan', function($q) use ($selectedKelasId) { + $query->whereHas('kelasKegiatan', function ($q) use ($selectedKelasId) { $q->where('kelas.id', $selectedKelasId); }); } } - - $kegiatanHariIni = $query->orderBy('waktu_mulai')->get(); - - // Total santri aktif (untuk perhitungan %) + + if ($request->filled('kategori_id')) { + $query->where('kategori_id', $request->kategori_id); + } + + $kegiatanHariIni = $query->orderBy('waktu_mulai')->get(); $totalSantriAktif = Santri::where('status', 'Aktif')->count(); - - // Hitung statistik untuk setiap kegiatan + $kegiatanHariIni->each(function ($kegiatan) use ($totalSantriAktif, $selectedDate) { - $totalAbsensi = $kegiatan->absensis->count(); - $hadir = $kegiatan->absensis->where('status', 'Hadir')->count(); - - // Persentase kehadiran + $totalAbsensi = $kegiatan->absensis->count(); + $hadir = $kegiatan->absensis->where('status', 'Hadir')->count(); $persenKehadiran = $totalAbsensi > 0 ? round(($hadir / $totalAbsensi) * 100) : 0; - - // Status kegiatan berdasarkan waktu - $now = Carbon::now(); - $waktuMulaiStr = is_string($kegiatan->waktu_mulai) - ? $kegiatan->waktu_mulai - : $kegiatan->waktu_mulai->format('H:i'); - $waktuSelesaiStr = is_string($kegiatan->waktu_selesai) - ? $kegiatan->waktu_selesai - : $kegiatan->waktu_selesai->format('H:i'); - - $waktuMulai = Carbon::parse($selectedDate->format('Y-m-d') . ' ' . $waktuMulaiStr); - $waktuSelesai = Carbon::parse($selectedDate->format('Y-m-d') . ' ' . $waktuSelesaiStr); - + + $now = Carbon::now(); + $waktuMulaiStr = is_string($kegiatan->waktu_mulai) ? $kegiatan->waktu_mulai : $kegiatan->waktu_mulai->format('H:i'); + $waktuSelesaiStr = is_string($kegiatan->waktu_selesai) ? $kegiatan->waktu_selesai : $kegiatan->waktu_selesai->format('H:i'); + $waktuMulai = Carbon::parse($selectedDate->format('Y-m-d') . ' ' . $waktuMulaiStr); + $waktuSelesai = Carbon::parse($selectedDate->format('Y-m-d') . ' ' . $waktuSelesaiStr); + if ($selectedDate->isToday()) { - if ($now->lt($waktuMulai)) { - $status = 'belum'; - } elseif ($now->between($waktuMulai, $waktuSelesai)) { - $status = 'berlangsung'; - } else { - $status = 'selesai'; - } + if ($now->lt($waktuMulai)) $status = 'belum'; + elseif ($now->between($waktuMulai, $waktuSelesai)) $status = 'berlangsung'; + else $status = 'selesai'; } elseif ($selectedDate->isFuture()) { $status = 'belum'; } else { $status = 'selesai'; } - - // Tambahkan data ke object - $kegiatan->total_hadir = $hadir; - $kegiatan->total_absensi = $totalAbsensi; + + $kegiatan->total_hadir = $hadir; + $kegiatan->total_absensi = $totalAbsensi; $kegiatan->persen_kehadiran = $persenKehadiran; - $kegiatan->status_kegiatan = $status; + $kegiatan->status_kegiatan = $status; }); - - // KPI Cards + $totalKegiatanHariIni = $kegiatanHariIni->count(); - $kegiatanSelesai = $kegiatanHariIni->where('status_kegiatan', 'selesai')->count(); - $kegiatanBerlangsung = $kegiatanHariIni->where('status_kegiatan', 'berlangsung')->count(); - $avgKehadiran = $kegiatanHariIni->count() > 0 - ? round($kegiatanHariIni->avg('persen_kehadiran')) - : 0; - - // KPI Comparison vs minggu lalu (same day) - $lastWeekDate = $selectedDate->copy()->subWeek(); - $lastWeekHari = $hariIndonesia[$lastWeekDate->format('l')]; - - $kegiatanLastWeek = Kegiatan::where('hari', $lastWeekHari)->count(); - $comparisonTotal = $totalKegiatanHariIni - $kegiatanLastWeek; - - // Avg kehadiran minggu lalu - $avgKehadiranLastWeek = Cache::remember('avg_kehadiran_' . $lastWeekDate->format('Y-m-d'), 600, function() use ($lastWeekDate, $lastWeekHari) { - $kegiatanLastWeek = Kegiatan::where('hari', $lastWeekHari)->get(); + $kegiatanSelesai = $kegiatanHariIni->where('status_kegiatan', 'selesai')->count(); + $kegiatanBerlangsung = $kegiatanHariIni->where('status_kegiatan', 'berlangsung')->count(); + $avgKehadiran = $kegiatanHariIni->count() > 0 ? round($kegiatanHariIni->avg('persen_kehadiran')) : 0; + + $lastWeekDate = $selectedDate->copy()->subWeek(); + $lastWeekHari = $hariIndonesia[$lastWeekDate->format('l')]; + $kegiatanLastWeekCount = Kegiatan::where('hari', $lastWeekHari)->count(); + $comparisonTotal = $totalKegiatanHariIni - $kegiatanLastWeekCount; + + $avgKehadiranLastWeek = Cache::remember('avg_kehadiran_' . $lastWeekDate->format('Y-m-d'), 600, function () use ($lastWeekDate, $lastWeekHari) { + $list = Kegiatan::where('hari', $lastWeekHari)->get(); $totalPersen = 0; - $count = 0; - - foreach ($kegiatanLastWeek as $kg) { - $absensi = AbsensiKegiatan::where('kegiatan_id', $kg->kegiatan_id) - ->whereDate('tanggal', $lastWeekDate->format('Y-m-d')) - ->get(); - if ($absensi->count() > 0) { - $hadir = $absensi->where('status', 'Hadir')->count(); - $totalPersen += ($hadir / $absensi->count()) * 100; + $count = 0; + foreach ($list as $kg) { + $abs = AbsensiKegiatan::where('kegiatan_id', $kg->kegiatan_id) + ->whereDate('tanggal', $lastWeekDate->format('Y-m-d'))->get(); + if ($abs->count() > 0) { + $totalPersen += ($abs->where('status', 'Hadir')->count() / $abs->count()) * 100; $count++; } } - return $count > 0 ? round($totalPersen / $count) : 0; }); - + $comparisonAvg = $avgKehadiran - $avgKehadiranLastWeek; - - // Get kelas list for filter tabs - $kelasList = Kelas::with('kelompok')->active()->ordered()->get(); - - // Generate Quick Insights - $insights = $this->generateInsights($kegiatanHariIni, $totalSantriAktif, $selectedDate); - - // Heatmap data (30 hari terakhir) - cached - $heatmapData = Cache::remember('heatmap_30days_' . now()->format('Y-m-d'), 600, function() { + $kelasList = Kelas::with('kelompok')->active()->ordered()->get(); + $kategoris = KategoriKegiatan::select('kategori_id', 'nama_kategori')->get(); + $insights = $this->generateInsights($kegiatanHariIni, $totalSantriAktif, $selectedDate); + + $heatmapData = Cache::remember('heatmap_30days_' . now()->format('Y-m-d'), 600, function () { return $this->generateHeatmapData(); }); - - // Data untuk view + $hariList = ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu', 'Ahad']; - + return view('admin.kegiatan.data.dashboard', compact( - 'kegiatanHariIni', - 'totalKegiatanHariIni', - 'kegiatanSelesai', - 'kegiatanBerlangsung', - 'avgKehadiran', - 'totalSantriAktif', - 'selectedDate', - 'selectedHari', - 'hariList', - 'kelasList', - 'selectedKelasId', - 'comparisonTotal', - 'comparisonAvg', - 'insights', - 'heatmapData' + 'kegiatanHariIni', 'totalKegiatanHariIni', 'kegiatanSelesai', + 'kegiatanBerlangsung', 'avgKehadiran', 'totalSantriAktif', + 'selectedDate', 'selectedHari', 'hariList', 'kelasList', + 'selectedKelasId', 'comparisonTotal', 'comparisonAvg', + 'insights', 'heatmapData', 'kategoris' )); } - + /** * Generate Quick Insights (Rule-Based AI) */ private function generateInsights($kegiatanHariIni, $totalSantriAktif, $selectedDate) { $insights = []; - - // Rule 1: Kehadiran rendah (<70%) + foreach ($kegiatanHariIni as $kegiatan) { if ($kegiatan->total_absensi > 0 && $kegiatan->persen_kehadiran < 70) { $insights[] = [ - 'type' => 'warning', - 'icon' => 'exclamation-triangle', - 'message' => "Kegiatan {$kegiatan->nama_kegiatan} kehadiran rendah ({$kegiatan->persen_kehadiran}%)", - 'detail' => "{$kegiatan->total_hadir} dari {$kegiatan->total_absensi} santri hadir", - 'action_url' => route('admin.absensi-kegiatan.input', $kegiatan->kegiatan_id) . '?tanggal=' . $selectedDate->format('Y-m-d'), - 'action_text' => 'Input Absensi' + 'type' => 'warning', 'icon' => 'exclamation-triangle', + 'message' => "Kegiatan {$kegiatan->nama_kegiatan} kehadiran rendah ({$kegiatan->persen_kehadiran}%)", + 'detail' => "{$kegiatan->total_hadir} dari {$kegiatan->total_absensi} santri hadir", + 'action_url' => route('admin.absensi-kegiatan.input', $kegiatan->kegiatan_id) . '?tanggal=' . $selectedDate->format('Y-m-d'), + 'action_text' => 'Input Absensi', ]; } } - - // Rule 2: Kehadiran perfect (100%) + foreach ($kegiatanHariIni as $kegiatan) { if ($kegiatan->persen_kehadiran == 100 && $kegiatan->total_absensi > 0) { $insights[] = [ - 'type' => 'success', - 'icon' => 'check-circle', + 'type' => 'success', 'icon' => 'check-circle', 'message' => "Perfect! {$kegiatan->nama_kegiatan} kehadiran 100%", - 'detail' => 'Semua santri hadir', - 'action_url' => null, - 'action_text' => null + 'detail' => 'Semua santri hadir', 'action_url' => null, 'action_text' => null, ]; } } - - // Rule 3: Kegiatan sedang berlangsung + $kegiatanLive = $kegiatanHariIni->where('status_kegiatan', 'berlangsung')->first(); if ($kegiatanLive) { $insights[] = [ - 'type' => 'info', - 'icon' => 'clock', - 'message' => "Kegiatan {$kegiatanLive->nama_kegiatan} sedang berlangsung", - 'detail' => "Progress absensi: {$kegiatanLive->persen_kehadiran}%", - 'action_url' => route('admin.absensi-kegiatan.input', $kegiatanLive->kegiatan_id) . '?tanggal=' . $selectedDate->format('Y-m-d'), - 'action_text' => 'Input Absensi Sekarang' + 'type' => 'info', 'icon' => 'clock', + 'message' => "Kegiatan {$kegiatanLive->nama_kegiatan} sedang berlangsung", + 'detail' => "Progress absensi: {$kegiatanLive->persen_kehadiran}%", + 'action_url' => route('admin.absensi-kegiatan.input', $kegiatanLive->kegiatan_id) . '?tanggal=' . $selectedDate->format('Y-m-d'), + 'action_text' => 'Input Absensi Sekarang', ]; } - - // Rule 4: Kegiatan selesai tapi belum input absensi + foreach ($kegiatanHariIni as $kegiatan) { if ($kegiatan->status_kegiatan == 'selesai' && $kegiatan->total_absensi == 0) { - $waktuSelesai = is_string($kegiatan->waktu_selesai) - ? $kegiatan->waktu_selesai - : $kegiatan->waktu_selesai->format('H:i'); - + $waktuSelesai = is_string($kegiatan->waktu_selesai) ? $kegiatan->waktu_selesai : $kegiatan->waktu_selesai->format('H:i'); $insights[] = [ - 'type' => 'danger', - 'icon' => 'exclamation-circle', - 'message' => "Kegiatan {$kegiatan->nama_kegiatan} belum input absensi", - 'detail' => "Sudah selesai pukul {$waktuSelesai}", - 'action_url' => route('admin.absensi-kegiatan.input', $kegiatan->kegiatan_id) . '?tanggal=' . $selectedDate->format('Y-m-d'), - 'action_text' => 'Input Sekarang' + 'type' => 'danger', 'icon' => 'exclamation-circle', + 'message' => "Kegiatan {$kegiatan->nama_kegiatan} belum input absensi", + 'detail' => "Sudah selesai pukul {$waktuSelesai}", + 'action_url' => route('admin.absensi-kegiatan.input', $kegiatan->kegiatan_id) . '?tanggal=' . $selectedDate->format('Y-m-d'), + 'action_text' => 'Input Sekarang', ]; } } - - return collect($insights)->take(5)->toArray(); // Max 5 insights + + return collect($insights)->take(5)->toArray(); } - + /** * Generate Heatmap Data (30 hari terakhir) */ private function generateHeatmapData() { $heatmapData = []; - $startDate = Carbon::now()->subDays(29); - + $startDate = Carbon::now()->subDays(29); + for ($i = 0; $i < 30; $i++) { - $date = $startDate->copy()->addDays($i); + $date = $startDate->copy()->addDays($i); $dateStr = $date->format('Y-m-d'); - - // Hitung rata-rata kehadiran hari tersebut $absensi = AbsensiKegiatan::whereDate('tanggal', $dateStr)->get(); - - if ($absensi->count() > 0) { - $hadir = $absensi->where('status', 'Hadir')->count(); - $percentage = round(($hadir / $absensi->count()) * 100, 1); - } else { - $percentage = 0; - } - + + $percentage = $absensi->count() > 0 + ? round(($absensi->where('status', 'Hadir')->count() / $absensi->count()) * 100, 1) + : 0; + $heatmapData[] = [ - 'date' => $dateStr, - 'day_name' => $date->locale('id')->isoFormat('ddd'), + 'date' => $dateStr, + 'day_name' => $date->locale('id')->isoFormat('ddd'), 'percentage' => $percentage, - 'level' => $this->getHeatmapLevel($percentage), - 'is_today' => $date->isToday() + 'level' => $this->getHeatmapLevel($percentage), + 'is_today' => $date->isToday(), ]; } - + return $heatmapData; } - - /** - * Get Heatmap Level (0-4) - */ + private function getHeatmapLevel($percentage) { - if ($percentage >= 90) return 4; // Dark green - if ($percentage >= 80) return 3; // Green - if ($percentage >= 70) return 2; // Yellow - if ($percentage > 0) return 1; // Red - return 0; // No data + if ($percentage >= 90) return 4; + if ($percentage >= 80) return 3; + if ($percentage >= 70) return 2; + if ($percentage > 0) return 1; + return 0; } - + /** * AJAX: Get Detail Kegiatan untuk Modal */ public function getDetailModal($kegiatan_id, Request $request) { - $tanggal = $request->get('tanggal', now()->format('Y-m-d')); - + $tanggal = $request->get('tanggal', now()->format('Y-m-d')); $kegiatan = Kegiatan::with(['kategori', 'kelasKegiatan.kelompok']) - ->where('kegiatan_id', $kegiatan_id) - ->firstOrFail(); - - // Get absensi untuk tanggal tersebut - $absensis = AbsensiKegiatan::with('santri') + ->where('kegiatan_id', $kegiatan_id)->firstOrFail(); + + $absensis = AbsensiKegiatan::with(['santri.kelasSantri.kelas']) ->where('kegiatan_id', $kegiatan_id) ->whereDate('tanggal', $tanggal) - ->orderBy('waktu_absen', 'desc') - ->get(); - - // Statistik - $stats = [ - 'hadir' => $absensis->where('status', 'Hadir')->count(), - 'izin' => $absensis->where('status', 'Izin')->count(), - 'sakit' => $absensis->where('status', 'Sakit')->count(), - 'alpa' => $absensis->where('status', 'Alpa')->count(), - ]; - - // Total santri yang seharusnya + ->orderBy('waktu_absen', 'desc')->get(); + if ($kegiatan->isForAllClasses()) { - $totalSantri = Santri::where('status', 'Aktif')->count(); + $absensiPerKelas = $absensis->groupBy(fn($item) => $item->santri->kelas_name ?? 'Belum Ada Kelas')->sortKeys(); } else { - $totalSantri = $kegiatan->getEligibleSantris()->count(); + $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; + } } - - $stats['belum_absen'] = $totalSantri - $absensis->count(); - $stats['total'] = $totalSantri; + + $stats = [ + 'hadir' => $absensis->where('status', 'Hadir')->count(), + 'izin' => $absensis->where('status', 'Izin')->count(), + 'sakit' => $absensis->where('status', 'Sakit')->count(), + 'alpa' => $absensis->where('status', 'Alpa')->count(), + ]; + $totalSantri = $kegiatan->isForAllClasses() + ? Santri::where('status', 'Aktif')->count() + : $kegiatan->getEligibleSantris()->count(); + + $stats['belum_absen'] = $totalSantri - $absensis->count(); + $stats['total'] = $totalSantri; $stats['persen_hadir'] = $totalSantri > 0 ? round(($stats['hadir'] / $totalSantri) * 100, 1) : 0; - - return view('admin.kegiatan.data.partials.detail-modal', compact('kegiatan', 'absensis', 'stats', 'tanggal')); + + return view('admin.kegiatan.data.partials.detail-modal', compact('kegiatan', 'absensis', 'absensiPerKelas', 'stats', 'tanggal')); } - + /** - * Jadwal Kegiatan Lengkap (untuk "Lihat Semua Jadwal") + * Jadwal Kegiatan Lengkap */ public function jadwal(Request $request) { $query = Kegiatan::with(['kategori', 'kelasKegiatan.kelompok']); - // Filter hari - if ($request->filled('hari')) { - $query->where('hari', $request->hari); - } - - // Filter kategori - if ($request->filled('kategori_id')) { - $query->where('kategori_id', $request->kategori_id); - } - - // Filter kelas + if ($request->filled('hari')) $query->where('hari', $request->hari); + if ($request->filled('kategori_id')) $query->where('kategori_id', $request->kategori_id); if ($request->filled('kelas_id')) { if ($request->kelas_id === 'umum') { $query->doesntHave('kelasKegiatan'); } else { - $query->whereHas('kelasKegiatan', function($q) use ($request) { - $q->where('kelas.id', $request->kelas_id); - }); + $query->whereHas('kelasKegiatan', fn($q) => $q->where('kelas.id', $request->kelas_id)); } } - - // Search - if ($request->filled('search')) { - $query->search($request->search); - } + if ($request->filled('search')) $query->search($request->search); $kegiatans = $query->select('id', 'kegiatan_id', 'kategori_id', 'nama_kegiatan', 'hari', 'waktu_mulai', 'waktu_selesai', 'materi') - ->orderBy('hari') - ->orderBy('waktu_mulai') - ->paginate(15) - ->appends(request()->query()); + ->orderBy('hari')->orderBy('waktu_mulai') + ->paginate(15)->appends(request()->query()); - // Data untuk filter $kategoris = KategoriKegiatan::select('kategori_id', 'nama_kategori')->get(); - $hariList = ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu', 'Ahad']; + $hariList = ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu', 'Ahad']; $kelasList = Kelas::with('kelompok')->active()->ordered()->get(); return view('admin.kegiatan.data.index', compact('kegiatans', 'kategoris', 'hariList', 'kelasList')); @@ -382,15 +300,14 @@ public function create() { $nextId = Cache::remember('next_kegiatan_id', 60, function () { $last = Kegiatan::select('kegiatan_id')->orderBy('id', 'desc')->first(); - $num = $last ? intval(substr($last->kegiatan_id, 2)) + 1 : 1; + $num = $last ? intval(substr($last->kegiatan_id, 2)) + 1 : 1; return 'KG' . str_pad($num, 3, '0', STR_PAD_LEFT); }); - $kategoris = KategoriKegiatan::select('kategori_id', 'nama_kategori')->get(); - $hariList = ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu', 'Ahad']; - $kelompokKelas = KelompokKelas::with(['kelas' => function($q) { - $q->where('is_active', true)->orderBy('urutan'); - }])->active()->ordered()->get(); + $kategoris = KategoriKegiatan::select('kategori_id', 'nama_kategori')->get(); + $hariList = ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu', 'Ahad']; + $kelompokKelas = KelompokKelas::with(['kelas' => fn($q) => $q->where('is_active', true)->orderBy('urutan')]) + ->active()->ordered()->get(); return view('admin.kegiatan.data.create', compact('nextId', 'kategoris', 'hariList', 'kelompokKelas')); } @@ -401,35 +318,45 @@ public function create() public function store(Request $request) { $validated = $request->validate([ - 'kategori_id' => 'required|exists:kategori_kegiatans,kategori_id', + 'kategori_id' => 'required|exists:kategori_kegiatans,kategori_id', 'nama_kegiatan' => 'required|string|max:150', - 'hari' => 'required|in:Senin,Selasa,Rabu,Kamis,Jumat,Sabtu,Ahad', - 'waktu_mulai' => 'required|date_format:H:i', + 'hari' => 'required|array|min:1', + 'hari.*' => 'in:Senin,Selasa,Rabu,Kamis,Jumat,Sabtu,Ahad', + 'waktu_mulai' => 'required|date_format:H:i', 'waktu_selesai' => 'required|date_format:H:i|after:waktu_mulai', - 'materi' => 'nullable|string|max:200', - 'keterangan' => 'nullable|string', - 'kelas_ids' => 'nullable|array', - 'kelas_ids.*' => 'exists:kelas,id', + 'materi' => 'nullable|string|max:200', + 'keterangan' => 'nullable|string', + 'kelas_ids' => 'nullable|array', + 'kelas_ids.*' => 'exists:kelas,id', ], [ - 'kategori_id.required' => 'Kategori wajib dipilih.', + 'kategori_id.required' => 'Kategori wajib dipilih.', 'nama_kegiatan.required' => 'Nama kegiatan wajib diisi.', - 'hari.required' => 'Hari wajib dipilih.', - 'waktu_mulai.required' => 'Waktu mulai wajib diisi.', + 'hari.required' => 'Minimal pilih satu hari.', + 'hari.min' => 'Minimal pilih satu hari.', + 'waktu_mulai.required' => 'Waktu mulai wajib diisi.', 'waktu_selesai.required' => 'Waktu selesai wajib diisi.', - 'waktu_selesai.after' => 'Waktu selesai harus lebih dari waktu mulai.', + 'waktu_selesai.after' => 'Waktu selesai harus lebih dari waktu mulai.', ]); - $kegiatan = Kegiatan::create($validated); - - // Assign kelas to kegiatan if selected - if ($request->has('kelas_ids') && !empty($request->kelas_ids)) { - $kegiatan->assignKelas($request->kelas_ids); + $hariList = $validated['hari']; + unset($validated['hari']); + $createdCount = 0; + + foreach ($hariList as $hari) { + $kg = Kegiatan::create(array_merge($validated, ['hari' => $hari])); + if ($request->has('kelas_ids') && !empty($request->kelas_ids)) { + $kg->assignKelas($request->kelas_ids); + } + $createdCount++; } - + Cache::forget('next_kegiatan_id'); - return redirect()->route('admin.kegiatan.index') - ->with('success', 'Kegiatan berhasil ditambahkan.'); + $message = $createdCount > 1 + ? "Berhasil menambahkan kegiatan untuk {$createdCount} hari." + : 'Kegiatan berhasil ditambahkan.'; + + return redirect()->route('admin.kegiatan.jadwal')->with('success', $message); } /** @@ -446,48 +373,84 @@ public function show(Kegiatan $kegiatan) */ public function edit(Kegiatan $kegiatan) { - $kategoris = KategoriKegiatan::select('kategori_id', 'nama_kategori')->get(); - $hariList = ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu', 'Ahad']; - $kelompokKelas = KelompokKelas::with(['kelas' => function($q) { - $q->where('is_active', true)->orderBy('urutan'); - }])->active()->ordered()->get(); - - // Load existing kelas relations + $kategoris = KategoriKegiatan::select('kategori_id', 'nama_kategori')->get(); + $hariList = ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu', 'Ahad']; + $kelompokKelas = KelompokKelas::with(['kelas' => fn($q) => $q->where('is_active', true)->orderBy('urutan')]) + ->active()->ordered()->get(); + $kegiatan->load('kelasKegiatan'); return view('admin.kegiatan.data.edit', compact('kegiatan', 'kategoris', 'hariList', 'kelompokKelas')); } /** - * Update kegiatan + * Update kegiatan β€” smart multi-hari + * + * Logika: + * - Cari semua kegiatan "saudara" = nama_kegiatan + kategori_id LAMA yang sama + * - Hari yang DIPILIH & sudah ada di saudara β†’ UPDATE kegiatan saudara tsb + * - Hari yang DIPILIH tapi belum ada di saudara β†’ BUAT kegiatan baru + * - Hari yang TIDAK DIPILIH β†’ tidak disentuh sama sekali */ public function update(Request $request, Kegiatan $kegiatan) { $validated = $request->validate([ - 'kategori_id' => 'required|exists:kategori_kegiatans,kategori_id', + 'kategori_id' => 'required|exists:kategori_kegiatans,kategori_id', 'nama_kegiatan' => 'required|string|max:150', - 'hari' => 'required|in:Senin,Selasa,Rabu,Kamis,Jumat,Sabtu,Ahad', - 'waktu_mulai' => 'required|date_format:H:i', + 'hari' => 'required|array|min:1', + 'hari.*' => 'in:Senin,Selasa,Rabu,Kamis,Jumat,Sabtu,Ahad', + 'waktu_mulai' => 'required|date_format:H:i', 'waktu_selesai' => 'required|date_format:H:i|after:waktu_mulai', - 'materi' => 'nullable|string|max:200', - 'keterangan' => 'nullable|string', - 'kelas_ids' => 'nullable|array', - 'kelas_ids.*' => 'exists:kelas,id', + 'materi' => 'nullable|string|max:200', + 'keterangan' => 'nullable|string', + 'kelas_ids' => 'nullable|array', + 'kelas_ids.*' => 'exists:kelas,id', ], [ - 'kategori_id.required' => 'Kategori wajib dipilih.', + 'kategori_id.required' => 'Kategori wajib dipilih.', 'nama_kegiatan.required' => 'Nama kegiatan wajib diisi.', - 'waktu_selesai.after' => 'Waktu selesai harus lebih dari waktu mulai.', + 'hari.required' => 'Minimal pilih satu hari.', + 'hari.min' => 'Minimal pilih satu hari.', + 'waktu_selesai.after' => 'Waktu selesai harus lebih dari waktu mulai.', ]); - $kegiatan->update($validated); - - // Update kelas assignments - if ($request->has('kelas_ids')) { - $kegiatan->assignKelas($request->kelas_ids ?? []); + $hariDipilih = $validated['hari']; + $kelasIds = $request->input('kelas_ids', []); + + // Data dasar tanpa hari & kelas_ids + $baseData = collect($validated)->except(['hari', 'kelas_ids'])->toArray(); + + // Cari semua saudara berdasarkan nama + kategori LAMA (sebelum diubah) + $saudara = Kegiatan::where('nama_kegiatan', $kegiatan->nama_kegiatan) + ->where('kategori_id', $kegiatan->kategori_id) + ->get() + ->keyBy('hari'); // ['Senin' => obj, 'Rabu' => obj, ...] + + $updatedCount = 0; + $createdCount = 0; + + foreach ($hariDipilih as $hari) { + if ($saudara->has($hari)) { + // Kegiatan di hari ini sudah ada β†’ update + $target = $saudara->get($hari); + $target->update(array_merge($baseData, ['hari' => $hari])); + $target->assignKelas($kelasIds); + $updatedCount++; + } else { + // Belum ada kegiatan di hari ini β†’ buat baru + $newKg = Kegiatan::create(array_merge($baseData, ['hari' => $hari])); + $newKg->assignKelas($kelasIds); + $createdCount++; + } } - return redirect()->route('admin.kegiatan.index') - ->with('success', 'Kegiatan berhasil diperbarui.'); + Cache::forget('next_kegiatan_id'); + + $parts = []; + if ($updatedCount > 0) $parts[] = "{$updatedCount} kegiatan diperbarui"; + if ($createdCount > 0) $parts[] = "{$createdCount} kegiatan baru dibuat"; + + return redirect()->route('admin.kegiatan.jadwal') + ->with('success', 'Berhasil: ' . implode(', ', $parts) . '.'); } /** @@ -499,7 +462,7 @@ public function destroy(Kegiatan $kegiatan) $kegiatan->delete(); Cache::forget('next_kegiatan_id'); - return redirect()->route('admin.kegiatan.index') - ->with('success', "Kegiatan \"$nama\" berhasil dihapus."); + return redirect()->route('admin.kegiatan.jadwal') + ->with('success', "Kegiatan \"{$nama}\" berhasil dihapus."); } } \ No newline at end of file diff --git a/sim-pkpps/app/Http/Controllers/Admin/KelasController.php b/sim-pkpps/app/Http/Controllers/Admin/KelasController.php index a32e2fd..d7139a8 100644 --- a/sim-pkpps/app/Http/Controllers/Admin/KelasController.php +++ b/sim-pkpps/app/Http/Controllers/Admin/KelasController.php @@ -1,18 +1,4 @@ filled('search')) { $search = $request->search; - $query->where(function($q) use ($search) { + $query->where(function ($q) use ($search) { $q->where('nama_kelas', 'like', "%{$search}%") ->orWhere('kode_kelas', 'like', "%{$search}%"); }); } - // Filter by kelompok kelas if ($request->filled('kelompok')) { $query->where('id_kelompok', $request->kelompok); } - // Filter by status if ($request->filled('status')) { - $isActive = $request->status === 'active'; - $query->where('is_active', $isActive); + $query->where('is_active', $request->status === 'active'); } - // Order by kelompok then urutan $kelas = $query->orderBy('id_kelompok', 'asc') - ->orderBy('urutan', 'asc') - ->paginate(15) - ->appends(request()->query()); + ->orderBy('urutan', 'asc') + ->paginate(15) + ->appends(request()->query()); - // Get kelompok kelas for filter dropdown $kelompokKelas = KelompokKelas::active()->ordered()->get(); return view('admin.kelas.index', compact('kelas', 'kelompokKelas')); } - /** - * Show the form for creating a new kelas. - */ public function create() { - // Get next kode_kelas $nextKodeKelas = Cache::remember('next_kelas_kode', 60, function () { $lastKelas = Kelas::orderBy('id', 'desc')->first(); - $nextNum = $lastKelas ? intval(substr($lastKelas->kode_kelas, 3)) + 1 : 1; + $nextNum = $lastKelas ? intval(substr($lastKelas->kode_kelas, 3)) + 1 : 1; return 'KLS' . str_pad($nextNum, 3, '0', STR_PAD_LEFT); }); - // Get kelompok kelas for dropdown $kelompokKelas = KelompokKelas::active()->ordered()->get(); return view('admin.kelas.create', compact('nextKodeKelas', 'kelompokKelas')); } - /** - * Store a newly created kelas in storage. - */ public function store(Request $request) { $validated = $request->validate([ - 'nama_kelas' => 'required|string|max:100|unique:kelas,nama_kelas', + 'nama_kelas' => 'required|string|max:100|unique:kelas,nama_kelas', 'id_kelompok' => 'required|string|exists:kelompok_kelas,id_kelompok', - 'urutan' => 'required|integer|min:0', - 'is_active' => 'boolean', - ], [ - 'nama_kelas.required' => 'Nama kelas wajib diisi.', - 'nama_kelas.unique' => 'Nama kelas sudah digunakan.', - 'id_kelompok.required' => 'Kelompok kelas wajib dipilih.', - 'id_kelompok.exists' => 'Kelompok kelas tidak valid.', - 'urutan.required' => 'Urutan wajib diisi.', - 'urutan.integer' => 'Urutan harus berupa angka.', - 'urutan.min' => 'Urutan minimal 0.', + 'urutan' => 'required|integer|min:0', + 'is_active' => 'boolean', ]); - // Set is_active default to true if not provided - $validated['is_active'] = $request->has('is_active') ? true : false; - - // Create kelas (kode_kelas will be auto-generated in model) + $validated['is_active'] = $request->has('is_active'); Kelas::create($validated); - - // Clear cache Cache::forget('next_kelas_kode'); return redirect()->route('admin.kelas.index') - ->with('success', 'Kelas berhasil ditambahkan.'); + ->with('success', 'Kelas berhasil ditambahkan.'); } - /** - * Display the specified kelas. - */ public function show(Kelas $kela) { - // Load relationships $kela->load(['kelompok', 'santriKelas.santri']); - - // Get santri count in this kelas for current academic year - $tahunAjaranAktif = SantriKelas::getCurrentAcademicYear(); $santriCount = $kela->santriKelas() - ->where('tahun_ajaran', $tahunAjaranAktif) - ->whereHas('santri', function($q) { - $q->where('status', 'Aktif'); - }) + ->whereHas('santri', fn($q) => $q->where('status', 'Aktif')) ->count(); - - return view('admin.kelas.show', compact('kela', 'santriCount', 'tahunAjaranAktif')); + + return view('admin.kelas.show', compact('kela', 'santriCount')); } - /** - * Show the form for editing the specified kelas. - */ public function edit(Kelas $kela) { - // Get kelompok kelas for dropdown $kelompokKelas = KelompokKelas::active()->ordered()->get(); - return view('admin.kelas.edit', compact('kela', 'kelompokKelas')); } - /** - * Update the specified kelas in storage. - */ public function update(Request $request, Kelas $kela) { $validated = $request->validate([ - 'nama_kelas' => 'required|string|max:100|unique:kelas,nama_kelas,' . $kela->id, + 'nama_kelas' => 'required|string|max:100|unique:kelas,nama_kelas,' . $kela->id, 'id_kelompok' => 'required|string|exists:kelompok_kelas,id_kelompok', - 'urutan' => 'required|integer|min:0', - 'is_active' => 'boolean', - ], [ - 'nama_kelas.required' => 'Nama kelas wajib diisi.', - 'nama_kelas.unique' => 'Nama kelas sudah digunakan.', - 'id_kelompok.required' => 'Kelompok kelas wajib dipilih.', - 'id_kelompok.exists' => 'Kelompok kelas tidak valid.', - 'urutan.required' => 'Urutan wajib diisi.', - 'urutan.integer' => 'Urutan harus berupa angka.', - 'urutan.min' => 'Urutan minimal 0.', + 'urutan' => 'required|integer|min:0', + 'is_active' => 'boolean', ]); - // Set is_active - $validated['is_active'] = $request->has('is_active') ? true : false; - - // Update kelas + $validated['is_active'] = $request->has('is_active'); $kela->update($validated); return redirect()->route('admin.kelas.index') - ->with('success', 'Kelas berhasil diperbarui.'); + ->with('success', 'Kelas berhasil diperbarui.'); } - /** - * Remove the specified kelas from storage. - */ public function destroy(Kelas $kela) { - // Check if kelas is still being used - $santriCount = $kela->santriKelas()->count(); + $santriCount = $kela->santriKelas()->count(); $kegiatanCount = $kela->kegiatans()->count(); if ($santriCount > 0) { return redirect()->route('admin.kelas.index') - ->with('error', "Kelas tidak dapat dihapus karena masih digunakan oleh {$santriCount} santri."); + ->with('error', "Kelas tidak dapat dihapus karena masih digunakan oleh {$santriCount} santri."); } if ($kegiatanCount > 0) { return redirect()->route('admin.kelas.index') - ->with('error', "Kelas tidak dapat dihapus karena masih memiliki {$kegiatanCount} kegiatan."); + ->with('error', "Kelas tidak dapat dihapus karena masih memiliki {$kegiatanCount} kegiatan."); } - // Delete kelas $kela->delete(); - return redirect()->route('admin.kelas.index') - ->with('success', 'Kelas berhasil dihapus.'); + ->with('success', 'Kelas berhasil dihapus.'); } // ========================================== // SECTION 2: CRUD KELOMPOK KELAS // ========================================== - - /** - * Display a listing of kelompok kelas. - */ + public function kelompokIndex(Request $request) { $query = KelompokKelas::withCount('kelas'); - // Search by nama kelompok if ($request->filled('search')) { - $search = $request->search; - $query->where('nama_kelompok', 'like', "%{$search}%"); + $query->where('nama_kelompok', 'like', '%' . $request->search . '%'); } - - // Filter by status if ($request->filled('status')) { - $isActive = $request->status === 'active'; - $query->where('is_active', $isActive); + $query->where('is_active', $request->status === 'active'); } - // Order by urutan $kelompokKelas = $query->orderBy('urutan', 'asc') - ->paginate(15) - ->appends(request()->query()); + ->paginate(15) + ->appends(request()->query()); return view('admin.kelas.kelompok.index', compact('kelompokKelas')); } - /** - * Show the form for creating a new kelompok kelas. - */ public function kelompokCreate() { - // Get next id_kelompok $nextIdKelompok = Cache::remember('next_kelompok_id', 60, function () { - $lastKelompok = KelompokKelas::orderBy('id', 'desc')->first(); - $nextNum = $lastKelompok ? intval(substr($lastKelompok->id_kelompok, 3)) + 1 : 1; + $last = KelompokKelas::orderBy('id', 'desc')->first(); + $nextNum = $last ? intval(substr($last->id_kelompok, 3)) + 1 : 1; return 'KEL' . str_pad($nextNum, 3, '0', STR_PAD_LEFT); }); return view('admin.kelas.kelompok.create', compact('nextIdKelompok')); } - /** - * Store a newly created kelompok kelas in storage. - */ public function kelompokStore(Request $request) { $validated = $request->validate([ 'nama_kelompok' => 'required|string|max:100|unique:kelompok_kelas,nama_kelompok', - 'deskripsi' => 'nullable|string|max:500', - 'urutan' => 'required|integer|min:0', - 'is_active' => 'boolean', - ], [ - 'nama_kelompok.required' => 'Nama kelompok wajib diisi.', - 'nama_kelompok.unique' => 'Nama kelompok sudah digunakan.', - 'urutan.required' => 'Urutan wajib diisi.', - 'urutan.integer' => 'Urutan harus berupa angka.', - 'urutan.min' => 'Urutan minimal 0.', - 'deskripsi.max' => 'Deskripsi maksimal 500 karakter.', + 'deskripsi' => 'nullable|string|max:500', + 'urutan' => 'required|integer|min:0', + 'is_active' => 'boolean', ]); - // Set is_active default to true if not provided - $validated['is_active'] = $request->has('is_active') ? true : false; - - // Create kelompok (id_kelompok will be auto-generated in model) + $validated['is_active'] = $request->has('is_active'); KelompokKelas::create($validated); - - // Clear cache Cache::forget('next_kelompok_id'); return redirect()->route('admin.kelas.kelompok.index') - ->with('success', 'Kelompok kelas berhasil ditambahkan.'); + ->with('success', 'Kelompok kelas berhasil ditambahkan.'); } - /** - * Show the form for editing the specified kelompok kelas. - */ public function kelompokEdit($id) { $kelompok = KelompokKelas::findOrFail($id); $kelompok->loadCount('kelas'); - return view('admin.kelas.kelompok.edit', compact('kelompok')); } - /** - * Update the specified kelompok kelas in storage. - */ public function kelompokUpdate(Request $request, $id) { - $kelompok = KelompokKelas::findOrFail($id); - + $kelompok = KelompokKelas::findOrFail($id); $validated = $request->validate([ 'nama_kelompok' => 'required|string|max:100|unique:kelompok_kelas,nama_kelompok,' . $kelompok->id, - 'deskripsi' => 'nullable|string|max:500', - 'urutan' => 'required|integer|min:0', - 'is_active' => 'boolean', - ], [ - 'nama_kelompok.required' => 'Nama kelompok wajib diisi.', - 'nama_kelompok.unique' => 'Nama kelompok sudah digunakan.', - 'urutan.required' => 'Urutan wajib diisi.', - 'urutan.integer' => 'Urutan harus berupa angka.', - 'urutan.min' => 'Urutan minimal 0.', - 'deskripsi.max' => 'Deskripsi maksimal 500 karakter.', + 'deskripsi' => 'nullable|string|max:500', + 'urutan' => 'required|integer|min:0', + 'is_active' => 'boolean', ]); - // Set is_active - $validated['is_active'] = $request->has('is_active') ? true : false; - - // Update kelompok + $validated['is_active'] = $request->has('is_active'); $kelompok->update($validated); return redirect()->route('admin.kelas.kelompok.index') - ->with('success', 'Kelompok kelas berhasil diperbarui.'); + ->with('success', 'Kelompok kelas berhasil diperbarui.'); } - /** - * Remove the specified kelompok kelas from storage. - */ public function kelompokDestroy($id) { - $kelompok = KelompokKelas::findOrFail($id); - - // Check if kelompok still has kelas + $kelompok = KelompokKelas::findOrFail($id); $kelasCount = $kelompok->kelas()->count(); if ($kelasCount > 0) { return redirect()->route('admin.kelas.kelompok.index') - ->with('error', "Kelompok tidak dapat dihapus karena masih memiliki {$kelasCount} kelas."); + ->with('error', "Kelompok tidak dapat dihapus karena masih memiliki {$kelasCount} kelas."); } - // Delete kelompok $kelompok->delete(); - return redirect()->route('admin.kelas.kelompok.index') - ->with('success', 'Kelompok kelas berhasil dihapus.'); + ->with('success', 'Kelompok kelas berhasil dihapus.'); } // ========================================== // SECTION 3: KENAIKAN KELAS MASSAL // ========================================== - - /** - * Display kenaikan kelas index page - */ + public function kenaikanIndex(Request $request) { - $tahunAjaranAktif = SantriKelas::getCurrentAcademicYear(); - $tahunAjaranBaru = $this->getNextAcademicYear($tahunAjaranAktif); - - // Get total santri aktif + $tahunAjaranAktif = $this->getActiveTahunAjaran(); + $tahunAjaranBaru = $this->getNextAcademicYear($tahunAjaranAktif); $totalSantriAktif = Santri::where('status', 'Aktif')->count(); - - // Get all kelompok kelas for dropdown - $kelompokKelas = KelompokKelas::with(['kelas' => function($q) { - $q->where('is_active', true)->orderBy('urutan'); - }]) - ->active() - ->ordered() - ->get(); - - // Determine selected kelompok (default: first kelompok) + + $kelompokKelas = KelompokKelas::with([ + 'kelas' => fn($q) => $q->where('is_active', true)->orderBy('urutan'), + ])->active()->ordered()->get(); + $selectedKelompok = $request->get('kelompok'); if (!$selectedKelompok && $kelompokKelas->isNotEmpty()) { $selectedKelompok = $kelompokKelas->first()->id_kelompok; } - - // Get kelas list for selected kelompok only + $kelasList = Kelas::with('kelompok') ->where('is_active', true) - ->when($selectedKelompok, function($q) use ($selectedKelompok) { - $q->where('id_kelompok', $selectedKelompok); - }) - ->withCount(['santriKelas as santri_aktif_count' => function($q) use ($tahunAjaranAktif) { - $q->where('tahun_ajaran', $tahunAjaranAktif) - ->whereHas('santri', function($q2) { - $q2->where('status', 'Aktif'); - }); - }]) + ->when($selectedKelompok, fn($q) => $q->where('id_kelompok', $selectedKelompok)) + ->withCount([ + 'santriKelas as santri_aktif_count' => fn($q) => $q->whereHas('santri', fn($s) => $s->where('status', 'Aktif')), + ]) ->orderBy('urutan', 'asc') ->get(); - - // Get all kelas for dropdown selection (bisa naik ke kelas manapun) + $allKelasList = Kelas::with('kelompok') ->where('is_active', true) ->orderBy('id_kelompok', 'asc') ->orderBy('urutan', 'asc') ->get(); - + return view('admin.kelas.kenaikan.index', compact( - 'tahunAjaranAktif', - 'tahunAjaranBaru', - 'totalSantriAktif', - 'kelompokKelas', - 'kelasList', - 'allKelasList', - 'selectedKelompok' + 'tahunAjaranAktif', 'tahunAjaranBaru', 'totalSantriAktif', + 'kelompokKelas', 'kelasList', 'allKelasList', 'selectedKelompok' )); } - - /** - * Preview santri in a class before kenaikan - */ + public function kenaikanPreview($id) { - $kelas = Kelas::with('kelompok')->findOrFail($id); - $tahunAjaranAktif = SantriKelas::getCurrentAcademicYear(); - $tahunAjaranBaru = $this->getNextAcademicYear($tahunAjaranAktif); - - // Get santri in this class (tahun ajaran aktif, status aktif) - $santriList = Santri::whereHas('kelasSantri', function($q) use ($id, $tahunAjaranAktif) { - $q->where('id_kelas', $id) - ->where('tahun_ajaran', $tahunAjaranAktif); - }) - ->where('status', 'Aktif') - ->orderBy('nama_lengkap') - ->get(); - - // Get all kelompok with kelas for dropdown - $kelasOptions = KelompokKelas::with(['kelas' => function($q) { - $q->where('is_active', true)->orderBy('urutan'); - }]) - ->active() - ->ordered() - ->get(); - + $kelas = Kelas::with('kelompok')->findOrFail($id); + $tahunAjaranAktif = $this->getActiveTahunAjaran(); + $tahunAjaranBaru = $this->getNextAcademicYear($tahunAjaranAktif); + + $santriList = Santri::whereHas('kelasSantri', fn($q) => $q->where('id_kelas', $id)) + ->where('status', 'Aktif') + ->orderBy('nama_lengkap') + ->get(); + + $kelasOptions = KelompokKelas::with([ + 'kelas' => fn($q) => $q->where('is_active', true)->orderBy('urutan'), + ])->active()->ordered()->get(); + return view('admin.kelas.kenaikan.preview', compact( - 'kelas', - 'santriList', - 'tahunAjaranAktif', - 'tahunAjaranBaru', - 'kelasOptions' + 'kelas', 'santriList', 'tahunAjaranAktif', 'tahunAjaranBaru', 'kelasOptions' )); } - - /** - * Process kenaikan kelas for all santri in a class - */ + public function kenaikanProcess(Request $request) { $request->validate([ - 'id_kelas_asal' => 'required|exists:kelas,id', - 'id_kelas_tujuan' => 'required|exists:kelas,id', + 'id_kelas_asal' => 'required|exists:kelas,id', + 'id_kelas_tujuan' => 'required|exists:kelas,id|different:id_kelas_asal', + ], [ + 'id_kelas_tujuan.different' => 'Kelas tujuan tidak boleh sama dengan kelas asal.', ]); - - $kelasAsal = Kelas::findOrFail($request->id_kelas_asal); + + $kelasAsal = Kelas::findOrFail($request->id_kelas_asal); $kelasTujuan = Kelas::findOrFail($request->id_kelas_tujuan); - $tahunAjaranAktif = SantriKelas::getCurrentAcademicYear(); - - // Get all santri aktif in kelas asal - $santriIds = Santri::whereHas('kelasSantri', function($q) use ($request, $tahunAjaranAktif) { - $q->where('id_kelas', $request->id_kelas_asal) - ->where('tahun_ajaran', $tahunAjaranAktif); - }) - ->where('status', 'Aktif') - ->pluck('id_santri'); - + + $santriIds = Santri::whereHas('kelasSantri', fn($q) => $q->where('id_kelas', $request->id_kelas_asal)) + ->where('status', 'Aktif') + ->pluck('id_santri'); + if ($santriIds->isEmpty()) { return redirect()->route('admin.kelas.kenaikan.index') - ->with('error', 'Tidak ada santri aktif di kelas ' . $kelasAsal->nama_kelas); + ->with('error', 'Tidak ada santri aktif di kelas ' . $kelasAsal->nama_kelas . '.'); } - + $processed = 0; - + DB::beginTransaction(); try { foreach ($santriIds as $idSantri) { - // Cari record santri_kelas yg ada di kelas asal $record = SantriKelas::where('id_santri', $idSantri) ->where('id_kelas', $kelasAsal->id) - ->where('tahun_ajaran', $tahunAjaranAktif) + ->orderBy('tahun_ajaran', 'desc') ->first(); - - if ($record) { - // Update record: ganti kelas saja, tahun_ajaran & is_primary TETAP - $record->update([ - 'id_kelas' => $kelasTujuan->id, - ]); - $processed++; + + if (!$record) continue; + + // Cek duplikasi: jika sudah ada di kelas tujuan + tahun_ajaran sama, hapus record lama + $sudahAda = SantriKelas::where('id_santri', $idSantri) + ->where('id_kelas', $kelasTujuan->id) + ->where('tahun_ajaran', $record->tahun_ajaran) + ->exists(); + + if ($sudahAda) { + $record->delete(); + } else { + $record->update(['id_kelas' => $kelasTujuan->id]); } + + $processed++; } - + DB::commit(); - + return redirect()->route('admin.kelas.kenaikan.index') - ->with('success', "Berhasil menaikkan {$processed} santri dari kelas {$kelasAsal->nama_kelas} ke {$kelasTujuan->nama_kelas}."); + ->with('success', "Berhasil menaikkan {$processed} santri dari {$kelasAsal->nama_kelas} ke {$kelasTujuan->nama_kelas}."); } catch (\Exception $e) { DB::rollBack(); - return redirect()->route('admin.kelas.kenaikan.index') - ->with('error', 'Terjadi kesalahan saat memproses kenaikan kelas: ' . $e->getMessage()); + ->with('error', 'Terjadi kesalahan: ' . $e->getMessage()); } } - - /** - * Process kenaikan kelas for selected santri only - */ + public function kenaikanProcessSelected(Request $request) { $request->validate([ - 'id_kelas_asal' => 'required|exists:kelas,id', - 'id_kelas_tujuan' => 'required|exists:kelas,id', - 'santri_ids' => 'required|array|min:1', - 'santri_ids.*' => 'exists:santris,id_santri', + 'id_kelas_asal' => 'required|exists:kelas,id', + 'id_kelas_tujuan' => 'required|exists:kelas,id|different:id_kelas_asal', + 'santri_ids' => 'required|array|min:1', + 'santri_ids.*' => 'exists:santris,id_santri', ], [ - 'santri_ids.required' => 'Pilih minimal 1 santri untuk dinaikkan kelasnya.', - 'santri_ids.min' => 'Pilih minimal 1 santri untuk dinaikkan kelasnya.', + 'santri_ids.required' => 'Pilih minimal 1 santri untuk dinaikkan kelasnya.', + 'santri_ids.min' => 'Pilih minimal 1 santri untuk dinaikkan kelasnya.', + 'id_kelas_tujuan.different' => 'Kelas tujuan tidak boleh sama dengan kelas asal.', ]); - - $kelasAsal = Kelas::findOrFail($request->id_kelas_asal); + + $kelasAsal = Kelas::findOrFail($request->id_kelas_asal); $kelasTujuan = Kelas::findOrFail($request->id_kelas_tujuan); - $tahunAjaranAktif = SantriKelas::getCurrentAcademicYear(); - + $processed = 0; - + DB::beginTransaction(); try { foreach ($request->santri_ids as $idSantri) { - // Cari record santri_kelas yg ada di kelas asal $record = SantriKelas::where('id_santri', $idSantri) ->where('id_kelas', $kelasAsal->id) - ->where('tahun_ajaran', $tahunAjaranAktif) + ->orderBy('tahun_ajaran', 'desc') ->first(); - - if ($record) { - // Update record: ganti kelas saja, tahun_ajaran & is_primary TETAP - $record->update([ - 'id_kelas' => $kelasTujuan->id, - ]); - $processed++; + + if (!$record) continue; + + // Cek duplikasi: jika sudah ada di kelas tujuan + tahun_ajaran sama, hapus record lama + $sudahAda = SantriKelas::where('id_santri', $idSantri) + ->where('id_kelas', $kelasTujuan->id) + ->where('tahun_ajaran', $record->tahun_ajaran) + ->exists(); + + if ($sudahAda) { + $record->delete(); + } else { + $record->update(['id_kelas' => $kelasTujuan->id]); } + + $processed++; } - + DB::commit(); - + return redirect()->route('admin.kelas.kenaikan.index') - ->with('success', "Berhasil menaikkan {$processed} santri dari kelas {$kelasAsal->nama_kelas} ke {$kelasTujuan->nama_kelas}."); + ->with('success', "Berhasil menaikkan {$processed} santri dari {$kelasAsal->nama_kelas} ke {$kelasTujuan->nama_kelas}."); } catch (\Exception $e) { DB::rollBack(); - return redirect()->route('admin.kelas.kenaikan.preview', $request->id_kelas_asal) - ->with('error', 'Terjadi kesalahan saat memproses kenaikan kelas: ' . $e->getMessage()); + ->with('error', 'Terjadi kesalahan: ' . $e->getMessage()); } } - + /** - * Helper: Get next academic year - * Input: 2024/2025 - * Output: 2025/2026 + * Helper: tahun ajaran aktif berdasarkan data yang ada di santri_kelas. + * Menggunakan tahun ajaran terbaru yang punya record, fallback ke kalkulasi. */ - private function getNextAcademicYear($currentYear) + private function getActiveTahunAjaran(): string + { + return SantriKelas::max('tahun_ajaran') ?? SantriKelas::getCurrentAcademicYear(); + } + + /** + * Helper: hitung tahun ajaran berikutnya + * Contoh: "2024/2025" -> "2025/2026" + */ + private function getNextAcademicYear(string $currentYear): string { $parts = explode('/', $currentYear); - $startYear = (int) $parts[0] + 1; - $endYear = (int) $parts[1] + 1; - - return $startYear . '/' . $endYear; + return ((int) $parts[0] + 1) . '/' . ((int) $parts[1] + 1); } } \ No newline at end of file diff --git a/sim-pkpps/app/Http/Controllers/Admin/LaporanKegiatanController.php b/sim-pkpps/app/Http/Controllers/Admin/LaporanKegiatanController.php index f6d6630..cb8be14 100644 --- a/sim-pkpps/app/Http/Controllers/Admin/LaporanKegiatanController.php +++ b/sim-pkpps/app/Http/Controllers/Admin/LaporanKegiatanController.php @@ -196,9 +196,9 @@ public function detailSantri($id_santri, Request $request) DB::raw('SUM(CASE WHEN status = "Sakit" THEN 1 ELSE 0 END) as sakit'), DB::raw('SUM(CASE WHEN status = "Alpa" THEN 1 ELSE 0 END) as alpa') ) - ->first(); + ->first() ?? (object) ['total' => 0, 'hadir' => 0, 'izin' => 0, 'sakit' => 0, 'alpa' => 0]; - $persenKehadiran = $stats->total > 0 ? round(($stats->hadir / $stats->total) * 100, 1) : 0; + $persenKehadiran = ($stats->total ?? 0) > 0 ? round(($stats->hadir / $stats->total) * 100, 1) : 0; // Kehadiran per kegiatan $perKegiatan = AbsensiKegiatan::where('id_santri', $id_santri) @@ -360,8 +360,8 @@ public function analisKegiatan($kegiatan_id, Request $request) DB::raw('SUM(CASE WHEN status="Sakit" THEN 1 ELSE 0 END) as sakit'), DB::raw('SUM(CASE WHEN status="Alpa" THEN 1 ELSE 0 END) as alpa') ) - ->first(); - $stats->persen = $stats->total > 0 ? round(($stats->hadir / $stats->total) * 100, 1) : 0; + ->first() ?? (object) ['total' => 0, 'hadir' => 0, 'izin' => 0, 'sakit' => 0, 'alpa' => 0]; + $stats->persen = ($stats->total ?? 0) > 0 ? round(($stats->hadir / $stats->total) * 100, 1) : 0; // Trend 4 minggu $trend = []; diff --git a/sim-pkpps/app/Http/Controllers/Admin/PembayaranSppController.php b/sim-pkpps/app/Http/Controllers/Admin/PembayaranSppController.php index 76d5522..a787029 100644 --- a/sim-pkpps/app/Http/Controllers/Admin/PembayaranSppController.php +++ b/sim-pkpps/app/Http/Controllers/Admin/PembayaranSppController.php @@ -11,27 +11,23 @@ class PembayaranSppController extends Controller { - /** - * Display a listing of the resource. - */ + // ══════════════════════════════════════════════════════ + // INDEX + // ══════════════════════════════════════════════════════ + public function index(Request $request) { // Default tab $tab = $request->get('tab', 'belum-bayar'); - + // Default bulan dan tahun ke bulan/tahun saat ini jika tidak ada filter $bulan = $request->filled('bulan') ? $request->bulan : date('n'); $tahun = $request->filled('tahun') ? $request->tahun : date('Y'); - - // Query untuk mendapatkan data pembayaran berdasarkan filter - $query = PembayaranSpp::with('santri') - ->where('bulan', $bulan) - ->where('tahun', $tahun); - // Data untuk filter + // Data untuk filter tahun $tahunList = PembayaranSpp::selectRaw('DISTINCT tahun') - ->orderBy('tahun', 'desc') - ->pluck('tahun'); + ->orderBy('tahun', 'desc') + ->pluck('tahun'); // Tambahkan tahun saat ini jika belum ada if (!$tahunList->contains(date('Y'))) { @@ -40,158 +36,141 @@ public function index(Request $request) // Get santri dengan status pembayaran untuk periode yang dipilih $santriList = Santri::where('status', 'Aktif') - ->with(['pembayaranSpp' => function($q) use ($bulan, $tahun) { + ->with(['pembayaranSpp' => function ($q) use ($bulan, $tahun) { $q->where('bulan', $bulan)->where('tahun', $tahun); }]) ->get() - ->map(function($santri) use ($bulan, $tahun) { - $pembayaran = $santri->pembayaranSpp->first(); - + ->map(function ($santri) { + $p = $santri->pembayaranSpp->first(); return [ - 'id_santri' => $santri->id_santri, + 'id_santri' => $santri->id_santri, 'nama_lengkap' => $santri->nama_lengkap, - 'nis' => $santri->nis, - 'kelas' => $santri->kelas, - 'pembayaran' => $pembayaran, - 'status' => $pembayaran ? $pembayaran->status : 'Belum Ada Tagihan', - 'is_telat' => $pembayaran ? $pembayaran->isTelat() : false, - 'nominal' => $pembayaran ? $pembayaran->nominal : 0, - 'tanggal_bayar' => $pembayaran ? $pembayaran->tanggal_bayar : null, - 'batas_bayar' => $pembayaran ? $pembayaran->batas_bayar : null, + 'nis' => $santri->nis, + 'kelas' => $santri->kelas, + 'pembayaran' => $p, + // status virtual: Lunas / Cicilan / Belum Lunas / Belum Ada Tagihan + 'status' => $p ? ($p->status === 'Lunas' ? 'Lunas' : ($p->isCicilan() ? 'Cicilan' : 'Belum Lunas')) : 'Belum Ada Tagihan', + 'is_telat' => $p ? $p->isTelat() : false, + 'nominal' => $p ? (float) $p->nominal : 0, + 'tanggal_bayar'=> $p ? $p->tanggal_bayar : null, + 'batas_bayar' => $p ? $p->batas_bayar : null, ]; }); - // Filter berdasarkan tab + // ─── KPI (hitung dari data PENUH sebelum filter tab) ───────── + $totalSantriAll = $santriList->count(); + $totalLunas = $santriList->where('status', 'Lunas')->count(); + $totalCicilan = $santriList->where('status', 'Cicilan')->count(); + $totalBelumBayar = $santriList->whereIn('status', ['Belum Lunas', 'Belum Ada Tagihan'])->count(); + $totalTelat = $santriList->where('is_telat', true)->count(); + $totalBelumAdaTagihan = $santriList->where('status', 'Belum Ada Tagihan')->count(); + $nominalLunas = $santriList->where('status', 'Lunas')->sum('nominal'); + $nominalBelumLunas = $santriList->whereIn('status', ['Belum Lunas', 'Cicilan'])->sum('nominal'); + + // ─── Filter tab ─────────────────────────────────────────────── if ($tab === 'sudah-bayar') { - $santriList = $santriList->filter(function($item) { - return $item['pembayaran'] && $item['status'] === 'Lunas'; - }); + $santriList = $santriList + ->filter(fn($i) => $i['pembayaran'] && $i['status'] === 'Lunas') + ->sortByDesc(fn($i) => $i['tanggal_bayar'] ? $i['tanggal_bayar']->timestamp : 0); + + } elseif ($tab === 'cicilan') { + $santriList = $santriList + ->filter(fn($i) => $i['pembayaran'] && $i['status'] === 'Cicilan') + ->sortBy('nama_lengkap'); + } else { - // Belum bayar (termasuk yang belum ada tagihan dan yang telat) - $santriList = $santriList->filter(function($item) { - return !$item['pembayaran'] || $item['status'] !== 'Lunas'; - }); + // belum-bayar: status Belum Lunas atau Belum Ada Tagihan + $santriList = $santriList + ->filter(fn($i) => in_array($i['status'], ['Belum Lunas', 'Belum Ada Tagihan'])) + ->sortBy('nama_lengkap'); } - // Filter search + // ─── Search ─────────────────────────────────────────────────── if ($request->filled('search')) { $search = strtolower($request->search); - $santriList = $santriList->filter(function($item) use ($search) { - return str_contains(strtolower($item['nama_lengkap']), $search) || - str_contains(strtolower($item['id_santri']), $search) || - str_contains(strtolower($item['nis']), $search); - }); + $santriList = $santriList->filter(fn($i) => + str_contains(strtolower($i['nama_lengkap']), $search) || + str_contains(strtolower($i['id_santri']), $search) || + str_contains(strtolower($i['nis']), $search) + ); } - // Filter status spesifik + // ─── Filter status spesifik (tab belum-bayar) ───────────────── if ($request->filled('filter_status')) { if ($request->filter_status === 'Telat') { - $santriList = $santriList->filter(function($item) { - return $item['is_telat']; - }); + $santriList = $santriList->filter(fn($i) => $i['is_telat']); } elseif ($request->filter_status === 'Belum Ada Tagihan') { - $santriList = $santriList->filter(function($item) { - return !$item['pembayaran']; - }); + $santriList = $santriList->filter(fn($i) => !$i['pembayaran']); } else { - $santriList = $santriList->filter(function($item) use ($request) { - return $item['status'] === $request->filter_status; - }); + $santriList = $santriList->filter(fn($i) => $i['status'] === $request->filter_status); } } - // Hitung statistik - $totalSantri = $santriList->count(); - $totalLunas = $santriList->where('status', 'Lunas')->count(); - $totalBelumBayar = $santriList->where('status', 'Belum Lunas')->count(); - $totalTelat = $santriList->where('is_telat', true)->count(); - $totalBelumAdaTagihan = $santriList->where('status', 'Belum Ada Tagihan')->count(); - - $nominalLunas = $santriList->where('status', 'Lunas')->sum('nominal'); - $nominalBelumLunas = $santriList->where('status', 'Belum Lunas')->sum('nominal'); - - // Sort - $santriList = $santriList->sortBy('nama_lengkap')->values(); - - // Manual pagination - $perPage = 20; - $currentPage = $request->get('page', 1); - $offset = ($currentPage - 1) * $perPage; - + // ─── Pagination manual ──────────────────────────────────────── + $santriList = $santriList->values(); + $perPage = 20; + $currentPage = $request->get('page', 1); + $offset = ($currentPage - 1) * $perPage; $santriPaginated = $santriList->slice($offset, $perPage)->values(); - $totalPages = ceil($santriList->count() / $perPage); + $totalPages = ceil($santriList->count() / $perPage); + $totalSantri = $santriList->count(); return view('admin.pembayaran-spp.index', compact( - 'santriPaginated', - 'tab', - 'bulan', - 'tahun', - 'tahunList', - 'totalSantri', - 'totalLunas', - 'totalBelumBayar', - 'totalTelat', - 'totalBelumAdaTagihan', - 'nominalLunas', - 'nominalBelumLunas', - 'currentPage', - 'totalPages' + 'santriPaginated', 'tab', 'bulan', 'tahun', 'tahunList', + 'totalSantri', 'totalSantriAll', + 'totalLunas', 'totalCicilan', 'totalBelumBayar', + 'totalTelat', 'totalBelumAdaTagihan', + 'nominalLunas', 'nominalBelumLunas', + 'currentPage', 'totalPages' )); } - /** - * Show the form for creating a new resource. - */ + // ══════════════════════════════════════════════════════ + // CREATE / STORE + // ══════════════════════════════════════════════════════ + public function create() { - // Ambil santri yang aktif - $santris = Santri::where('status', 'Aktif') - ->orderBy('nama_lengkap', 'asc') - ->get(); - - // Generate preview ID - $last = PembayaranSpp::orderBy('id', 'desc')->first(); + $santris = Santri::where('status', 'Aktif')->orderBy('nama_lengkap', 'asc')->get(); + $last = PembayaranSpp::orderBy('id', 'desc')->first(); $nextNum = $last ? intval(substr($last->id_pembayaran, 3)) + 1 : 1; - $nextId = 'SPP' . str_pad($nextNum, 3, '0', STR_PAD_LEFT); - + $nextId = 'SPP' . str_pad($nextNum, 3, '0', STR_PAD_LEFT); return view('admin.pembayaran-spp.create', compact('santris', 'nextId')); } - /** - * Store a newly created resource in storage. - */ public function store(Request $request) { $validated = $request->validate([ - 'id_santri' => 'required|exists:santris,id_santri', - 'bulan' => 'required|integer|min:1|max:12', - 'tahun' => 'required|integer|min:2020|max:2100', - 'nominal' => 'required|numeric|min:0', - 'status' => 'required|in:Lunas,Belum Lunas', - 'tanggal_bayar' => 'nullable|date', - 'batas_bayar' => 'required|date', - 'keterangan' => 'nullable|string', + 'id_santri' => 'required|exists:santris,id_santri', + 'bulan' => 'required|integer|min:1|max:12', + 'tahun' => 'required|integer|min:2020|max:2100', + 'nominal' => 'required|numeric|min:0', + 'status' => 'required|in:Lunas,Belum Lunas', + 'tanggal_bayar'=> 'nullable|date', + 'batas_bayar' => 'required|date', + 'keterangan' => 'nullable|string', ], [ 'id_santri.required' => 'Santri wajib dipilih.', - 'id_santri.exists' => 'Santri tidak ditemukan.', - 'bulan.required' => 'Bulan wajib diisi.', - 'bulan.min' => 'Bulan harus antara 1-12.', - 'bulan.max' => 'Bulan harus antara 1-12.', - 'tahun.required' => 'Tahun wajib diisi.', - 'nominal.required' => 'Nominal wajib diisi.', - 'nominal.min' => 'Nominal minimal 0.', - 'status.required' => 'Status wajib dipilih.', + 'id_santri.exists' => 'Santri tidak ditemukan.', + 'bulan.required' => 'Bulan wajib diisi.', + 'bulan.min' => 'Bulan harus antara 1-12.', + 'bulan.max' => 'Bulan harus antara 1-12.', + 'tahun.required' => 'Tahun wajib diisi.', + 'nominal.required' => 'Nominal wajib diisi.', + 'nominal.min' => 'Nominal minimal 0.', + 'status.required' => 'Status wajib dipilih.', 'batas_bayar.required' => 'Batas bayar wajib diisi.', ]); - // Cek duplikasi - $exists = PembayaranSpp::where('id_santri', $validated['id_santri']) - ->where('bulan', $validated['bulan']) - ->where('tahun', $validated['tahun']) - ->exists(); + // Cek duplikasi β€” jika sudah ada, arahkan ke edit + $existing = PembayaranSpp::where('id_santri', $validated['id_santri']) + ->where('bulan', $validated['bulan']) + ->where('tahun', $validated['tahun']) + ->first(); - if ($exists) { - return back()->withInput()->with('error', 'Data pembayaran untuk periode ini sudah ada.'); + if ($existing) { + return redirect()->route('admin.pembayaran-spp.edit', $existing->id) + ->with('info', 'Data SPP untuk periode ini sudah ada. Silakan edit data berikut untuk mengubah status pembayaran.'); } // Jika status lunas dan tanggal_bayar kosong, set ke hari ini @@ -202,56 +181,51 @@ public function store(Request $request) PembayaranSpp::create($validated); return redirect()->route('admin.pembayaran-spp.index') - ->with('success', 'Data pembayaran SPP berhasil ditambahkan.'); + ->with('success', 'Data pembayaran SPP berhasil ditambahkan.'); } - /** - * Display the specified resource. - */ + // ══════════════════════════════════════════════════════ + // SHOW / EDIT / UPDATE / DESTROY + // ══════════════════════════════════════════════════════ + public function show(PembayaranSpp $pembayaranSpp) { $pembayaranSpp->load('santri'); return view('admin.pembayaran-spp.show', compact('pembayaranSpp')); } - /** - * Show the form for editing the specified resource. - */ public function edit(PembayaranSpp $pembayaranSpp) { $santris = Santri::orderBy('nama_lengkap', 'asc')->get(); return view('admin.pembayaran-spp.edit', compact('pembayaranSpp', 'santris')); } - /** - * Update the specified resource in storage. - */ public function update(Request $request, PembayaranSpp $pembayaranSpp) { $validated = $request->validate([ - 'id_santri' => 'required|exists:santris,id_santri', - 'bulan' => 'required|integer|min:1|max:12', - 'tahun' => 'required|integer|min:2020|max:2100', - 'nominal' => 'required|numeric|min:0', - 'status' => 'required|in:Lunas,Belum Lunas', - 'tanggal_bayar' => 'nullable|date', - 'batas_bayar' => 'required|date', - 'keterangan' => 'nullable|string', + 'id_santri' => 'required|exists:santris,id_santri', + 'bulan' => 'required|integer|min:1|max:12', + 'tahun' => 'required|integer|min:2020|max:2100', + 'nominal' => 'required|numeric|min:0', + 'status' => 'required|in:Lunas,Belum Lunas', + 'tanggal_bayar'=> 'nullable|date', + 'batas_bayar' => 'required|date', + 'keterangan' => 'nullable|string', ], [ - 'id_santri.required' => 'Santri wajib dipilih.', - 'bulan.required' => 'Bulan wajib diisi.', - 'tahun.required' => 'Tahun wajib diisi.', - 'nominal.required' => 'Nominal wajib diisi.', - 'status.required' => 'Status wajib dipilih.', + 'id_santri.required' => 'Santri wajib dipilih.', + 'bulan.required' => 'Bulan wajib diisi.', + 'tahun.required' => 'Tahun wajib diisi.', + 'nominal.required' => 'Nominal wajib diisi.', + 'status.required' => 'Status wajib dipilih.', 'batas_bayar.required' => 'Batas bayar wajib diisi.', ]); // Cek duplikasi (kecuali data sendiri) $exists = PembayaranSpp::where('id_santri', $validated['id_santri']) - ->where('bulan', $validated['bulan']) - ->where('tahun', $validated['tahun']) - ->where('id', '!=', $pembayaranSpp->id) - ->exists(); + ->where('bulan', $validated['bulan']) + ->where('tahun', $validated['tahun']) + ->where('id', '!=', $pembayaranSpp->id) + ->exists(); if ($exists) { return back()->withInput()->with('error', 'Data pembayaran untuk periode ini sudah ada.'); @@ -262,92 +236,162 @@ public function update(Request $request, PembayaranSpp $pembayaranSpp) $validated['tanggal_bayar'] = Carbon::now()->format('Y-m-d'); } + // Jika diubah ke Lunas, hapus data cicilan dari keterangan + if ($validated['status'] === 'Lunas' && $pembayaranSpp->isCicilan()) { + $validated['keterangan'] = $pembayaranSpp->catatan_teks; // simpan teks catatan saja + } + $pembayaranSpp->update($validated); return redirect()->route('admin.pembayaran-spp.index') - ->with('success', 'Data pembayaran SPP berhasil diperbarui.'); + ->with('success', 'Data pembayaran SPP berhasil diperbarui.'); } - /** - * Remove the specified resource from storage. - */ public function destroy(PembayaranSpp $pembayaranSpp) { $periode = $pembayaranSpp->periode_lengkap; - $santri = $pembayaranSpp->santri->nama_lengkap; - + $santri = $pembayaranSpp->santri->nama_lengkap; $pembayaranSpp->delete(); - return redirect()->route('admin.pembayaran-spp.index') - ->with('success', "Data pembayaran SPP {$periode} untuk {$santri} berhasil dihapus."); + ->with('success', "Data pembayaran SPP {$periode} untuk {$santri} berhasil dihapus."); + } + + // ══════════════════════════════════════════════════════ + // QUICK ACTIONS + // ══════════════════════════════════════════════════════ + + /** + * Tandai Lunas langsung (quick pay) + */ + public function bayar(Request $request, PembayaranSpp $pembayaranSpp) + { + if ($pembayaranSpp->status === 'Lunas') { + return redirect()->back()->with('info', 'Pembayaran ini sudah berstatus Lunas.'); + } + + $pembayaranSpp->update([ + 'status' => 'Lunas', + 'tanggal_bayar' => $request->filled('tanggal_bayar') + ? $request->tanggal_bayar + : Carbon::now()->format('Y-m-d'), + // Bersihkan data cicilan dari keterangan, simpan catatan teks jika ada + 'keterangan' => $pembayaranSpp->catatan_teks, + ]); + + $nama = $pembayaranSpp->santri->nama_lengkap; + $periode = $pembayaranSpp->periode_lengkap; + + return redirect()->route('admin.pembayaran-spp.index', [ + 'tab' => 'sudah-bayar', + 'bulan' => $pembayaranSpp->bulan, + 'tahun' => $pembayaranSpp->tahun, + ])->with('success', "Pembayaran SPP {$periode} untuk {$nama} berhasil ditandai Lunas."); } /** - * Tampilkan riwayat pembayaran per santri + * Catat cicilan (tambah nominal terbayar) + * Status DB tetap "Belum Lunas" β€” cicilan disimpan di keterangan (JSON). */ + public function catatCicilan(Request $request, PembayaranSpp $pembayaranSpp) + { + $request->validate([ + 'nominal_cicilan' => 'required|numeric|min:1', + 'catatan' => 'nullable|string|max:200', + ]); + + $sudahTerbayar = $pembayaranSpp->nominal_terbayar; + $totalTagihan = (float) $pembayaranSpp->nominal; + $baru = $sudahTerbayar + (float) $request->nominal_cicilan; + + // Jika total cicilan >= tagihan β†’ otomatis Lunas + if ($baru >= $totalTagihan) { + $pembayaranSpp->update([ + 'status' => 'Lunas', + 'tanggal_bayar' => Carbon::now()->format('Y-m-d'), + 'keterangan' => $request->catatan ?? $pembayaranSpp->catatan_teks, + ]); + + return redirect()->route('admin.pembayaran-spp.index', [ + 'tab' => 'sudah-bayar', + 'bulan' => $pembayaranSpp->bulan, + 'tahun' => $pembayaranSpp->tahun, + ])->with('success', "Cicilan terakhir diterima. SPP {$pembayaranSpp->periode_lengkap} untuk {$pembayaranSpp->santri->nama_lengkap} sekarang Lunas."); + } + + // Masih cicilan β€” update keterangan saja, status tetap "Belum Lunas" + $pembayaranSpp->setCicilan($baru, $request->catatan ?? $pembayaranSpp->catatan_teks); + $pembayaranSpp->save(); + + $sisaFormat = 'Rp ' . number_format($totalTagihan - $baru, 0, ',', '.'); + $cicilanFormat = 'Rp ' . number_format((float) $request->nominal_cicilan, 0, ',', '.'); + + return redirect()->back() + ->with('success', "Cicilan {$cicilanFormat} berhasil dicatat. Sisa: {$sisaFormat}"); + } + + // ══════════════════════════════════════════════════════ + // RIWAYAT PER SANTRI + // ══════════════════════════════════════════════════════ + public function riwayat($id_santri) { $santri = Santri::where('id_santri', $id_santri)->firstOrFail(); - + $pembayaranSpp = PembayaranSpp::where('id_santri', $id_santri) - ->orderBy('tahun', 'desc') - ->orderBy('bulan', 'desc') - ->paginate(15); + ->orderBy('tahun', 'desc') + ->orderBy('bulan', 'desc') + ->paginate(15); // Statistik $totalBayar = PembayaranSpp::where('id_santri', $id_santri) - ->where('status', 'Lunas') - ->sum('nominal'); - + ->where('status', 'Lunas') + ->sum('nominal'); + $totalTunggakan = PembayaranSpp::where('id_santri', $id_santri) - ->where('status', 'Belum Lunas') - ->sum('nominal'); - + ->where('status', 'Belum Lunas') + ->sum('nominal'); + $jumlahTelat = PembayaranSpp::where('id_santri', $id_santri) - ->where('status', 'Belum Lunas') - ->where('batas_bayar', '<', Carbon::now()) - ->count(); + ->where('status', 'Belum Lunas') + ->where('batas_bayar', '<', Carbon::now()) + ->count(); return view('admin.pembayaran-spp.riwayat', compact( - 'santri', - 'pembayaranSpp', - 'totalBayar', - 'totalTunggakan', - 'jumlahTelat' + 'santri', 'pembayaranSpp', 'totalBayar', 'totalTunggakan', 'jumlahTelat' )); } - /** - * Generate SPP untuk semua santri aktif dalam periode tertentu - */ + // ══════════════════════════════════════════════════════ + // GENERATE SPP MASSAL + // ══════════════════════════════════════════════════════ + public function generate(Request $request) { if ($request->isMethod('post')) { $validated = $request->validate([ - 'bulan' => 'required|integer|min:1|max:12', - 'tahun' => 'required|integer|min:2020|max:2100', - 'nominal' => 'required|numeric|min:0', + 'bulan' => 'required|integer|min:1|max:12', + 'tahun' => 'required|integer|min:2020|max:2100', + 'nominal' => 'required|numeric|min:0', 'batas_bayar' => 'required|date', ]); - $santris = Santri::where('status', 'Aktif')->get(); + $santris = Santri::where('status', 'Aktif')->get(); $generated = 0; - $skipped = 0; + $skipped = 0; foreach ($santris as $santri) { - // Cek apakah sudah ada $exists = PembayaranSpp::where('id_santri', $santri->id_santri) - ->where('bulan', $validated['bulan']) - ->where('tahun', $validated['tahun']) - ->exists(); + ->where('bulan', $validated['bulan']) + ->where('tahun', $validated['tahun']) + ->exists(); if (!$exists) { PembayaranSpp::create([ - 'id_santri' => $santri->id_santri, - 'bulan' => $validated['bulan'], - 'tahun' => $validated['tahun'], - 'nominal' => $validated['nominal'], - 'status' => 'Belum Lunas', + 'id_santri' => $santri->id_santri, + 'bulan' => $validated['bulan'], + 'tahun' => $validated['tahun'], + 'nominal' => $validated['nominal'], + 'status' => 'Belum Lunas', 'batas_bayar' => $validated['batas_bayar'], ]); $generated++; @@ -357,34 +401,27 @@ public function generate(Request $request) } return redirect()->route('admin.pembayaran-spp.index') - ->with('success', "Berhasil generate {$generated} data SPP. {$skipped} data dilewati (sudah ada)."); + ->with('success', "Berhasil generate {$generated} data SPP. {$skipped} data dilewati (sudah ada)."); } return view('admin.pembayaran-spp.generate'); } - /** - * Halaman pilihan laporan - */ + // ══════════════════════════════════════════════════════ + // LAPORAN & CETAK + // ══════════════════════════════════════════════════════ + public function laporan() { return view('admin.pembayaran-spp.laporan'); } - /** - * Cetak laporan SPP (semua data atau filter) - */ public function cetakLaporan(Request $request) { $query = PembayaranSpp::with('santri'); - // Filter - if ($request->filled('bulan')) { - $query->where('bulan', $request->bulan); - } - if ($request->filled('tahun')) { - $query->where('tahun', $request->tahun); - } + if ($request->filled('bulan')) $query->where('bulan', $request->bulan); + if ($request->filled('tahun')) $query->where('tahun', $request->tahun); if ($request->filled('status')) { if ($request->status === 'Telat') { $query->telat(); @@ -393,56 +430,33 @@ public function cetakLaporan(Request $request) } } - $pembayaranSpp = $query->orderBy('tahun', 'desc') - ->orderBy('bulan', 'desc') - ->get(); - - // Statistik - $totalLunas = $pembayaranSpp->where('status', 'Lunas')->sum('nominal'); + $pembayaranSpp = $query->orderBy('tahun', 'desc')->orderBy('bulan', 'desc')->get(); + $totalLunas = $pembayaranSpp->where('status', 'Lunas')->sum('nominal'); $totalTunggakan = $pembayaranSpp->where('status', 'Belum Lunas')->sum('nominal'); - $jumlahTelat = $pembayaranSpp->filter(function($spp) { - return $spp->isTelat(); - })->count(); + $jumlahTelat = $pembayaranSpp->filter(fn($s) => $s->isTelat())->count(); return view('admin.pembayaran-spp.cetak-laporan', compact( - 'pembayaranSpp', - 'totalLunas', - 'totalTunggakan', - 'jumlahTelat' + 'pembayaranSpp', 'totalLunas', 'totalTunggakan', 'jumlahTelat' )); } - /** - * Cetak laporan SPP per santri - */ public function cetakLaporanSantri($id_santri) { - $santri = Santri::where('id_santri', $id_santri)->firstOrFail(); - + $santri = Santri::where('id_santri', $id_santri)->firstOrFail(); $pembayaranSpp = PembayaranSpp::where('id_santri', $id_santri) - ->orderBy('tahun', 'desc') - ->orderBy('bulan', 'desc') - ->get(); + ->orderBy('tahun', 'desc') + ->orderBy('bulan', 'desc') + ->get(); - // Statistik - $totalLunas = $pembayaranSpp->where('status', 'Lunas')->sum('nominal'); + $totalLunas = $pembayaranSpp->where('status', 'Lunas')->sum('nominal'); $totalTunggakan = $pembayaranSpp->where('status', 'Belum Lunas')->sum('nominal'); - $jumlahTelat = $pembayaranSpp->filter(function($spp) { - return $spp->isTelat(); - })->count(); + $jumlahTelat = $pembayaranSpp->filter(fn($s) => $s->isTelat())->count(); return view('admin.pembayaran-spp.cetak-laporan-santri', compact( - 'santri', - 'pembayaranSpp', - 'totalLunas', - 'totalTunggakan', - 'jumlahTelat' + 'santri', 'pembayaranSpp', 'totalLunas', 'totalTunggakan', 'jumlahTelat' )); } - /** - * Cetak bukti pembayaran - */ public function cetakBukti(PembayaranSpp $pembayaranSpp) { $pembayaranSpp->load('santri'); diff --git a/sim-pkpps/app/Http/Controllers/Admin/UangSakuController.php b/sim-pkpps/app/Http/Controllers/Admin/UangSakuController.php index 18a17ed..57a5574 100644 --- a/sim-pkpps/app/Http/Controllers/Admin/UangSakuController.php +++ b/sim-pkpps/app/Http/Controllers/Admin/UangSakuController.php @@ -14,18 +14,46 @@ class UangSakuController extends Controller { /** * Tampilkan daftar uang saku β€” Grouped per Santri + * Default: bulan ini */ public function index(Request $request) { $search = $request->get('search'); - // Query santri aktif yang punya transaksi (atau semua jika tidak ada filter) + // ── Default: bulan ini ────────────────────────────────────── + $dari = $request->get('dari', now()->startOfMonth()->format('Y-m-d')); + $sampai = $request->get('sampai', now()->endOfMonth()->format('Y-m-d')); + $sort = $request->get('sort', 'nama'); // nama | saldo_asc | saldo_desc | transaksi_desc | terakhir + + // ── KPI ringkasan periode (dipengaruhi filter tanggal) ────── + $kpiQuery = UangSaku::whereBetween('tanggal_transaksi', [$dari, $sampai]); + $kpi = [ + 'total_transaksi' => (clone $kpiQuery)->count(), + 'total_pemasukan' => (float)(clone $kpiQuery)->where('jenis_transaksi', 'pemasukan')->sum('nominal'), + 'total_pengeluaran' => (float)(clone $kpiQuery)->where('jenis_transaksi', 'pengeluaran')->sum('nominal'), + 'total_santri' => (clone $kpiQuery)->distinct('id_santri')->count('id_santri'), + ]; + // Selisih periode: apakah dalam rentang ini uang yang masuk lebih besar dari yang keluar + $kpi['selisih'] = $kpi['total_pemasukan'] - $kpi['total_pengeluaran']; + + // ── KPI Real-time: Total saldo aktual seluruh santri (tidak dipengaruhi filter) ── + // Ambil saldo_sesudah dari transaksi TERAKHIR masing-masing santri + $totalSaldoSemua = DB::table('uang_saku as u1') + ->join(DB::raw('( + SELECT id_santri, MAX(id) as max_id + FROM uang_saku + GROUP BY id_santri + ) as latest'), function ($join) { + $join->on('u1.id_santri', '=', 'latest.id_santri') + ->on('u1.id', '=', 'latest.max_id'); + }) + ->sum('u1.saldo_sesudah'); + + $kpi['total_saldo_realtime'] = (float) $totalSaldoSemua; + + // ── Query santri ──────────────────────────────────────────── $santriQuery = Santri::aktif() ->select('id_santri', 'nama_lengkap') - ->withCount(['uangSaku as transaksi_bulan_ini' => function ($q) { - $q->whereMonth('tanggal_transaksi', now()->month) - ->whereYear('tanggal_transaksi', now()->year); - }]) ->has('uangSaku'); if ($search) { @@ -35,37 +63,72 @@ public function index(Request $request) }); } - $santriList = $santriQuery->orderBy('nama_lengkap') - ->paginate(20) - ->appends(request()->query()); + $santriQuery->orderBy('nama_lengkap'); + + $santriList = $santriQuery->paginate(20)->appends(request()->query()); - // Ambil saldo terakhir & transaksi terbaru per santri (batch) $ids = $santriList->pluck('id_santri'); - // Saldo terakhir per santri (dari transaksi terbaru) - $saldoMap = UangSaku::whereIn('id_santri', $ids) - ->select('id_santri', 'saldo_sesudah') - ->orderByDesc('tanggal_transaksi') - ->orderByDesc('created_at') + // ── Saldo terakhir per santri (efisien: subquery per-id) ──── + // Ambil id transaksi terakhir per santri lalu join, hindari get()->unique() yang boros + $latestIds = DB::table('uang_saku') + ->whereIn('id_santri', $ids) + ->select('id_santri', DB::raw('MAX(id) as max_id')) + ->groupBy('id_santri') + ->pluck('max_id', 'id_santri'); + + $saldoMap = UangSaku::whereIn('id', $latestIds->values()) ->get() - ->unique('id_santri') ->keyBy('id_santri'); - // Transaksi terbaru per santri (max 5) + // ── Pemasukan & pengeluaran bulan ini per santri ──────────── + $bulanIniStats = UangSaku::whereIn('id_santri', $ids) + ->whereMonth('tanggal_transaksi', now()->month) + ->whereYear('tanggal_transaksi', now()->year) + ->select( + 'id_santri', + DB::raw('SUM(CASE WHEN jenis_transaksi="pemasukan" THEN nominal ELSE 0 END) as pemasukan_bulan'), + DB::raw('SUM(CASE WHEN jenis_transaksi="pengeluaran" THEN nominal ELSE 0 END) as pengeluaran_bulan'), + DB::raw('COUNT(*) as total_bulan') + ) + ->groupBy('id_santri') + ->get() + ->keyBy('id_santri'); + + // ── Transaksi terbaru per santri (max 5, untuk collapsed detail) ── $transaksiMap = UangSaku::whereIn('id_santri', $ids) ->orderByDesc('tanggal_transaksi') ->orderByDesc('created_at') ->get() ->groupBy('id_santri') - ->map(fn ($group) => $group->take(5)); + ->map(fn($g) => $g->take(5)); - // Attach ke santri objects - $santriList->getCollection()->each(function ($santri) use ($saldoMap, $transaksiMap) { - $santri->saldo_terakhir = $saldoMap[$santri->id_santri]->saldo_sesudah ?? 0; - $santri->transaksi_terbaru = $transaksiMap[$santri->id_santri] ?? collect(); + // ── Attach semua data ke santri objects ───────────────────── + $collection = $santriList->getCollection()->map(function ($santri) use ($saldoMap, $bulanIniStats, $transaksiMap) { + $saldoRow = $saldoMap[$santri->id_santri] ?? null; + $bulan = $bulanIniStats[$santri->id_santri] ?? null; + + $santri->saldo_terakhir = $saldoRow ? (float)$saldoRow->saldo_sesudah : 0; + $santri->transaksi_terakhir_tgl = $saldoRow ? $saldoRow->tanggal_transaksi : null; + $santri->pemasukan_bulan = $bulan ? (float)$bulan->pemasukan_bulan : 0; + $santri->pengeluaran_bulan = $bulan ? (float)$bulan->pengeluaran_bulan : 0; + $santri->transaksi_bulan_ini = $bulan ? (int)$bulan->total_bulan : 0; + $santri->transaksi_terbaru = $transaksiMap[$santri->id_santri] ?? collect(); + return $santri; }); - return view('admin.uang-saku.index', compact('santriList')); + // ── Re-sort collection setelah attach ─────────────────────── + $sorted = match($sort) { + 'saldo_asc' => $collection->sortBy('saldo_terakhir'), + 'saldo_desc' => $collection->sortByDesc('saldo_terakhir'), + 'transaksi_desc' => $collection->sortByDesc('transaksi_bulan_ini'), + 'terakhir' => $collection->sortByDesc('transaksi_terakhir_tgl'), + default => $collection->sortBy('nama_lengkap'), + }; + + $santriList->setCollection($sorted->values()); + + return view('admin.uang-saku.index', compact('santriList', 'kpi', 'dari', 'sampai', 'sort')); } /** @@ -77,15 +140,13 @@ public function santriInfo($id_santri) $bulanIni = now(); - // Saldo terakhir $lastTx = UangSaku::where('id_santri', $id_santri) ->orderByDesc('tanggal_transaksi') ->orderByDesc('created_at') ->first(); - $saldo = $lastTx ? $lastTx->saldo_sesudah : 0; + $saldo = $lastTx ? (float)$lastTx->saldo_sesudah : 0; - // Total pemasukan & pengeluaran bulan ini $pemasukanBulanIni = UangSaku::where('id_santri', $id_santri) ->where('jenis_transaksi', 'pemasukan') ->whereMonth('tanggal_transaksi', $bulanIni->month) @@ -98,13 +159,12 @@ public function santriInfo($id_santri) ->whereYear('tanggal_transaksi', $bulanIni->year) ->sum('nominal'); - // 3 transaksi terakhir $transaksiTerakhir = UangSaku::where('id_santri', $id_santri) ->orderByDesc('tanggal_transaksi') ->orderByDesc('created_at') ->limit(3) ->get() - ->map(fn ($t) => [ + ->map(fn($t) => [ 'tanggal' => $t->tanggal_transaksi->format('d/m/Y'), 'jenis' => $t->jenis_transaksi, 'nominal' => number_format($t->nominal, 0, ',', '.'), @@ -112,18 +172,15 @@ public function santriInfo($id_santri) ]); return response()->json([ - 'nama' => $santri->nama_lengkap, - 'saldo_terakhir' => number_format($saldo, 0, ',', '.'), - 'saldo_raw' => $saldo, - 'total_pemasukan_bulan_ini' => number_format($pemasukanBulanIni, 0, ',', '.'), + 'nama' => $santri->nama_lengkap, + 'saldo_terakhir' => number_format($saldo, 0, ',', '.'), + 'saldo_raw' => $saldo, + 'total_pemasukan_bulan_ini' => number_format($pemasukanBulanIni, 0, ',', '.'), 'total_pengeluaran_bulan_ini' => number_format($pengeluaranBulanIni, 0, ',', '.'), - 'transaksi_terakhir' => $transaksiTerakhir, + 'transaksi_terakhir' => $transaksiTerakhir, ]); } - /** - * Form tambah transaksi - */ public function create() { $santriList = Santri::where('status', 'Aktif') @@ -134,128 +191,92 @@ public function create() return view('admin.uang-saku.create', compact('santriList')); } - /** - * Simpan transaksi baru - */ public function store(Request $request) { $validated = $request->validate([ - 'id_santri' => 'required|exists:santris,id_santri', - 'jenis_transaksi' => 'required|in:pemasukan,pengeluaran', - 'nominal' => 'required|numeric|min:1|max:99999999', - 'keterangan' => 'nullable|string|max:500', + 'id_santri' => 'required|exists:santris,id_santri', + 'jenis_transaksi' => 'required|in:pemasukan,pengeluaran', + 'nominal' => 'required|numeric|min:1|max:99999999', + 'keterangan' => 'nullable|string|max:500', 'tanggal_transaksi' => 'required|date', - ], [ - 'id_santri.required' => 'Santri wajib dipilih.', - 'id_santri.exists' => 'Santri tidak ditemukan.', - 'jenis_transaksi.required' => 'Jenis transaksi wajib dipilih.', - 'nominal.required' => 'Nominal wajib diisi.', - 'nominal.numeric' => 'Nominal harus berupa angka.', - 'nominal.min' => 'Nominal minimal Rp 1.', - 'tanggal_transaksi.required' => 'Tanggal transaksi wajib diisi.', ]); DB::beginTransaction(); try { UangSaku::create($validated); - - // Update saldo transaksi berikutnya jika ada $this->recalculateSaldoAfter($validated['id_santri'], $validated['tanggal_transaksi']); - DB::commit(); Cache::forget('santri_aktif_uang_saku'); - return redirect()->route('admin.uang-saku.index') ->with('success', 'Transaksi uang saku berhasil ditambahkan.'); } catch (\Exception $e) { DB::rollBack(); - return back()->withInput() - ->with('error', 'Gagal menambahkan transaksi: ' . $e->getMessage()); + return back()->withInput()->with('error', 'Gagal menambahkan transaksi: ' . $e->getMessage()); } } - /** - * Tampilkan detail transaksi - */ public function show($id) { $transaksi = UangSaku::with('santri')->findOrFail($id); return view('admin.uang-saku.show', compact('transaksi')); } - /** - * Form edit transaksi - */ public function edit($id) { - $transaksi = UangSaku::with('santri')->findOrFail($id); - + $transaksi = UangSaku::with('santri')->findOrFail($id); $santriList = Santri::where('status', 'Aktif') ->select('id_santri', 'nama_lengkap') ->orderBy('nama_lengkap') ->get(); - return view('admin.uang-saku.edit', compact('transaksi', 'santriList')); } - /** - * Update transaksi - */ public function update(Request $request, $id) { $transaksi = UangSaku::findOrFail($id); - $validated = $request->validate([ - 'jenis_transaksi' => 'required|in:pemasukan,pengeluaran', - 'nominal' => 'required|numeric|min:1|max:99999999', - 'keterangan' => 'nullable|string|max:500', + 'jenis_transaksi' => 'required|in:pemasukan,pengeluaran', + 'nominal' => 'required|numeric|min:1|max:99999999', + 'keterangan' => 'nullable|string|max:500', 'tanggal_transaksi' => 'required|date', - ], [ - 'jenis_transaksi.required' => 'Jenis transaksi wajib dipilih.', - 'nominal.required' => 'Nominal wajib diisi.', - 'nominal.numeric' => 'Nominal harus berupa angka.', - 'nominal.min' => 'Nominal minimal Rp 1.', - 'tanggal_transaksi.required' => 'Tanggal transaksi wajib diisi.', ]); + // Simpan tanggal lama sebelum update, agar recalculate dimulai dari yang paling awal + $tanggalLama = $transaksi->tanggal_transaksi->format('Y-m-d'); + DB::beginTransaction(); try { - $transaksi->update($validated); - - // Recalculate semua saldo setelah transaksi ini - $this->recalculateSaldoAfter($transaksi->id_santri, $transaksi->tanggal_transaksi); - + // Gunakan saveQuietly agar model boot (updating) tidak ikut menghitung ulang saldo + // β€” recalculate akan dikerjakan secara menyeluruh oleh recalculateSaldoAfter() + $transaksi->fill($validated)->saveQuietly(); + + // Recalculate dari tanggal yang paling awal antara tanggal lama dan baru + $tanggalBaru = $validated['tanggal_transaksi']; + $tanggalMulai = min($tanggalLama, $tanggalBaru); + + $this->recalculateSaldoAfter($transaksi->id_santri, $tanggalMulai); DB::commit(); Cache::forget('santri_aktif_uang_saku'); - return redirect()->route('admin.uang-saku.index') ->with('success', 'Transaksi berhasil diperbarui.'); } catch (\Exception $e) { DB::rollBack(); - return back()->withInput() - ->with('error', 'Gagal memperbarui transaksi: ' . $e->getMessage()); + return back()->withInput()->with('error', 'Gagal memperbarui transaksi: ' . $e->getMessage()); } } - /** - * Hapus transaksi - */ public function destroy($id) { $transaksi = UangSaku::findOrFail($id); - $idSantri = $transaksi->id_santri; - $tanggal = $transaksi->tanggal_transaksi; + $idSantri = $transaksi->id_santri; + $tanggal = $transaksi->tanggal_transaksi->format('Y-m-d'); DB::beginTransaction(); try { $transaksi->delete(); - - // Recalculate saldo setelah transaksi dihapus $this->recalculateSaldoAfter($idSantri, $tanggal); - DB::commit(); Cache::forget('santri_aktif_uang_saku'); - return redirect()->route('admin.uang-saku.index') ->with('success', 'Transaksi berhasil dihapus.'); } catch (\Exception $e) { @@ -264,35 +285,21 @@ public function destroy($id) } } - /** - * Tampilkan riwayat uang saku per santri dengan filter tanggal - */ public function riwayat(Request $request, $id_santri) { $santri = Santri::where('id_santri', $id_santri)->firstOrFail(); - - // Default: bulan ini - $tanggalDari = $request->filled('tanggal_dari') - ? $request->tanggal_dari - : now()->startOfMonth()->format('Y-m-d'); - - $tanggalSampai = $request->filled('tanggal_sampai') - ? $request->tanggal_sampai - : now()->endOfMonth()->format('Y-m-d'); - - // Query transaksi dengan filter tanggal - $query = UangSaku::where('id_santri', $id_santri); - - if ($tanggalDari && $tanggalSampai) { - $query->whereBetween('tanggal_transaksi', [$tanggalDari, $tanggalSampai]); - } - + + $tanggalDari = $request->filled('tanggal_dari') ? $request->tanggal_dari : now()->startOfMonth()->format('Y-m-d'); + $tanggalSampai = $request->filled('tanggal_sampai') ? $request->tanggal_sampai : now()->endOfMonth()->format('Y-m-d'); + + $query = UangSaku::where('id_santri', $id_santri) + ->whereBetween('tanggal_transaksi', [$tanggalDari, $tanggalSampai]); + $transaksi = $query->orderBy('tanggal_transaksi', 'desc') ->orderBy('created_at', 'desc') ->paginate(20) ->appends($request->query()); - // Statistik dengan filter tanggal $totalPemasukan = UangSaku::where('id_santri', $id_santri) ->where('jenis_transaksi', 'pemasukan') ->whereBetween('tanggal_transaksi', [$tanggalDari, $tanggalSampai]) @@ -303,82 +310,79 @@ public function riwayat(Request $request, $id_santri) ->whereBetween('tanggal_transaksi', [$tanggalDari, $tanggalSampai]) ->sum('nominal'); - // Saldo terakhir tetap dari keseluruhan transaksi - $saldoTerakhir = $santri->saldo_uang_saku; + // Ambil saldo aktual dari transaksi TERAKHIR santri ini (real-time, bukan dari filter) + $lastTx = UangSaku::where('id_santri', $id_santri) + ->orderByDesc('tanggal_transaksi') + ->orderByDesc('created_at') + ->first(); + $saldoTerakhir = $lastTx ? (float)$lastTx->saldo_sesudah : 0; - // Data untuk grafik dengan filter tanggal $dataGrafik = UangSaku::where('id_santri', $id_santri) ->whereBetween('tanggal_transaksi', [$tanggalDari, $tanggalSampai]) ->select( DB::raw('DATE(tanggal_transaksi) as tanggal'), - DB::raw('SUM(CASE WHEN jenis_transaksi = "pemasukan" THEN nominal ELSE 0 END) as pemasukan'), - DB::raw('SUM(CASE WHEN jenis_transaksi = "pengeluaran" THEN nominal ELSE 0 END) as pengeluaran') + DB::raw('SUM(CASE WHEN jenis_transaksi="pemasukan" THEN nominal ELSE 0 END) as pemasukan'), + DB::raw('SUM(CASE WHEN jenis_transaksi="pengeluaran" THEN nominal ELSE 0 END) as pengeluaran') ) ->groupBy('tanggal') ->orderBy('tanggal') ->get(); - // Jika tidak ada transaksi di rentang tanggal, buat data kosong if ($dataGrafik->isEmpty()) { - $dataGrafik = collect([ - (object)[ - 'tanggal' => $tanggalDari, - 'pemasukan' => 0, - 'pengeluaran' => 0 - ] - ]); + $dataGrafik = collect([(object)['tanggal' => $tanggalDari, 'pemasukan' => 0, 'pengeluaran' => 0]]); } - // Info periode - $periodeDari = \Carbon\Carbon::parse($tanggalDari); - $periodeSampai = \Carbon\Carbon::parse($tanggalSampai); - + $periodeDari = Carbon::parse($tanggalDari); + $periodeSampai = Carbon::parse($tanggalSampai); + return view('admin.uang-saku.riwayat', compact( - 'santri', - 'transaksi', - 'totalPemasukan', - 'totalPengeluaran', - 'saldoTerakhir', - 'dataGrafik', - 'tanggalDari', - 'tanggalSampai', - 'periodeDari', - 'periodeSampai' + 'santri', 'transaksi', + 'totalPemasukan', 'totalPengeluaran', 'saldoTerakhir', + 'dataGrafik', 'tanggalDari', 'tanggalSampai', + 'periodeDari', 'periodeSampai' )); } /** - * Helper: Recalculate saldo untuk transaksi setelah tanggal tertentu + * Hitung ulang saldo_sebelum & saldo_sesudah untuk semua transaksi + * milik $idSantri yang tanggalnya >= $tanggal. + * + * Dipanggil setelah store / update / destroy agar urutan saldo + * tetap konsisten meski transaksi disisipkan di tengah. */ private function recalculateSaldoAfter($idSantri, $tanggal) { + // Pastikan format tanggal string (bukan Carbon object) + $tanggal = $tanggal instanceof \Carbon\Carbon + ? $tanggal->format('Y-m-d') + : $tanggal; + $transaksiSetelah = UangSaku::where('id_santri', $idSantri) ->where('tanggal_transaksi', '>=', $tanggal) ->orderBy('tanggal_transaksi') ->orderBy('created_at') + ->orderBy('id') ->get(); foreach ($transaksiSetelah as $index => $trans) { if ($index === 0) { - // Transaksi pertama: ambil saldo dari transaksi sebelumnya - $saldoSebelumnya = UangSaku::where('id_santri', $idSantri) - ->where('id', '<', $trans->id) - ->orderBy('tanggal_transaksi', 'desc') - ->orderBy('created_at', 'desc') + // Cari saldo_sesudah transaksi tepat sebelum batch ini + $prev = UangSaku::where('id_santri', $idSantri) + ->where('tanggal_transaksi', '<', $tanggal) + ->orderByDesc('tanggal_transaksi') + ->orderByDesc('created_at') + ->orderByDesc('id') ->first(); - - $trans->saldo_sebelum = $saldoSebelumnya ? $saldoSebelumnya->saldo_sesudah : 0; + $trans->saldo_sebelum = $prev ? (float)$prev->saldo_sesudah : 0; } else { - $trans->saldo_sebelum = $transaksiSetelah[$index - 1]->saldo_sesudah; + $trans->saldo_sebelum = (float)$transaksiSetelah[$index - 1]->saldo_sesudah; } - if ($trans->jenis_transaksi === 'pemasukan') { - $trans->saldo_sesudah = $trans->saldo_sebelum + $trans->nominal; - } else { - $trans->saldo_sesudah = $trans->saldo_sebelum - $trans->nominal; - } + $trans->saldo_sesudah = $trans->jenis_transaksi === 'pemasukan' + ? $trans->saldo_sebelum + (float)$trans->nominal + : $trans->saldo_sebelum - (float)$trans->nominal; - $trans->saveQuietly(); // Save tanpa trigger event + $trans->saveQuietly(); } } } \ No newline at end of file diff --git a/sim-pkpps/app/Http/Controllers/Admin/UserController.php b/sim-pkpps/app/Http/Controllers/Admin/UserController.php index a310171..c2c17d8 100644 --- a/sim-pkpps/app/Http/Controllers/Admin/UserController.php +++ b/sim-pkpps/app/Http/Controllers/Admin/UserController.php @@ -6,183 +6,363 @@ use App\Http\Controllers\Controller; use App\Models\User; use App\Models\Santri; +use App\Models\SantriAccount; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; -use Illuminate\Validation\Rule; class UserController extends Controller { + // ══════════════════ AKUN SANTRI (WEB) ══════════════════ + /** - * Tampilkan daftar akun Santri. + * Daftar akun santri */ public function santriAccounts() { - $users = User::where('role', 'santri')->with('santri')->get(); - $santris_tanpa_akun = Santri::whereDoesntHave('user', function($query) { - $query->where('role', 'santri'); + $users = SantriAccount::where('role', 'santri')->with('santri')->get(); + + $santris_tanpa_akun = Santri::whereDoesntHave('santriAccount', function ($q) { + $q->where('role', 'santri'); })->get(); return view('admin.users.santri_accounts', compact('users', 'santris_tanpa_akun')); } /** - * Tampilkan daftar akun Wali Santri. + * Buat akun santri untuk satu santri langsung (1 klik) + */ + public function buatAkunSantri(Request $request, string $idSantri) + { + $santri = Santri::where('id_santri', $idSantri)->firstOrFail(); + + if (!$santri->nis) { + return redirect()->back() + ->with('error', 'Santri ' . $santri->nama_lengkap . ' belum memiliki NIS.'); + } + + $sudahAda = SantriAccount::where('role', 'santri') + ->where('id_santri', $idSantri)->exists(); + + if ($sudahAda) { + return redirect()->back() + ->with('error', 'Santri ' . $santri->nama_lengkap . ' sudah memiliki akun.'); + } + + SantriAccount::create([ + 'id_santri' => $santri->id_santri, + 'username' => $santri->nama_lengkap, + 'password' => Hash::make($santri->nis), + 'role' => 'santri', + ]); + + return redirect()->back() + ->with('success', 'Akun santri ' . $santri->nama_lengkap . ' berhasil dibuat. Username: ' . $santri->nama_lengkap . ' | Password: ' . $santri->nis); + } + + /** + * Buat akun santri untuk semua santri yang belum punya akun (1 klik massal) + */ + public function buatSemuaAkunSantri(Request $request) + { + $santriList = Santri::whereDoesntHave('santriAccount', function ($q) { + $q->where('role', 'santri'); + })->whereNotNull('nis')->get(); + + if ($santriList->isEmpty()) { + return redirect()->back() + ->with('info', 'Semua santri sudah memiliki akun.'); + } + + $berhasil = 0; + + foreach ($santriList as $santri) { + SantriAccount::create([ + 'id_santri' => $santri->id_santri, + 'username' => $santri->nama_lengkap, + 'password' => Hash::make($santri->nis), + 'role' => 'santri', + ]); + $berhasil++; + } + + return redirect()->back() + ->with('success', $berhasil . ' akun santri berhasil dibuat sekaligus.'); + } + + /** + * Hapus akun santri + */ + public function destroySantriAccount(string $id) + { + $account = SantriAccount::where('role', 'santri')->findOrFail($id); + $nama = $account->santri ? $account->santri->nama_lengkap : $account->username; + $account->delete(); + + return redirect()->back() + ->with('success', 'Akun santri ' . $nama . ' berhasil dihapus.'); + } + + // ══════════════════ AKUN WALI (MOBILE) ══════════════════ + + /** + * Daftar akun wali */ public function waliAccounts() { - $users = User::where('role', 'wali')->with('santri')->get(); - - $santris_tanpa_wali = Santri::whereDoesntHave('waliUser')->get(); + $users = SantriAccount::where('role', 'wali')->with('santri')->get(); + + $santris_tanpa_wali = Santri::whereDoesntHave('santriAccount', function ($q) { + $q->where('role', 'wali'); + })->get(); return view('admin.users.wali_accounts', compact('users', 'santris_tanpa_wali')); } /** - * Tampilkan form untuk membuat akun baru. + * Resolve username untuk akun wali. + * + * Aturan: + * - Default : nama_orang_tua (sama seperti sebelumnya, username = nama ortu) + * - Fallback : "nama_orang_tua - nama_santri" + * β†’ hanya dipakai jika nama_orang_tua sudah dipakai + * akun wali lain (cek DB + array in-memory untuk proses massal). + * + * @param Santri $santri + * @param array $usernameYangSudahDipakai username yang sudah dibuat dalam iterasi massal saat ini */ - public function createAccount(string $role) + private function resolveUsernameWali(Santri $santri, array $usernameYangSudahDipakai = []): string { - if (!in_array($role, ['santri', 'wali'])) { - abort(404); + $usernameDefault = $santri->nama_orang_tua; + + // Cek di database: apakah nama ortu ini sudah jadi username wali lain? + $sudahDiDbOlehLain = SantriAccount::where('role', 'wali') + ->where('username', $usernameDefault) + ->where('id_santri', '!=', $santri->id_santri) + ->exists(); + + // Cek di array in-memory (untuk proses massal dalam 1 request) + $sudahDiMemoriOlehLain = in_array($usernameDefault, $usernameYangSudahDipakai); + + if ($sudahDiDbOlehLain || $sudahDiMemoriOlehLain) { + // Fallback: tambahkan nama santri agar unik + return $usernameDefault . ' - ' . $santri->nama_lengkap; } - if ($role === 'santri') { - $list_data = Santri::whereDoesntHave('user', function($query) { - $query->where('role', 'santri'); - })->get(); - } else { - // Wali: ambil santri yang belum punya akun wali - $list_data = Santri::whereDoesntHave('waliUser')->get(); - } - - return view('admin.users.create_account', compact('role', 'list_data')); + // Normal: cukup nama orang tua saja + return $usernameDefault; } /** - * Simpan akun baru. + * Buat akun wali untuk satu santri langsung (1 klik) */ - public function storeAccount(Request $request, string $role) + public function buatAkunWali(Request $request, string $idSantri) { - if (!in_array($role, ['santri', 'wali'])) { - abort(404); + $santri = Santri::where('id_santri', $idSantri)->firstOrFail(); + + if (!$santri->nis) { + return redirect()->back() + ->with('error', 'Santri ' . $santri->nama_lengkap . ' belum memiliki NIS.'); } - // Validasi berbeda untuk santri dan wali - $rules = [ - 'role_id' => [ - 'required', - Rule::exists('santris', 'id_santri'), - function ($attribute, $value, $fail) use ($role) { - $exists = User::where('role', $role) - ->where('role_id', $value) - ->exists(); - if ($exists) { - $fail("Santri ini sudah memiliki akun {$role}."); - } - }, - ], - 'username' => 'required|string|max:255|unique:users,username', - ]; - - // Untuk wali: password tidak perlu min karena otomatis dari NIS - // Untuk santri: password minimal 8 karakter - if ($role === 'wali') { - $rules['password'] = 'required|string|confirmed'; - } else { - $rules['password'] = 'required|string|min:8|confirmed'; + if (!$santri->nama_orang_tua) { + return redirect()->back() + ->with('error', 'Santri ' . $santri->nama_lengkap . ' belum memiliki data nama orang tua.'); } - $messages = [ - 'role_id.required' => 'Wajib memilih santri.', - 'role_id.exists' => 'Data santri tidak ditemukan.', - 'username.unique' => 'Username sudah digunakan.', - 'username.required' => 'Username wajib diisi.', - 'password.required' => 'Password wajib diisi.', - 'password.min' => 'Password minimal 8 karakter.', - 'password.confirmed' => 'Konfirmasi password tidak cocok.', - ]; + $sudahAda = SantriAccount::where('role', 'wali') + ->where('id_santri', $idSantri)->exists(); - $validated = $request->validate($rules, $messages); + if ($sudahAda) { + return redirect()->back() + ->with('error', 'Wali santri ' . $santri->nama_lengkap . ' sudah memiliki akun.'); + } - // Ambil data santri - $santri = Santri::where('id_santri', $validated['role_id'])->firstOrFail(); - - // Untuk wali: name = nama orang tua (jika ada) atau nama santri - // Untuk santri: name = nama santri - $name = ($role === 'wali') - ? ($santri->nama_orang_tua ?? $santri->nama_lengkap) - : $santri->nama_lengkap; + $username = $this->resolveUsernameWali($santri); - // Simpan User - User::create([ - 'name' => $name, - 'username' => $validated['username'], - 'password' => Hash::make($validated['password']), - 'role' => $role, - 'role_id' => $validated['role_id'], + SantriAccount::create([ + 'id_santri' => $santri->id_santri, + 'username' => $username, + 'password' => Hash::make($santri->nis), + 'role' => 'wali', ]); - $successMsg = $role === 'wali' - ? "Akun wali untuk santri {$santri->nama_lengkap} berhasil dibuat. Login: Username={$validated['username']}, Password=NIS" - : "Akun santri {$santri->nama_lengkap} berhasil dibuat."; - - return redirect()->route('admin.users.'.$role.'_accounts') - ->with('success', $successMsg); + return redirect()->back() + ->with('success', 'Akun wali untuk ' . $santri->nama_lengkap . ' berhasil dibuat. Username: ' . $username . ' | Password: ' . $santri->nis); } /** - * Hapus akun santri/wali. + * Buat akun wali untuk semua santri yang belum punya akun wali (1 klik massal) */ - public function destroyAccount(string $role, string $userId) + public function buatSemuaAkunWali(Request $request) { - if (!in_array($role, ['santri', 'wali'])) { - abort(404); + $santriList = Santri::whereDoesntHave('santriAccount', function ($q) { + $q->where('role', 'wali'); + })->whereNotNull('nis')->whereNotNull('nama_orang_tua')->get(); + + if ($santriList->isEmpty()) { + return redirect()->back() + ->with('info', 'Semua santri sudah memiliki akun wali.'); } - // Cari user berdasarkan ID - $user = User::findOrFail($userId); + $berhasil = 0; + $gagal = 0; - // Pastikan user yang akan dihapus adalah role yang sesuai - if ($user->role !== $role) { - return redirect()->back()->with('error', 'Akun tidak valid.'); + // Lacak username yg dibuat dalam iterasi ini agar + // santri berikut dg nama ortu sama langsung dapat fallback + $usernameYangSudahDipakai = []; + + foreach ($santriList as $santri) { + if (!$santri->nama_orang_tua) { + $gagal++; + continue; + } + + $username = $this->resolveUsernameWali($santri, $usernameYangSudahDipakai); + + SantriAccount::create([ + 'id_santri' => $santri->id_santri, + 'username' => $username, + 'password' => Hash::make($santri->nis), + 'role' => 'wali', + ]); + + $usernameYangSudahDipakai[] = $username; + $berhasil++; } - $userName = $user->name; - $user->delete(); + $pesan = $berhasil . ' akun wali berhasil dibuat.'; + if ($gagal > 0) { + $pesan .= ' ' . $gagal . ' dilewati karena data orang tua tidak lengkap.'; + } - return redirect()->route('admin.users.'.$role.'_accounts') - ->with('success', "Akun {$role} {$userName} berhasil dihapus."); + return redirect()->back()->with('success', $pesan); } /** - * Reset password akun santri/wali ke default (NIS). + * Hapus akun wali */ - public function resetPassword(string $role, string $userId) + public function destroyWaliAccount(string $id) { - if (!in_array($role, ['santri', 'wali'])) { - abort(404); + $account = SantriAccount::where('role', 'wali')->findOrFail($id); + $nama = $account->santri ? $account->santri->nama_lengkap : $account->username; + $account->delete(); + + return redirect()->back() + ->with('success', 'Akun wali ' . $nama . ' berhasil dihapus.'); + } + + // ══════════════════ AKUN ADMIN ══════════════════ + + /** + * Daftar akun admin + */ + public function adminAccounts() + { + $admins = User::whereIn('role', ['super_admin', 'akademik', 'pamong']) + ->orderByRaw("FIELD(role, 'super_admin', 'akademik', 'pamong')") + ->orderBy('name') + ->get(); + + return view('admin.users.admin_accounts', compact('admins')); + } + + /** + * Form buat akun admin baru + */ + public function createAdminAccount() + { + return view('admin.users.admin_form', [ + 'admin' => null, + 'action' => route('admin.users.admin_store'), + 'method' => 'POST', + ]); + } + + /** + * Simpan akun admin baru + */ + public function storeAdminAccount(Request $request) + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'email' => 'required|email|unique:users,email', + 'role' => 'required|in:akademik,pamong', + 'password' => 'required|string|min:8|confirmed', + ], [ + 'name.required' => 'Nama wajib diisi.', + 'email.required' => 'Email wajib diisi.', + 'email.unique' => 'Email sudah digunakan.', + 'role.required' => 'Role wajib dipilih.', + 'password.required' => 'Password wajib diisi.', + 'password.min' => 'Password minimal 8 karakter.', + 'password.confirmed'=> 'Konfirmasi password tidak cocok.', + ]); + + User::create([ + 'name' => $validated['name'], + 'email' => $validated['email'], + 'username' => $validated['email'], + 'password' => Hash::make($validated['password']), + 'role' => $validated['role'], + ]); + + return redirect()->route('admin.users.admin_accounts') + ->with('success', 'Akun ' . $validated['role'] . ' untuk ' . $validated['name'] . ' berhasil dibuat.'); + } + + /** + * Form edit akun admin + */ + public function editAdminAccount(string $userId) + { + $admin = User::whereIn('role', ['akademik', 'pamong'])->findOrFail($userId); + + return view('admin.users.admin_form', [ + 'admin' => $admin, + 'action' => route('admin.users.admin_update', $userId), + 'method' => 'PUT', + ]); + } + + /** + * Update akun admin + */ + public function updateAdminAccount(Request $request, string $userId) + { + $admin = User::whereIn('role', ['akademik', 'pamong'])->findOrFail($userId); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'email' => 'required|email|unique:users,email,' . $userId, + 'role' => 'required|in:akademik,pamong', + 'password' => 'nullable|string|min:8|confirmed', + ]); + + $admin->name = $validated['name']; + $admin->email = $validated['email']; + $admin->username = $validated['email']; + $admin->role = $validated['role']; + + if (!empty($validated['password'])) { + $admin->password = Hash::make($validated['password']); } - // Cari user berdasarkan ID - $user = User::findOrFail($userId); + $admin->save(); - // Pastikan user adalah role yang sesuai - if ($user->role !== $role) { - return redirect()->back()->with('error', 'Akun tidak valid.'); - } + return redirect()->route('admin.users.admin_accounts') + ->with('success', 'Akun ' . $admin->name . ' berhasil diperbarui.'); + } - // Ambil santri terkait - $santri = Santri::where('id_santri', $user->role_id)->first(); - - if (!$santri || !$santri->nis) { - return redirect()->back()->with('error', 'NIS santri tidak ditemukan. Tidak dapat mereset password.'); - } + /** + * Hapus akun admin + */ + public function destroyAdminAccount(string $userId) + { + $admin = User::whereIn('role', ['akademik', 'pamong'])->findOrFail($userId); + $nama = $admin->name; + $admin->delete(); - // Reset password ke NIS - $user->password = Hash::make($santri->nis); - $user->save(); - - return redirect()->route('admin.users.'.$role.'_accounts') - ->with('success', "Password akun {$user->name} berhasil direset ke NIS: {$santri->nis}"); + return redirect()->route('admin.users.admin_accounts') + ->with('success', 'Akun ' . $nama . ' berhasil dihapus.'); } } \ No newline at end of file diff --git a/sim-pkpps/app/Http/Controllers/Api/ApiAbsensiKegiatanController.php b/sim-pkpps/app/Http/Controllers/Api/ApiAbsensiKegiatanController.php index 36a3ac9..5f86ad9 100644 --- a/sim-pkpps/app/Http/Controllers/Api/ApiAbsensiKegiatanController.php +++ b/sim-pkpps/app/Http/Controllers/Api/ApiAbsensiKegiatanController.php @@ -23,7 +23,7 @@ public function today(Request $request) { try { $user = $request->user(); - $idSantri = $user->role_id; // Santri atau wali punya role_id = id_santri + $idSantri = $user->id_santri; // id_santri dari santri_accounts $tanggal = $request->get('tanggal', now()->format('Y-m-d')); $selectedDate = Carbon::parse($tanggal); @@ -120,7 +120,7 @@ public function week(Request $request) { try { $user = $request->user(); - $idSantri = $user->role_id; + $idSantri = $user->id_santri; $startDate = Carbon::now()->startOfWeek(); $endDate = Carbon::now()->endOfWeek(); @@ -221,7 +221,7 @@ public function month(Request $request) { try { $user = $request->user(); - $idSantri = $user->role_id; + $idSantri = $user->id_santri; $bulan = $request->get('bulan', now()->format('Y-m')); $date = Carbon::parse($bulan . '-01'); diff --git a/sim-pkpps/app/Http/Controllers/Api/ApiAuthController.php b/sim-pkpps/app/Http/Controllers/Api/ApiAuthController.php index 0e968bc..8267296 100644 --- a/sim-pkpps/app/Http/Controllers/Api/ApiAuthController.php +++ b/sim-pkpps/app/Http/Controllers/Api/ApiAuthController.php @@ -4,7 +4,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; -use App\Models\User; +use App\Models\SantriAccount; use App\Models\Santri; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; @@ -13,109 +13,98 @@ class ApiAuthController extends Controller { /** - * Login Santri/Wali via Mobile - * - * Request: - * - id_santri (username) + * Login Wali via Mobile (Sanctum token) + * + * Request: + * - username * - password - * + * * Response: * - token - * - user (name, role, role_id) - * - santri (data lengkap santri jika role=santri) + * - user (role, id_santri) + * - santri (data lengkap) */ public function login(Request $request) { $request->validate([ 'id_santri' => 'required|string', - 'password' => 'required|string', + 'password' => 'required|string', ]); - // Cari user berdasarkan username (id_santri) - $user = User::where('username', $request->id_santri)->first(); + // -- Cari akun di santri_accounts -- + $account = SantriAccount::where('username', $request->id_santri)->first(); - // Validasi user dan password - if (!$user || !Hash::check($request->password, $user->password)) { + if (!$account || !Hash::check($request->password, $account->password)) { throw ValidationException::withMessages([ 'id_santri' => ['ID Santri atau password salah.'], ]); } - // Cek apakah user adalah santri atau wali - if (!in_array($user->role, ['santri', 'wali'])) { - return response()->json([ - 'success' => false, - 'message' => 'Akun ini tidak memiliki akses ke aplikasi mobile.', - ], 403); - } + // -- Hapus token lama -- + $account->tokens()->delete(); - // Hapus token lama (optional, untuk keamanan) - $user->tokens()->delete(); + // -- Buat token baru -- + $token = $account->createToken('mobile-app')->plainTextToken; - // Buat token baru - $token = $user->createToken('mobile-app')->plainTextToken; + // -- Update last_login -- + $account->update(['last_login' => now()]); - // Prepare response data + // -- Response data -- $responseData = [ 'success' => true, 'message' => 'Login berhasil', - 'token' => $token, - 'user' => [ - 'name' => $user->name, - 'role' => $user->role, - 'role_id' => $user->role_id, + 'token' => $token, + 'user' => [ + 'name' => $account->santri->nama_lengkap ?? '-', + 'role' => $account->role, + 'role_id' => $account->id_santri, ], ]; - // Jika santri atau wali, sertakan data santri - // Untuk wali, role_id menyimpan id_santri yang diwali (anaknya) - if (in_array($user->role, ['santri', 'wali'])) { - $santri = Santri::with(['kelasSantri.kelas.kelompok', 'kelasPrimary.kelas']) - ->where('id_santri', $user->role_id) - ->select([ - 'id_santri', - 'nis', - 'nama_lengkap', - 'jenis_kelamin', - 'status', - 'alamat_santri', - 'daerah_asal', - 'nama_orang_tua', - 'nomor_hp_ortu', - 'foto' - ]) - ->first(); + // -- Sertakan data santri -- + $santri = Santri::with(['kelasSantri.kelas.kelompok', 'kelasPrimary.kelas']) + ->where('id_santri', $account->id_santri) + ->select([ + 'id_santri', + 'nis', + 'nama_lengkap', + 'jenis_kelamin', + 'status', + 'alamat_santri', + 'daerah_asal', + 'nama_orang_tua', + 'nomor_hp_ortu', + 'foto' + ]) + ->first(); - if ($santri) { - // Build kelas_list grouped by kelompok - $kelasList = $this->buildKelasListGrouped($santri); + if ($santri) { + $kelasList = $this->buildKelasListGrouped($santri); - // Get primary kelas name for backward compatibility - $kelasName = 'Belum Ada Kelas'; - if ($santri->kelasPrimary && $santri->kelasPrimary->kelas) { - $kelasName = $santri->kelasPrimary->kelas->nama_kelas; - } elseif ($santri->kelasSantri->isNotEmpty() && $santri->kelasSantri->first()->kelas) { - $kelasName = $santri->kelasSantri->first()->kelas->nama_kelas; - } - - $responseData['santri'] = [ - 'id_santri' => $santri->id_santri, - 'nis' => $santri->nis, - 'nama_lengkap' => $santri->nama_lengkap, - 'jenis_kelamin' => $santri->jenis_kelamin, - 'status' => $santri->status, - 'alamat_santri' => $santri->alamat_santri, - 'daerah_asal' => $santri->daerah_asal, - 'nama_orang_tua' => $santri->nama_orang_tua, - 'nomor_hp_ortu' => $santri->nomor_hp_ortu, - 'foto' => $santri->foto, - 'foto_url' => $santri->foto_url, - 'kelas' => $kelasName, // Backward compatibility - 'kelas_list' => $kelasList, // NEW: Multiple kelas grouped - ]; - } else { - $responseData['santri'] = null; + $kelasName = 'Belum Ada Kelas'; + if ($santri->kelasPrimary && $santri->kelasPrimary->kelas) { + $kelasName = $santri->kelasPrimary->kelas->nama_kelas; + } elseif ($santri->kelasSantri->isNotEmpty() && $santri->kelasSantri->first()->kelas) { + $kelasName = $santri->kelasSantri->first()->kelas->nama_kelas; } + + $responseData['santri'] = [ + 'id_santri' => $santri->id_santri, + 'nis' => $santri->nis, + 'nama_lengkap' => $santri->nama_lengkap, + 'jenis_kelamin' => $santri->jenis_kelamin, + 'status' => $santri->status, + 'alamat_santri' => $santri->alamat_santri, + 'daerah_asal' => $santri->daerah_asal, + 'nama_orang_tua' => $santri->nama_orang_tua, + 'nomor_hp_ortu' => $santri->nomor_hp_ortu, + 'foto' => $santri->foto, + 'foto_url' => $santri->foto_url, + 'kelas' => $kelasName, + 'kelas_list' => $kelasList, + ]; + } else { + $responseData['santri'] = null; } return response()->json($responseData, 200); @@ -126,7 +115,6 @@ public function login(Request $request) */ public function logout(Request $request) { - // Hapus token yang sedang digunakan $request->user()->currentAccessToken()->delete(); return response()->json([ @@ -137,24 +125,13 @@ public function logout(Request $request) /** * Get Profile Santri yang sedang login - * Untuk role santri: tampilkan data diri sendiri - * Untuk role wali: tampilkan data santri yang diwali (anaknya) */ public function profile(Request $request) { - $user = $request->user(); + $account = $request->user(); - // Hanya santri dan wali yang bisa akses profil - if (!in_array($user->role, ['santri', 'wali'])) { - return response()->json([ - 'success' => false, - 'message' => 'Hanya santri/wali yang bisa mengakses profil.', - ], 403); - } - - // Untuk santri dan wali, role_id menyimpan id_santri $santri = Santri::with(['kelasSantri.kelas.kelompok', 'kelasPrimary.kelas']) - ->where('id_santri', $user->role_id) + ->where('id_santri', $account->id_santri) ->select([ 'id_santri', 'nis', @@ -177,10 +154,8 @@ public function profile(Request $request) ], 404); } - // Build kelas_list grouped by kelompok $kelasList = $this->buildKelasListGrouped($santri); - // Get primary kelas name for backward compatibility $kelasName = 'Belum Ada Kelas'; if ($santri->kelasPrimary && $santri->kelasPrimary->kelas) { $kelasName = $santri->kelasPrimary->kelas->nama_kelas; @@ -191,19 +166,19 @@ public function profile(Request $request) return response()->json([ 'success' => true, 'data' => [ - 'id_santri' => $santri->id_santri, - 'nis' => $santri->nis, - 'nama_lengkap' => $santri->nama_lengkap, - 'jenis_kelamin' => $santri->jenis_kelamin, - 'status' => $santri->status, - 'alamat_santri' => $santri->alamat_santri, - 'daerah_asal' => $santri->daerah_asal, - 'nama_orang_tua' => $santri->nama_orang_tua, - 'nomor_hp_ortu' => $santri->nomor_hp_ortu, - 'foto_url' => $santri->foto_url, // Accessor dari Model Santri + 'id_santri' => $santri->id_santri, + 'nis' => $santri->nis, + 'nama_lengkap' => $santri->nama_lengkap, + 'jenis_kelamin' => $santri->jenis_kelamin, + 'status' => $santri->status, + 'alamat_santri' => $santri->alamat_santri, + 'daerah_asal' => $santri->daerah_asal, + 'nama_orang_tua' => $santri->nama_orang_tua, + 'nomor_hp_ortu' => $santri->nomor_hp_ortu, + 'foto_url' => $santri->foto_url, 'bergabung_sejak' => $santri->created_at->format('d F Y'), - 'kelas' => $kelasName, // Backward compatibility - 'kelas_list' => $kelasList, // NEW: Multiple kelas grouped + 'kelas' => $kelasName, + 'kelas_list' => $kelasList, ] ], 200); } diff --git a/sim-pkpps/app/Http/Controllers/Api/ApiBeritaController.php b/sim-pkpps/app/Http/Controllers/Api/ApiBeritaController.php index a6aa456..e40425a 100644 --- a/sim-pkpps/app/Http/Controllers/Api/ApiBeritaController.php +++ b/sim-pkpps/app/Http/Controllers/Api/ApiBeritaController.php @@ -15,7 +15,7 @@ class ApiBeritaController extends Controller public function index(Request $request) { try { - $idSantri = $request->user()->role_id; + $idSantri = $request->user()->id_santri; $santri = Santri::with('kelasPrimary.kelas')->where('id_santri', $idSantri)->first(); @@ -82,7 +82,7 @@ public function index(Request $request) public function show(Request $request, $idBerita) { try { - $idSantri = $request->user()->role_id; + $idSantri = $request->user()->id_santri; $berita = Berita::where('id_berita', $idBerita) ->where('status', 'published') diff --git a/sim-pkpps/app/Http/Controllers/Api/ApiCapaianController.php b/sim-pkpps/app/Http/Controllers/Api/ApiCapaianController.php index 1b42f01..280a66f 100644 --- a/sim-pkpps/app/Http/Controllers/Api/ApiCapaianController.php +++ b/sim-pkpps/app/Http/Controllers/Api/ApiCapaianController.php @@ -108,16 +108,7 @@ public function overview(Request $request) { try { $user = $request->user(); - - // Validasi role - if (!in_array($user->role, ['santri', 'wali'])) { - return response()->json([ - 'success' => false, - 'message' => 'Akses ditolak. Role: ' . $user->role, - ], 403); - } - - $idSantri = $user->role_id; + $idSantri = $user->id_santri; $santri = Santri::with(['kelasPrimary.kelas.kelompok', 'kelasSantri.kelas.kelompok']) ->where('id_santri', $idSantri) @@ -221,7 +212,7 @@ public function listMateriByKategori(Request $request, $kategori) { try { $user = $request->user(); - $idSantri = $user->role_id; + $idSantri = $user->id_santri; $validKategori = ['Al-Qur\'an', 'Hadist', 'Materi Tambahan']; if (!in_array($kategori, $validKategori)) { @@ -304,7 +295,7 @@ public function detailCapaian(Request $request, $idCapaian) { try { $user = $request->user(); - $idSantri = $user->role_id; + $idSantri = $user->id_santri; $capaian = Capaian::where('id_capaian', $idCapaian) ->where('id_santri', $idSantri) @@ -386,7 +377,7 @@ public function grafikProgress(Request $request) { try { $user = $request->user(); - $idSantri = $user->role_id; + $idSantri = $user->id_santri; $semesters = Semester::orderBy('tahun_ajaran') ->orderBy('periode') @@ -435,12 +426,7 @@ public function trendSemester(Request $request) { try { $user = $request->user(); - - if (!in_array($user->role, ['santri', 'wali'])) { - return response()->json(['success' => false, 'message' => 'Akses ditolak'], 403); - } - - $idSantri = $user->role_id; + $idSantri = $user->id_santri; $santri = Santri::with(['kelasPrimary.kelas.kelompok'])->where('id_santri', $idSantri)->first(); if (!$santri) { @@ -514,12 +500,7 @@ public function dashboard(Request $request) { try { $user = $request->user(); - - if (!in_array($user->role, ['santri', 'wali'])) { - return response()->json(['success' => false, 'message' => 'Akses ditolak'], 403); - } - - $idSantri = $user->role_id; + $idSantri = $user->id_santri; $santri = Santri::with(['kelasPrimary.kelas.kelompok', 'kelasSantri.kelas.kelompok']) ->where('id_santri', $idSantri) ->first(); diff --git a/sim-pkpps/app/Http/Controllers/Api/ApiKepulanganController.php b/sim-pkpps/app/Http/Controllers/Api/ApiKepulanganController.php index 3fa918a..34ef0d6 100644 --- a/sim-pkpps/app/Http/Controllers/Api/ApiKepulanganController.php +++ b/sim-pkpps/app/Http/Controllers/Api/ApiKepulanganController.php @@ -19,17 +19,9 @@ public function index(Request $request) { try { $user = Auth::user(); - - // Pastikan user adalah santri atau wali - if (!in_array($user->role, ['santri', 'wali'])) { - return response()->json([ - 'success' => false, - 'message' => 'Akses ditolak. Hanya santri/wali yang dapat mengakses.', - ], 403); - } - // Ambil id_santri dari role_id (untuk santri dan wali, role_id = id_santri) - $idSantri = $user->role_id; + // Ambil id_santri dari akun yang login + $idSantri = $user->id_santri; if (!$idSantri) { return response()->json([ @@ -70,7 +62,7 @@ public function index(Request $request) 'success' => true, 'message' => 'Data kepulangan berhasil diambil.', 'data' => [ - 'kepulangan' => $kepulangan->map(function($item) { + 'kepulangan' => collect($kepulangan->items())->map(function($item) { return [ 'id_kepulangan' => $item->id_kepulangan, 'tanggal_izin' => $item->tanggal_izin->format('Y-m-d'), @@ -128,16 +120,8 @@ public function show($idKepulangan) { try { $user = Auth::user(); - - // Pastikan user adalah santri atau wali - if (!in_array($user->role, ['santri', 'wali'])) { - return response()->json([ - 'success' => false, - 'message' => 'Akses ditolak.', - ], 403); - } - $idSantri = $user->role_id; + $idSantri = $user->id_santri; // Get kepulangan dengan validasi kepemilikan $kepulangan = Kepulangan::with('santri') @@ -220,7 +204,7 @@ public function kuota(Request $request) ], 403); } - $idSantri = $user->role_id; + $idSantri = $user->id_santri; if (!$idSantri) { return response()->json([ @@ -275,4 +259,67 @@ public function kuota(Request $request) ], 500); } } + + /** + * Notifikasi status kepulangan santri saat ini + * GET /api/v1/kepulangan/notifikasi + */ + public function notifikasiKepulangan(Request $request) + { + try { + $user = Auth::user(); + + if (!in_array($user->role, ['santri', 'wali'])) { + return response()->json([ + 'success' => false, + 'message' => 'Akses ditolak.', + ], 403); + } + + $idSantri = $user->id_santri; + $today = Carbon::today(); + + // Cari kepulangan yang sedang aktif (tanggal hari ini ada di antara tanggal_pulang dan tanggal_kembali) + $kepulangan = Kepulangan::where('id_santri', $idSantri) + ->where('status', 'Disetujui') + ->where('tanggal_pulang', '<=', $today) + ->where('tanggal_kembali', '>=', $today) + ->orderBy('tanggal_kembali', 'desc') + ->first(); + + if (!$kepulangan) { + return response()->json([ + 'success' => true, + 'data' => [ + 'sedang_pulang' => false, + 'tanggal_kembali' => null, + 'sisa_hari' => 0, + 'status' => null, + ], + ]); + } + + $tanggalKembali = Carbon::parse($kepulangan->tanggal_kembali); + $sisaHari = $today->diffInDays($tanggalKembali, false); // negatif jika sudah lewat + $statusKepulangan = $sisaHari < 0 ? 'terlambat' : 'aktif'; + + return response()->json([ + 'success' => true, + 'data' => [ + 'sedang_pulang' => true, + 'tanggal_kembali' => $tanggalKembali->format('Y-m-d'), + 'tanggal_kembali_formatted' => $tanggalKembali->locale('id')->isoFormat('D MMMM Y'), + 'sisa_hari' => (int) $sisaHari, + 'status' => $statusKepulangan, + 'alasan' => $kepulangan->alasan, + ], + ]); + + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => 'Terjadi kesalahan: ' . $e->getMessage(), + ], 500); + } + } } diff --git a/sim-pkpps/app/Http/Controllers/Api/ApiKesehatanController.php b/sim-pkpps/app/Http/Controllers/Api/ApiKesehatanController.php index fa45974..8ac546e 100644 --- a/sim-pkpps/app/Http/Controllers/Api/ApiKesehatanController.php +++ b/sim-pkpps/app/Http/Controllers/Api/ApiKesehatanController.php @@ -16,7 +16,7 @@ public function index(Request $request) { try { // Ambil id_santri dari user yang login (wali) - $idSantri = $request->user()->role_id; + $idSantri = $request->user()->id_santri; // Cek santri exist $santri = Santri::where('id_santri', $idSantri)->first(); @@ -91,7 +91,7 @@ public function index(Request $request) public function show(Request $request, $idKesehatan) { try { - $idSantri = $request->user()->role_id; + $idSantri = $request->user()->id_santri; // Cari data kesehatan $kesehatan = KesehatanSantri::where('id_kesehatan', $idKesehatan) @@ -134,7 +134,7 @@ public function show(Request $request, $idKesehatan) public function statistik(Request $request) { try { - $idSantri = $request->user()->role_id; + $idSantri = $request->user()->id_santri; // Hitung total per status $totalDirawat = KesehatanSantri::where('id_santri', $idSantri) diff --git a/sim-pkpps/app/Http/Controllers/Api/ApiPengajuanKepulanganController.php b/sim-pkpps/app/Http/Controllers/Api/ApiPengajuanKepulanganController.php index bd70ab0..2adb629 100644 --- a/sim-pkpps/app/Http/Controllers/Api/ApiPengajuanKepulanganController.php +++ b/sim-pkpps/app/Http/Controllers/Api/ApiPengajuanKepulanganController.php @@ -20,16 +20,7 @@ public function store(Request $request) { try { $user = Auth::user(); - - // Validasi role - if (!in_array($user->role, ['santri', 'wali'])) { - return response()->json([ - 'success' => false, - 'message' => 'Akses ditolak.', - ], 403); - } - - $idSantri = $user->role_id; + $idSantri = $user->id_santri; // Validasi input $validated = $request->validate([ @@ -98,15 +89,7 @@ public function index(Request $request) { try { $user = Auth::user(); - - if (!in_array($user->role, ['santri', 'wali'])) { - return response()->json([ - 'success' => false, - 'message' => 'Akses ditolak.', - ], 403); - } - - $idSantri = $user->role_id; + $idSantri = $user->id_santri; // Build query $page = $request->input('page', 1); @@ -176,7 +159,7 @@ public function preview(Request $request) { try { $user = Auth::user(); - $idSantri = $user->role_id; + $idSantri = $user->id_santri; $validated = $request->validate([ 'tanggal_pulang' => 'required|date', diff --git a/sim-pkpps/app/Http/Controllers/Api/ApiSppController.php b/sim-pkpps/app/Http/Controllers/Api/ApiSppController.php index 2bb0357..809626b 100644 --- a/sim-pkpps/app/Http/Controllers/Api/ApiSppController.php +++ b/sim-pkpps/app/Http/Controllers/Api/ApiSppController.php @@ -16,7 +16,7 @@ class ApiSppController extends Controller public function statusBulanIni(Request $request) { try { - $idSantri = $request->user()->role_id; + $idSantri = $request->user()->id_santri; $bulanIni = date('n'); $tahunIni = date('Y'); @@ -67,7 +67,7 @@ public function statusBulanIni(Request $request) public function tunggakan(Request $request) { try { - $idSantri = $request->user()->role_id; + $idSantri = $request->user()->id_santri; // Hitung tunggakan $tunggakanList = PembayaranSpp::where('id_santri', $idSantri) @@ -104,7 +104,7 @@ public function tunggakan(Request $request) public function riwayat(Request $request) { try { - $idSantri = $request->user()->role_id; + $idSantri = $request->user()->id_santri; // Query riwayat $query = PembayaranSpp::where('id_santri', $idSantri) @@ -174,7 +174,7 @@ public function riwayat(Request $request) public function statistik(Request $request) { try { - $idSantri = $request->user()->role_id; + $idSantri = $request->user()->id_santri; $totalLunas = PembayaranSpp::where('id_santri', $idSantri) ->where('status', 'Lunas') diff --git a/sim-pkpps/app/Http/Controllers/Api/ApiUangSakuController.php b/sim-pkpps/app/Http/Controllers/Api/ApiUangSakuController.php index 6310750..e39ddaa 100644 --- a/sim-pkpps/app/Http/Controllers/Api/ApiUangSakuController.php +++ b/sim-pkpps/app/Http/Controllers/Api/ApiUangSakuController.php @@ -16,7 +16,7 @@ public function saldo(Request $request) { try { // Ambil id_santri dari user yang login (wali) - $idSantri = $request->user()->role_id; + $idSantri = $request->user()->id_santri; // Ambil data santri $santri = Santri::where('id_santri', $idSantri)->first(); @@ -77,7 +77,7 @@ public function index(Request $request) { try { // Ambil id_santri dari user yang login (wali) - $idSantri = $request->user()->role_id; + $idSantri = $request->user()->id_santri; // Query transaksi uang saku $query = UangSaku::where('id_santri', $idSantri) diff --git a/sim-pkpps/app/Http/Controllers/Api/PelanggaranApiController.php b/sim-pkpps/app/Http/Controllers/Api/PelanggaranApiController.php index 823a8fa..33a8108 100644 --- a/sim-pkpps/app/Http/Controllers/Api/PelanggaranApiController.php +++ b/sim-pkpps/app/Http/Controllers/Api/PelanggaranApiController.php @@ -101,7 +101,7 @@ public function getRiwayatPelanggaran(Request $request) try { // Ambil id_santri dari user yang login $user = $request->user(); - $idSantri = $user->role_id; // role_id menyimpan id_santri + $idSantri = $user->id_santri; // id_santri dari santri_accounts // Query dengan pagination $perPage = $request->input('per_page', 10); @@ -168,7 +168,7 @@ public function getStatistik(Request $request) { try { $user = $request->user(); - $idSantri = $user->role_id; + $idSantri = $user->id_santri; // Hanya hitung yang sudah dipublish $totalPelanggaran = RiwayatPelanggaran::where('id_santri', $idSantri) @@ -221,7 +221,7 @@ public function getDetailRiwayat(Request $request, $idRiwayat) { try { $user = $request->user(); - $idSantri = $user->role_id; + $idSantri = $user->id_santri; $riwayat = RiwayatPelanggaran::with([ 'kategori:id_kategori,nama_pelanggaran,poin,kafaroh,id_klasifikasi', diff --git a/sim-pkpps/app/Http/Controllers/Auth/AdminAuthController.php b/sim-pkpps/app/Http/Controllers/Auth/AdminAuthController.php index 9783b63..5964697 100644 --- a/sim-pkpps/app/Http/Controllers/Auth/AdminAuthController.php +++ b/sim-pkpps/app/Http/Controllers/Auth/AdminAuthController.php @@ -33,24 +33,28 @@ public function authenticate(Request $request) 'password' => ['required', 'string'], ]); - // Clear session lama sebelum login - $request->session()->invalidate(); - $request->session()->regenerateToken(); - - // Start session baru - $request->session()->start(); - - // Coba login dengan username DAN role harus 'admin' + // -- Coba login dengan username -- if (Auth::attempt([ - 'username' => $credentials['username'], - 'password' => $credentials['password'], - 'role' => 'admin' + 'username' => $credentials['username'], + 'password' => $credentials['password'], ], $request->boolean('remember'))) { - - // Regenerate session untuk keamanan + + $user = Auth::user(); + $adminRoles = ['super_admin', 'akademik', 'pamong']; + + // -- Pastikan hanya role admin yang bisa login via form admin -- + if (!in_array($user->role, $adminRoles)) { + Auth::logout(); + $request->session()->invalidate(); + throw ValidationException::withMessages([ + 'username' => 'Akun ini bukan akun admin.', + ]); + } + + // -- Regenerate session untuk keamanan -- $request->session()->regenerate(); - - return redirect()->intended(route('admin.dashboard')); + + return redirect()->intended(route('admin.dashboard')); } // Track failed attempts @@ -112,8 +116,8 @@ public function storeRegister(Request $request) $user = User::create([ 'name' => 'Administrator', 'email' => $request->email, - 'username' => $request->email, // WAJIB: Gunakan email sebagai username untuk login - 'role' => 'admin', + 'username' => $request->email, + 'role' => 'super_admin', 'password' => Hash::make($request->password), ]); diff --git a/sim-pkpps/app/Http/Controllers/Auth/AdminForgotPasswordController.php b/sim-pkpps/app/Http/Controllers/Auth/AdminForgotPasswordController.php new file mode 100644 index 0000000..deab54b --- /dev/null +++ b/sim-pkpps/app/Http/Controllers/Auth/AdminForgotPasswordController.php @@ -0,0 +1,242 @@ +validate([ + 'email' => 'required|email', + ], [ + 'email.required' => 'Email wajib diisi.', + 'email.email' => 'Format email tidak valid.', + ]); + + // Cek apakah email terdaftar sebagai super_admin + $user = User::where('email', $request->email) + ->where('role', 'super_admin') + ->first(); + + if (!$user) { + return back()->withErrors([ + 'email' => 'Email tidak ditemukan atau bukan akun Super Admin.', + ])->withInput(); + } + + // Hapus OTP lama untuk email ini + PasswordResetOtp::where('email', $request->email)->delete(); + + // Generate OTP 6 digit + $otp = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT); + + // Simpan ke database dengan expired 10 menit + PasswordResetOtp::create([ + 'email' => $request->email, + 'otp' => $otp, + 'expired_at' => now()->addMinutes(10), + ]); + + // Kirim email OTP + Mail::to($request->email)->send(new OtpMail($otp, $user->name)); + + // Redirect ke form verifikasi OTP + return redirect() + ->route('admin.forgot.verify_form', ['email' => $request->email]) + ->with('success', 'Kode OTP telah dikirim ke email Anda. Berlaku 10 menit.'); + } + + // ══════════════════ STEP 2 : VERIFIKASI OTP ══════════════════ + + /** + * Tampilkan form input OTP + */ + public function showVerifyForm(Request $request) + { + $email = $request->query('email'); + + if (!$email) { + return redirect()->route('admin.forgot.email_form'); + } + + return view('admin.auth.verify_otp', compact('email')); + } + + /** + * Proses verifikasi OTP + */ + public function verifyOtp(Request $request) + { + $request->validate([ + 'email' => 'required|email', + 'otp' => 'required|string|size:6', + ], [ + 'otp.required' => 'Kode OTP wajib diisi.', + 'otp.size' => 'Kode OTP harus 6 digit.', + ]); + + $record = PasswordResetOtp::where('email', $request->email) + ->where('otp', $request->otp) + ->where('is_verified', false) + ->first(); + + if (!$record) { + return back()->withErrors([ + 'otp' => 'Kode OTP tidak valid.', + ])->withInput(); + } + + if ($record->isExpired()) { + $record->delete(); + return back()->withErrors([ + 'otp' => 'Kode OTP sudah expired. Silakan kirim ulang.', + ])->withInput(); + } + + // Tandai OTP sebagai terverifikasi + $record->update(['is_verified' => true]); + + // Redirect ke form reset password + return redirect() + ->route('admin.forgot.reset_form', ['email' => $request->email]) + ->with('success', 'Kode OTP valid. Silakan buat password baru.'); + } + + /** + * Kirim ulang OTP + */ + public function resendOtp(Request $request) + { + $request->validate([ + 'email' => 'required|email', + ]); + + $user = User::where('email', $request->email) + ->where('role', 'super_admin') + ->first(); + + if (!$user) { + return back()->withErrors([ + 'email' => 'Email tidak ditemukan.', + ]); + } + + // Hapus OTP lama + PasswordResetOtp::where('email', $request->email)->delete(); + + // Generate OTP baru + $otp = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT); + + PasswordResetOtp::create([ + 'email' => $request->email, + 'otp' => $otp, + 'expired_at' => now()->addMinutes(10), + ]); + + Mail::to($request->email)->send(new OtpMail($otp, $user->name)); + + return back()->with('success', 'Kode OTP baru telah dikirim ke email Anda.'); + } + + // ══════════════════ STEP 3 : RESET PASSWORD ══════════════════ + + /** + * Tampilkan form reset password (hanya jika OTP sudah diverifikasi) + */ + public function showResetForm(Request $request) + { + $email = $request->query('email'); + + if (!$email) { + return redirect()->route('admin.forgot.email_form'); + } + + // Pastikan OTP sudah diverifikasi + $verified = PasswordResetOtp::where('email', $email) + ->where('is_verified', true) + ->exists(); + + if (!$verified) { + return redirect()->route('admin.forgot.email_form') + ->withErrors(['email' => 'Silakan verifikasi OTP terlebih dahulu.']); + } + + return view('admin.auth.reset_password', compact('email')); + } + + /** + * Proses reset password + */ + public function resetPassword(Request $request) + { + $request->validate([ + 'email' => 'required|email', + 'password' => [ + 'required', + 'string', + 'min:8', + 'confirmed', + 'regex:/[A-Z]/', // minimal 1 huruf besar + 'regex:/[a-z]/', // minimal 1 huruf kecil + 'regex:/[0-9]/', // minimal 1 angka + 'regex:/[^A-Za-z0-9]/', // minimal 1 simbol + ], + ], [ + 'password.required' => 'Password baru wajib diisi.', + 'password.min' => 'Password minimal 8 karakter.', + 'password.confirmed' => 'Konfirmasi password tidak cocok.', + 'password.regex' => 'Password harus mengandung huruf besar, huruf kecil, angka, dan simbol.', + ]); + + // Cek ulang apakah OTP sudah terverifikasi + $verified = PasswordResetOtp::where('email', $request->email) + ->where('is_verified', true) + ->exists(); + + if (!$verified) { + return redirect()->route('admin.forgot.email_form') + ->withErrors(['email' => 'Sesi tidak valid. Silakan ulangi proses.']); + } + + // Update password user + $user = User::where('email', $request->email) + ->where('role', 'super_admin') + ->first(); + + if (!$user) { + return redirect()->route('admin.forgot.email_form') + ->withErrors(['email' => 'Akun tidak ditemukan.']); + } + + $user->password = Hash::make($request->password); + $user->save(); + + // Hapus semua record OTP untuk email ini + PasswordResetOtp::where('email', $request->email)->delete(); + + return redirect()->route('admin.login') + ->with('success', 'Password berhasil diubah! Silakan login dengan password baru.'); + } +} diff --git a/sim-pkpps/app/Http/Controllers/Auth/SantriAuthController.php b/sim-pkpps/app/Http/Controllers/Auth/SantriAuthController.php index 4060aa5..73709a8 100644 --- a/sim-pkpps/app/Http/Controllers/Auth/SantriAuthController.php +++ b/sim-pkpps/app/Http/Controllers/Auth/SantriAuthController.php @@ -1,26 +1,24 @@ check()) { + return redirect()->route('santri.dashboard'); + } + return view('santri.auth.login'); } - /** - * Proses login santri/wali dengan auto-clear session on failed - */ public function authenticate(Request $request) { $credentials = $request->validate([ @@ -31,63 +29,50 @@ public function authenticate(Request $request) 'password.required' => 'Password wajib diisi.', ]); - // βœ… TAMBAHAN 1: Clear old session data - $request->session()->forget(['login_attempts', 'last_attempt_time']); + $request->session()->forget(['login_attempts']); - // Coba login dengan guard default - if (Auth::attempt($credentials, $request->boolean('remember'))) { - $user = Auth::user(); - - // Cek apakah user adalah santri atau wali - if ($user->role === 'santri' || $user->role === 'wali') { - // βœ… TAMBAHAN 2: Regenerate & clear - $request->session()->regenerate(); - $request->session()->forget(['login_attempts', 'last_attempt_time']); - - return redirect()->intended(route('santri.dashboard')) - ->with('success', 'Selamat datang, ' . $user->name . '!'); - } - - // βœ… TAMBAHAN 3: Role tidak sesuai - clear session - Auth::logout(); - $request->session()->invalidate(); + if (Auth::guard('santri')->attempt($credentials, $request->boolean('remember'))) { $request->session()->regenerate(); - - return redirect()->back()->withErrors([ - 'username' => 'Akun Anda tidak memiliki akses ke halaman ini. Gunakan login Admin jika Anda admin.' - ])->withInput($request->except('password')); + + // Gunakan DB::table langsung β€” hindari masalah model cast/mutator + $account = Auth::guard('santri')->user(); + + DB::table('santri_accounts') + ->where('id', $account->id) + ->update(['last_login' => now()]); + + $nama = $account->santri + ? $account->santri->nama_lengkap + : $account->username; + + return redirect()->route('santri.dashboard') + ->with('success', 'Selamat datang, ' . $nama . '!'); } - // βœ… TAMBAHAN 4: Track & auto-flush $attempts = $request->session()->get('login_attempts', 0) + 1; $request->session()->put('login_attempts', $attempts); - $request->session()->put('last_attempt_time', now()); if ($attempts >= 3) { $request->session()->flush(); $request->session()->regenerate(); - + return redirect()->back()->withErrors([ - 'username' => 'Terlalu banyak percobaan login gagal. Session telah direset. Silakan coba lagi.' + 'username' => 'Terlalu banyak percobaan. Session direset, silakan coba lagi.', ])->withInput($request->except('password')); } throw ValidationException::withMessages([ - 'username' => "Login gagal (Percobaan ke-{$attempts}/3). Username/Password salah atau akun tidak terdaftar.", + 'username' => 'Login gagal (Percobaan ke-' . $attempts . '/3). Username atau password salah.', ]); } - /** - * Logout santri/wali - */ public function logout(Request $request) { - Auth::logout(); - + Auth::guard('santri')->logout(); $request->session()->invalidate(); $request->session()->regenerateToken(); - + return redirect()->route('santri.login') - ->with('success', 'Anda berhasil logout.'); + ->with('success', 'Berhasil logout.'); } } \ No newline at end of file diff --git a/sim-pkpps/app/Http/Controllers/DashboardController.php b/sim-pkpps/app/Http/Controllers/DashboardController.php index 947d9e3..460fc13 100644 --- a/sim-pkpps/app/Http/Controllers/DashboardController.php +++ b/sim-pkpps/app/Http/Controllers/DashboardController.php @@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; use App\Models\Santri; +use App\Models\SantriKelas; use App\Models\User; use App\Models\Kegiatan; use App\Models\AbsensiKegiatan; @@ -16,6 +17,7 @@ use App\Models\Kepulangan; use App\Models\PengajuanKepulangan; use App\Models\PembayaranSpp; +use App\Models\Keuangan; // ← TAMBAHAN: untuk data kas pondok use App\Models\UangSaku; use App\Models\Capaian; use App\Models\Semester; @@ -48,16 +50,23 @@ public function admin() $tahunIni = (int) $today->format('Y'); // ────────────────────────── KPI CARDS ────────────────────────── - $totalSantriAktif = Cache::remember('dash_santri_aktif', 300, fn () => Santri::aktif()->count()); + $user = Auth::user(); + $totalSantriAktif = Cache::remember('dash_santri_aktif', 300, function () { + return Santri::aktif()->count(); + }); // Kegiatan hari ini + status absensi - $kegiatanHariIni = Kegiatan::with(['kategori', 'absensis' => fn ($q) => $q->whereDate('tanggal', $today)]) + $kegiatanHariIni = Kegiatan::with(['kategori', 'absensis' => function ($q) use ($today) { + $q->whereDate('tanggal', $today); + }]) ->where('hari', $hariIni) ->orderBy('waktu_mulai') ->get(); $totalKegiatan = $kegiatanHariIni->count(); - $sudahAbsensi = $kegiatanHariIni->filter(fn ($k) => $k->absensis->isNotEmpty())->count(); + $sudahAbsensi = $kegiatanHariIni->filter(function ($k) { + return $k->absensis->isNotEmpty(); + })->count(); $belumAbsensi = $totalKegiatan - $sudahAbsensi; // Santri di UKP (sedang dirawat) @@ -66,10 +75,13 @@ public function admin() // Pengajuan kepulangan menunggu approval $kepulanganMenunggu = PengajuanKepulangan::where('status', 'Menunggu')->count(); - // Santri aktif yang belum punya akun wali - $santriTanpaWali = Santri::aktif() - ->whereDoesntHave('waliUser') - ->count(); + // Santri aktif yang belum punya akun wali (super_admin only) + $santriTanpaWali = 0; + if ($user->role === 'super_admin') { + $santriTanpaWali = Santri::aktif() + ->whereDoesntHave('waliUser') + ->count(); + } $kpiCards = compact( 'totalSantriAktif', 'totalKegiatan', 'sudahAbsensi', @@ -95,16 +107,19 @@ public function admin() }); // ────────────────────────── ALERT PANEL ────────────────────────── - // 1) Santri alpa beruntun (β‰₯3 hari berturut-turut dalam 7 hari terakhir) + // 1) Santri alpa beruntun (semua role bisa lihat) $santriAlpaBeruntun = $this->getSantriAlpaBeruntun(); - // 2) SPP jatuh tempo (belum lunas & batas_bayar sudah lewat) - $sppJatuhTempo = PembayaranSpp::telat() - ->with('santri:id_santri,nama_lengkap') - ->select('id_pembayaran', 'id_santri', 'bulan', 'tahun', 'nominal', 'batas_bayar') - ->orderBy('batas_bayar') - ->limit(10) - ->get(); + // 2) SPP jatuh tempo (super_admin only) + $sppJatuhTempo = collect([]); + if ($user->role === 'super_admin') { + $sppJatuhTempo = PembayaranSpp::telat() + ->with('santri:id_santri,nama_lengkap') + ->select('id_pembayaran', 'id_santri', 'bulan', 'tahun', 'nominal', 'batas_bayar') + ->orderBy('batas_bayar') + ->limit(10) + ->get(); + } // 3) Pengajuan kepulangan menunggu review $kepulanganPending = PengajuanKepulangan::where('status', 'Menunggu') @@ -119,22 +134,45 @@ public function admin() // ──────────────── GRAFIK TREN KEHADIRAN (4 MINGGU) ──────────────── $trenKehadiran = $this->getTrenKehadiran($today); - // ──────────────── RINGKASAN SPP BULAN INI ──────────────── - $sppBulanIni = Cache::remember("dash_spp_{$bulanIni}_{$tahunIni}", 300, function () use ($bulanIni, $tahunIni) { - $lunas = PembayaranSpp::where('bulan', $bulanIni)->where('tahun', $tahunIni)->lunas()->count(); - $belum = PembayaranSpp::where('bulan', $bulanIni)->where('tahun', $tahunIni)->belumLunas()->count(); - $terkumpul = PembayaranSpp::where('bulan', $bulanIni)->where('tahun', $tahunIni)->lunas()->sum('nominal'); - $totalTagihan = PembayaranSpp::where('bulan', $bulanIni)->where('tahun', $tahunIni)->sum('nominal'); + // ──────────────── RINGKASAN SPP + KEUANGAN BULAN INI ───────────── + // Default (untuk non super_admin atau jika query gagal) + $sppBulanIni = [ + 'lunas' => 0, + 'belum' => 0, + 'terkumpul' => 0, + 'totalTagihan' => 0, + 'pemasukanLain' => 0, // pemasukan kas pondok selain SPP + 'pengeluaran' => 0, // pengeluaran kas pondok + ]; - return compact('lunas', 'belum', 'terkumpul', 'totalTagihan'); - }); + 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'); - // ──────────────── FEED AKTIVITAS TERBARU ──────────────── - $feedAktivitas = $this->getFeedAktivitas($today); + // ── Data Keuangan Pondok (non-SPP) ── + $pemasukanLain = (float) Keuangan::pemasukan() + ->whereMonth('tanggal', $bulanIni) + ->whereYear('tanggal', $tahunIni) + ->sum('nominal'); + + $pengeluaran = (float) Keuangan::pengeluaran() + ->whereMonth('tanggal', $bulanIni) + ->whereYear('tanggal', $tahunIni) + ->sum('nominal'); + + return compact('lunas', 'belum', 'terkumpul', 'totalTagihan', 'pemasukanLain', 'pengeluaran'); + }); + } return view('admin.dashboardAdmin', compact( 'kpiCards', 'kegiatanHariIni', 'alerts', - 'trenKehadiran', 'sppBulanIni', 'feedAktivitas', + 'trenKehadiran', 'sppBulanIni', 'hariIni', 'today' )); @@ -156,7 +194,6 @@ private function getSantriAlpaBeruntun(int $threshold = 3): \Illuminate\Support\ { $weekAgo = Carbon::today()->subDays(7); - // Ambil data alpa per santri 7 hari terakhir $alpaData = AbsensiKegiatan::where('status', 'Alpa') ->whereDate('tanggal', '>=', $weekAgo) ->select('id_santri') @@ -190,7 +227,6 @@ private function getTrenKehadiran(Carbon $today): array $kategoris = KategoriKegiatan::select('kategori_id', 'nama_kategori')->get(); - // 4 minggu terakhir β†’ label "Mg 1" s.d "Mg 4" for ($i = 3; $i >= 0; $i--) { $start = $today->copy()->subWeeks($i)->startOfWeek(Carbon::MONDAY); $end = $start->copy()->endOfWeek(Carbon::SUNDAY); @@ -216,13 +252,12 @@ private function getTrenKehadiran(Carbon $today): array } /** - * Feed aktivitas terbaru: absensi, pelanggaran, pembayaran SPP, transaksi uang saku + * Feed aktivitas terbaru */ private function getFeedAktivitas(Carbon $today): \Illuminate\Support\Collection { $items = collect(); - // Absensi terbaru AbsensiKegiatan::with(['santri:id_santri,nama_lengkap', 'kegiatan:kegiatan_id,nama_kegiatan']) ->whereDate('tanggal', $today) ->orderByDesc('created_at') @@ -235,7 +270,6 @@ private function getFeedAktivitas(Carbon $today): \Illuminate\Support\Collection 'time' => $a->created_at, ])); - // Pelanggaran terbaru (7 hari) RiwayatPelanggaran::with(['santri:id_santri,nama_lengkap', 'kategori:id_kategori,nama_pelanggaran']) ->whereDate('tanggal', '>=', $today->copy()->subDays(7)) ->terbaru() @@ -248,7 +282,6 @@ private function getFeedAktivitas(Carbon $today): \Illuminate\Support\Collection 'time' => $p->created_at, ])); - // Pembayaran SPP terbaru (7 hari) PembayaranSpp::with('santri:id_santri,nama_lengkap') ->lunas() ->whereNotNull('tanggal_bayar') @@ -267,183 +300,166 @@ private function getFeedAktivitas(Carbon $today): \Illuminate\Support\Collection } /** - * Dashboard Santri/Wali - FIXED VERSION βœ… + * Dashboard Santri */ public function santri() { try { - $user = Auth::user(); - + $account = auth('santri')->user(); + Log::info('=== DASHBOARD SANTRI START ==='); - Log::info('User ID: ' . $user->id); - Log::info('Role: ' . $user->role); - Log::info('Role ID: ' . $user->role_id); - - // Validasi role - if (!in_array($user->role, ['santri', 'wali'])) { - Log::error('Role tidak sesuai: ' . $user->role); - abort(403, 'Akses ditolak. Role Anda: ' . $user->role); - } - - // βœ… Ambil data santri - $santri = Santri::where('id_santri', $user->role_id) - ->select('id_santri', 'nama_lengkap', 'kelas') + Log::info('Account ID: ' . $account->id); + Log::info('Role: ' . $account->role); + Log::info('ID Santri: ' . $account->id_santri); + + $santri = Santri::with([ + 'kelasPrimary.kelas.kelompok', + ]) + ->where('id_santri', $account->id_santri) + ->select('id_santri', 'nama_lengkap') ->first(); - + if (!$santri) { - Log::error('Santri tidak ditemukan dengan role_id: ' . $user->role_id); + Log::error('Santri tidak ditemukan dengan id_santri: ' . $account->id_santri); abort(404, 'Data santri tidak ditemukan.'); } - + Log::info('Santri ditemukan: ' . $santri->nama_lengkap); - + + $namaKelas = $santri->kelas; $idSantri = $santri->id_santri; $today = Carbon::today(); $weekAgo = Carbon::now()->subDays(7); - - // βœ… Ambil semester aktif dengan FALLBACK + + // Ambil semester aktif dengan FALLBACK $semesterAktif = null; try { $semesterAktif = Semester::aktif() ->select('id_semester', 'nama_semester', 'tahun_ajaran') ->first(); - + if (!$semesterAktif) { $semesterAktif = Semester::select('id_semester', 'nama_semester', 'tahun_ajaran') ->orderBy('tahun_ajaran', 'desc') ->orderBy('periode', 'desc') ->first(); } - + Log::info('Semester aktif: ' . ($semesterAktif ? $semesterAktif->nama_semester : 'Tidak ada')); } catch (\Exception $e) { Log::warning('Error mengambil semester: ' . $e->getMessage()); $semesterAktif = null; } - - // βœ… AMBIL PROGRES AL-QUR'AN dengan FALLBACK + + // Progres Al-Qur'an $progresAlquran = 0; try { $query = Capaian::where('id_santri', $idSantri); - if ($semesterAktif) { $query->where('id_semester', $semesterAktif->id_semester); } - - $progresAlquran = $query->whereHas('materi', function($q) { + $progresAlquran = $query->whereHas('materi', function ($q) { $q->where('kategori', 'Al-Qur\'an'); })->avg('persentase') ?? 0; - + Log::info('Progres Al-Quran: ' . $progresAlquran); } catch (\Exception $e) { Log::warning('Error progres Al-Quran: ' . $e->getMessage()); - $progresAlquran = 0; } - - // βœ… AMBIL PROGRES HADIST dengan FALLBACK + + // Progres Hadist $progresHadist = 0; try { $query = Capaian::where('id_santri', $idSantri); - if ($semesterAktif) { $query->where('id_semester', $semesterAktif->id_semester); } - - $progresHadist = $query->whereHas('materi', function($q) { + $progresHadist = $query->whereHas('materi', function ($q) { $q->where('kategori', 'Hadist'); })->avg('persentase') ?? 0; - + Log::info('Progres Hadist: ' . $progresHadist); } catch (\Exception $e) { Log::warning('Error progres Hadist: ' . $e->getMessage()); - $progresHadist = 0; } - - // βœ… AMBIL PROGRES MATERI TAMBAHAN dengan FALLBACK + + // Progres Materi Tambahan $progresMateriTambahan = 0; try { $query = Capaian::where('id_santri', $idSantri); - if ($semesterAktif) { $query->where('id_semester', $semesterAktif->id_semester); } - - $progresMateriTambahan = $query->whereHas('materi', function($q) { + $progresMateriTambahan = $query->whereHas('materi', function ($q) { $q->where('kategori', 'Materi Tambahan'); })->avg('persentase') ?? 0; - + Log::info('Progres Materi Tambahan: ' . $progresMateriTambahan); } catch (\Exception $e) { Log::warning('Error progres Materi Tambahan: ' . $e->getMessage()); - $progresMateriTambahan = 0; } - - // βœ… DATA UNTUK GRAFIK 1: Progress per Materi dengan FALLBACK + + // Data untuk grafik: Progress per Materi $capaianPerMateri = collect([]); try { - $query = Capaian::with(['materi' => function($q) { - $q->select('id_materi', 'nama_kitab', 'kategori', 'total_halaman'); - }]) + $query = Capaian::with(['materi' => function ($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 2: Distribusi Status dengan FALLBACK + + // Data untuk grafik: Distribusi Status $distribusiStatus = [ 'selesai' => 0, 'hampir_selesai' => 0, 'sedang_berjalan' => 0, 'baru_dimulai' => 0, ]; - try { $baseQuery = Capaian::where('id_santri', $idSantri); - if ($semesterAktif) { $baseQuery->where('id_semester', $semesterAktif->id_semester); } - + $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 dashboard utama + $data = [ - 'nama_santri' => $santri->nama_lengkap, - 'kelas' => $santri->kelas, - 'progres_quran' => round($progresAlquran, 1), - 'progres_hadist' => round($progresHadist, 1), + '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' => method_exists($santri, 'getSaldoUangSakuAttribute') - ? $santri->saldo_uang_saku - : 0, - 'poin_pelanggaran' => RiwayatPelanggaran::where('id_santri', $idSantri)->sum('poin') ?? 0, + 'saldo_uang_saku' => $santri->saldo_uang_saku ?? 0, + 'poin_pelanggaran' => RiwayatPelanggaran::where('id_santri', $idSantri)->sum('poin') ?? 0, ]; - + Log::info('Data array: ' . json_encode($data)); - - // βœ… Query status kesehatan dengan FALLBACK + + // Status kesehatan $statusKesehatan = null; try { $statusKesehatan = KesehatanSantri::where('id_santri', $idSantri) @@ -454,8 +470,8 @@ public function santri() } catch (\Exception $e) { Log::warning('Error status kesehatan: ' . $e->getMessage()); } - - // βœ… Query kepulangan aktif dengan FALLBACK + + // Kepulangan aktif $kepulanganAktif = null; try { $kepulanganAktif = Kepulangan::where('id_santri', $idSantri) @@ -467,37 +483,36 @@ public function santri() } catch (\Exception $e) { Log::warning('Error kepulangan aktif: ' . $e->getMessage()); } - - // βœ… Query berita terbaru dengan FALLBACK + + // Berita terbaru $beritaTerbaru = collect([]); try { $beritaTerbaru = Berita::select('id_berita', 'judul', 'created_at') ->where('status', 'published') ->where('created_at', '>=', $weekAgo) - ->where(function($query) use ($santri) { + ->where(function ($query) use ($namaKelas) { $query->where('target_berita', 'semua') - ->orWhere(function($q) use ($santri) { + ->orWhere(function ($q) use ($namaKelas) { $q->where('target_berita', 'kelas_tertentu') - ->whereJsonContains('target_kelas', $santri->kelas); + ->whereJsonContains('target_kelas', $namaKelas); }); }) ->orderBy('created_at', 'desc') ->limit(5) ->get(); - + Log::info('Berita terbaru: ' . $beritaTerbaru->count() . ' items'); } catch (\Exception $e) { Log::warning('Error berita terbaru: ' . $e->getMessage()); $beritaTerbaru = collect([]); } - + Log::info('=== DASHBOARD SANTRI SUCCESS ==='); - - // Return view dengan semua data + return view('santri.dashboardSantri', compact( 'data', 'santri', - 'user', + 'account', 'beritaTerbaru', 'statusKesehatan', 'kepulanganAktif', @@ -505,20 +520,18 @@ public function santri() 'distribusiStatus', 'semesterAktif' )); - + } catch (\Exception $e) { Log::error('=== FATAL ERROR DI DASHBOARD SANTRI ==='); Log::error('Message: ' . $e->getMessage()); Log::error('File: ' . $e->getFile()); Log::error('Line: ' . $e->getLine()); Log::error('Trace: ' . $e->getTraceAsString()); - - // Tampilkan error detail jika debug mode + if (config('app.debug')) { abort(500, 'Error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()); - } else { - abort(500, 'Terjadi kesalahan saat memuat dashboard. Silakan hubungi administrator.'); } + abort(500, 'Terjadi kesalahan saat memuat dashboard. Silakan hubungi administrator.'); } } } \ No newline at end of file diff --git a/sim-pkpps/app/Http/Controllers/Santri/RiwayatKegiatanSantriController.php b/sim-pkpps/app/Http/Controllers/Santri/RiwayatKegiatanSantriController.php index a61d9e7..5038109 100644 --- a/sim-pkpps/app/Http/Controllers/Santri/RiwayatKegiatanSantriController.php +++ b/sim-pkpps/app/Http/Controllers/Santri/RiwayatKegiatanSantriController.php @@ -7,172 +7,365 @@ use App\Models\Kegiatan; use App\Models\Santri; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Carbon\Carbon; class RiwayatKegiatanSantriController extends Controller { + private function getSantriId() + { + return auth('santri')->user()->id_santri; + } + /** - * Halaman utama: Jadwal Harian + Riwayat Absensi + * Resolve date range. + * Jadwal & Riwayat default: today + * Statistik default: this_week */ + private function resolveDateRange(Request $request, string $defaultPreset = 'today'): array + { + $preset = $request->input('preset', $defaultPreset); + $now = Carbon::now(); + + switch ($preset) { + case 'today': + return [$now->copy()->startOfDay(), $now->copy()->endOfDay(), 'today']; + case 'this_week': + return [$now->copy()->startOfWeek(), $now->copy()->endOfWeek(), 'this_week']; + case 'last_30': + return [$now->copy()->subDays(29)->startOfDay(), $now->copy()->endOfDay(), 'last_30']; + case 'this_month': + return [$now->copy()->startOfMonth(), $now->copy()->endOfMonth(), 'this_month']; + case 'last_month': + $lm = $now->copy()->subMonth(); + return [$lm->copy()->startOfMonth(), $lm->copy()->endOfMonth(), 'last_month']; + default: + // custom + $from = $request->filled('date_from') + ? Carbon::parse($request->date_from)->startOfDay() + : $now->copy()->startOfDay(); + $to = $request->filled('date_to') + ? Carbon::parse($request->date_to)->endOfDay() + : $now->copy()->endOfDay(); + if ($from->gt($to)) [$from, $to] = [$to, $from]; + return [$from, $to, 'custom']; + } + } + public function index(Request $request) { - $user = Auth::user(); - - if ($user->role !== 'santri') { - abort(403, 'Akses ditolak.'); - } - - $santri = Santri::where('id_santri', $user->role_id) - ->select('id_santri', 'nama_lengkap', 'kelas') + $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(); - - $idSantri = $santri->id_santri; - $today = Carbon::today(); - $hariIni = Carbon::now()->locale('id')->dayName; // Senin, Selasa, etc. - - // βœ… JADWAL KEGIATAN HARI INI (Tetap) - $jadwalHariIni = Kegiatan::with('kategori') - ->where('hari', ucfirst($hariIni)) - ->select('kegiatan_id', 'kategori_id', 'nama_kegiatan', 'waktu_mulai', 'waktu_selesai', 'materi') - ->orderBy('waktu_mulai') - ->get(); - - // βœ… CEK STATUS ABSENSI HARI INI - $absensiHariIni = AbsensiKegiatan::where('id_santri', $idSantri) - ->whereDate('tanggal', $today) - ->pluck('status', 'kegiatan_id') - ->toArray(); - - // βœ… RIWAYAT ABSENSI (dengan filter) - $query = AbsensiKegiatan::with('kegiatan.kategori') - ->where('id_santri', $idSantri); - - // Filter Bulan - if ($request->filled('bulan')) { - $bulan = Carbon::parse($request->bulan); - $query->whereMonth('tanggal', $bulan->month) - ->whereYear('tanggal', $bulan->year); + + $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 + $statPresetReq = $request->input('preset_stat', $request->input('preset', 'this_week')); + [$statFrom, $statTo, $statPreset] = $this->resolveDateRange( + $request->merge(['preset' => $statPresetReq, + 'date_from' => $request->input('stat_date_from'), + 'date_to' => $request->input('stat_date_to')]), + 'this_week' + ); + if ($statPreset === 'custom') { + $statFrom = $request->filled('stat_date_from') ? Carbon::parse($request->stat_date_from)->startOfDay() : $statFrom; + $statTo = $request->filled('stat_date_to') ? Carbon::parse($request->stat_date_to)->endOfDay() : $statTo; } - - // Filter Status - if ($request->filled('status')) { - $query->where('status', $request->status); - } - - $riwayats = $query->orderBy('tanggal', 'desc') - ->orderBy('waktu_absen', 'desc') - ->paginate(15) - ->appends(request()->query()); - - // βœ… STATISTIK KEHADIRAN (30 HARI TERAKHIR) - $stats30Hari = AbsensiKegiatan::where('id_santri', $idSantri) - ->whereDate('tanggal', '>=', Carbon::now()->subDays(30)) + + // Jadwal range + $jadPresetReq = $request->input('preset_jad', $request->input('preset', 'today')); + [$jadFrom, $jadTo, $jadPreset] = $this->resolveDateRange( + $request->merge(['preset' => $jadPresetReq, + 'date_from' => $request->input('jad_date_from'), + 'date_to' => $request->input('jad_date_to')]), + 'today' + ); + + // 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 -- + $hariMapDb = [ + 'Senin' => 'Senin', 'Selasa' => 'Selasa', 'Rabu' => 'Rabu', + 'Kamis' => 'Kamis', 'Jumat' => 'Jumat', 'Sabtu' => 'Sabtu', + 'Minggu' => 'Ahad', + ]; + $hariCarbon = Carbon::now()->locale('id')->dayName; + $hariIni = $hariMapDb[$hariCarbon] ?? $hariCarbon; + + // ── KPI stats (pakai stat range) ────────────────────────────────── + $statFromStr = $statFrom->format('Y-m-d'); + $statToStr = $statTo->format('Y-m-d'); + + $statsRange = AbsensiKegiatan::where('id_santri', $idSantri) + ->whereBetween('tanggal', [$statFromStr, $statToStr]) ->select('status', DB::raw('count(*) as total')) ->groupBy('status') ->pluck('total', 'status') ->toArray(); - - $totalKegiatan30Hari = array_sum($stats30Hari); - $persentaseKehadiran = $totalKegiatan30Hari > 0 - ? round(($stats30Hari['Hadir'] ?? 0) / $totalKegiatan30Hari * 100, 1) - : 0; - - // βœ… DATA GRAFIK: Kehadiran per Minggu (4 Minggu Terakhir) - $dataGrafikMingguan = []; - for ($i = 3; $i >= 0; $i--) { - $startWeek = Carbon::now()->subWeeks($i)->startOfWeek(); - $endWeek = Carbon::now()->subWeeks($i)->endOfWeek(); - - $hadir = AbsensiKegiatan::where('id_santri', $idSantri) - ->whereBetween('tanggal', [$startWeek, $endWeek]) - ->where('status', 'Hadir') - ->count(); - - $total = AbsensiKegiatan::where('id_santri', $idSantri) - ->whereBetween('tanggal', [$startWeek, $endWeek]) - ->count(); - - $dataGrafikMingguan[] = [ - 'minggu' => 'Minggu ' . (4 - $i), - 'hadir' => $hadir, - 'total' => $total, - 'persentase' => $total > 0 ? round($hadir / $total * 100, 1) : 0, - ]; + + $totalRange = array_sum($statsRange); + $hadirRange = $statsRange['Hadir'] ?? 0; + $izinRange = $statsRange['Izin'] ?? 0; + $sakitRange = $statsRange['Sakit'] ?? 0; + $alpaRange = $statsRange['Alpa'] ?? 0; + $persentaseKehadiran = $totalRange > 0 ? round($hadirRange / $totalRange * 100, 1) : 0; + + // ── JADWAL ──────────────────────────────────────────────────────── + $hariDalamRange = []; + $cursor = $jadFrom->copy(); + while ($cursor->lte($jadTo)) { + $hariDb = $hariMapDb[$cursor->locale('id')->dayName] ?? $cursor->locale('id')->dayName; + $hariDalamRange[$hariDb] = true; + $cursor->addDay(); } - - // βœ… STATISTIK PER KATEGORI KEGIATAN - $statsByKategori = AbsensiKegiatan::where('id_santri', $idSantri) + $hariDalamRange = array_keys($hariDalamRange); + + $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 + $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 = 0; + AbsensiKegiatan::where('id_santri', $idSantri) + ->orderByDesc('tanggal')->orderByDesc('waktu_absen') + ->select('status')->limit(60) + ->each(function($a) use (&$streak) { + if ($a->status === 'Hadir') $streak++; + else return false; + }); + + // ── GRAFIK TREN (stat range) ────────────────────────────────────── + $diffDays = $statFrom->diffInDays($statTo); + $dataGrafik = []; + + if ($diffDays <= 31) { + $cur = $statFrom->copy(); + while ($cur->lte($statTo)) { + $d = $cur->format('Y-m-d'); + $hadir = AbsensiKegiatan::where('id_santri', $idSantri)->whereDate('tanggal', $d)->where('status', 'Hadir')->count(); + $total = AbsensiKegiatan::where('id_santri', $idSantri)->whereDate('tanggal', $d)->count(); + $dataGrafik[] = ['label' => $cur->format('d/m'), 'hadir' => $hadir, 'total' => $total]; + $cur->addDay(); + } + } else { + $cur = $statFrom->copy()->startOfWeek(); + while ($cur->lte($statTo)) { + $wStart = $cur->copy()->max($statFrom); + $wEnd = $cur->copy()->endOfWeek()->min($statTo); + $hadir = AbsensiKegiatan::where('id_santri', $idSantri)->whereBetween('tanggal', [$wStart->format('Y-m-d'), $wEnd->format('Y-m-d')])->where('status', 'Hadir')->count(); + $total = AbsensiKegiatan::where('id_santri', $idSantri)->whereBetween('tanggal', [$wStart->format('Y-m-d'), $wEnd->format('Y-m-d')])->count(); + $dataGrafik[] = ['label' => $wStart->format('d/m') . '–' . $wEnd->format('d/m'), 'hadir' => $hadir, 'total' => $total]; + $cur->addWeek(); + } + } + + // ── CONSISTENCY SCORE per KEGIATAN (stat range) ─────────────────── + // Score = % hadir, dengan label badge berdasarkan level + $consistencyScores = AbsensiKegiatan::where('absensi_kegiatans.id_santri', $idSantri) + ->whereBetween('absensi_kegiatans.tanggal', [$statFromStr, $statToStr]) ->join('kegiatans', 'absensi_kegiatans.kegiatan_id', '=', 'kegiatans.kegiatan_id') ->join('kategori_kegiatans', 'kegiatans.kategori_id', '=', 'kategori_kegiatans.kategori_id') ->select( + 'kegiatans.kegiatan_id', + 'kegiatans.nama_kegiatan', 'kategori_kegiatans.nama_kategori', + DB::raw('COUNT(*) as total'), DB::raw('SUM(CASE WHEN absensi_kegiatans.status = "Hadir" THEN 1 ELSE 0 END) as hadir'), - DB::raw('COUNT(*) as total') + DB::raw('SUM(CASE WHEN absensi_kegiatans.status = "Alpa" THEN 1 ELSE 0 END) as alpa'), + DB::raw('SUM(CASE WHEN absensi_kegiatans.status IN ("Izin","Sakit") THEN 1 ELSE 0 END) as dispensasi') ) - ->groupBy('kategori_kegiatans.nama_kategori') - ->get(); - + ->groupBy('kegiatans.kegiatan_id', 'kegiatans.nama_kegiatan', 'kategori_kegiatans.nama_kategori') + ->get() + ->map(function ($row) { + $score = $row->total > 0 ? round($row->hadir / $row->total * 100) : 0; + // Badge tier + if ($score >= 90) { $badge = 'Konsisten'; $tier = 'top'; } + elseif ($score >= 75) { $badge = 'Baik'; $tier = 'good'; } + elseif ($score >= 60) { $badge = 'Cukup'; $tier = 'fair'; } + elseif ($score >= 40) { $badge = 'Perlu Perhatian'; $tier = 'warn'; } + else { $badge = 'Kritis'; $tier = 'crit'; } + $row->score = $score; + $row->badge = $badge; + $row->tier = $tier; + return $row; + }) + ->sortByDesc('score') + ->values(); + + // ── HEATMAP: kalender bulan aktif (stat range, max tampil 1 bulan) ─ + // Kita buat kalender bulan-bulan dalam stat range, dengan angka tanggal + $heatmapMonths = []; + $cur = $statFrom->copy()->startOfMonth(); + while ($cur->lte($statTo)) { + $monthKey = $cur->format('Y-m'); + $daysInMonth = $cur->daysInMonth; + $firstDayOfWeek = $cur->copy()->startOfMonth()->dayOfWeekIso; // 1=Mon..7=Sun + + $days = []; + for ($d = 1; $d <= $daysInMonth; $d++) { + $date = $cur->format('Y-m') . '-' . str_pad($d, 2, '0', STR_PAD_LEFT); + $rows = AbsensiKegiatan::where('id_santri', $idSantri)->whereDate('tanggal', $date)->get(); + $level = 0; + if ($rows->count() > 0) { + $pct = round($rows->where('status', 'Hadir')->count() / $rows->count() * 100); + $level = $pct >= 90 ? 4 : ($pct >= 70 ? 3 : ($pct >= 50 ? 2 : 1)); + } + $days[] = [ + 'day' => $d, + 'date' => $date, + 'level' => $level, + 'count' => $rows->where('status', 'Hadir')->count(), + 'total' => $rows->count(), + 'is_today' => $date === Carbon::today()->format('Y-m-d'), + 'in_range' => $date >= $statFromStr && $date <= $statToStr, + ]; + } + + $heatmapMonths[] = [ + 'label' => $cur->locale('id')->isoFormat('MMMM YYYY'), + 'firstDayOfWeek'=> $firstDayOfWeek, + 'days' => $days, + ]; + + $cur->addMonth(); + } + + $kategoriList = \App\Models\KategoriKegiatan::select('kategori_id', 'nama_kategori')->get(); + return view('santri.kegiatan.index', compact( - 'santri', - 'jadwalHariIni', - 'absensiHariIni', - 'riwayats', - 'stats30Hari', - 'totalKegiatan30Hari', - 'persentaseKehadiran', - 'dataGrafikMingguan', - 'statsByKategori', - 'hariIni' + 'santri', 'namaKelas', + 'jadwalDalamRange', 'absensiDalamRange', 'absensiHariIni', 'hariIni', + 'jadPreset', 'jadFrom', 'jadTo', + 'riwayats', 'riwPreset', 'riwFrom', 'riwTo', + 'statsRange', 'totalRange', 'hadirRange', 'izinRange', 'sakitRange', 'alpaRange', + 'persentaseKehadiran', 'streak', + 'dataGrafik', 'statPreset', 'statFrom', 'statTo', 'statFromStr', 'statToStr', 'diffDays', + 'consistencyScores', + 'heatmapMonths', + 'kategoriList', + 'activeTab', 'hariIni' )); } - - /** - * Detail Riwayat Absensi per Kegiatan - */ - public function show($kegiatan_id) + + public function show($kegiatan_id, Request $request) { - $user = Auth::user(); - - if ($user->role !== 'santri') { - abort(403, 'Akses ditolak.'); - } - - $santri = Santri::where('id_santri', $user->role_id) - ->select('id_santri', 'nama_lengkap') + $idSantri = $this->getSantriId(); + + $santri = Santri::where('id_santri', $idSantri) + ->with(['kelasPrimary.kelas']) + ->select('id_santri', 'nama_lengkap', 'nis', 'status') ->firstOrFail(); - + $kegiatan = Kegiatan::with('kategori') ->where('kegiatan_id', $kegiatan_id) ->firstOrFail(); - - // Riwayat absensi untuk kegiatan ini + $riwayats = AbsensiKegiatan::where('id_santri', $santri->id_santri) ->where('kegiatan_id', $kegiatan_id) ->orderBy('tanggal', 'desc') ->paginate(20); - - // Statistik kehadiran untuk kegiatan ini + $stats = AbsensiKegiatan::where('id_santri', $santri->id_santri) ->where('kegiatan_id', $kegiatan_id) ->select('status', DB::raw('count(*) as total')) ->groupBy('status') ->pluck('total', 'status') ->toArray(); - - $totalAbsensi = array_sum($stats); - $persentaseHadir = $totalAbsensi > 0 - ? round(($stats['Hadir'] ?? 0) / $totalAbsensi * 100, 1) - : 0; - + + $totalAbsensi = array_sum($stats); + $persentaseHadir = $totalAbsensi > 0 + ? round(($stats['Hadir'] ?? 0) / $totalAbsensi * 100, 1) : 0; + + $trendBulanan = []; + for ($i = 5; $i >= 0; $i--) { + $bulan = Carbon::now()->subMonths($i); + $data = AbsensiKegiatan::where('id_santri', $idSantri) + ->where('kegiatan_id', $kegiatan_id) + ->whereMonth('tanggal', $bulan->month) + ->whereYear('tanggal', $bulan->year) + ->select('status', DB::raw('count(*) as total')) + ->groupBy('status') + ->pluck('total', 'status') + ->toArray(); + $trendBulanan[] = [ + 'bulan' => $bulan->locale('id')->isoFormat('MMM YY'), + 'hadir' => $data['Hadir'] ?? 0, + 'total' => array_sum($data), + ]; + } + + // Referrer tab untuk tombol kembali + $fromTab = $request->input('from_tab', 'riwayat'); + return view('santri.kegiatan.show', compact( - 'santri', - 'kegiatan', - 'riwayats', - 'stats', - 'totalAbsensi', - 'persentaseHadir' + 'santri', 'kegiatan', 'riwayats', + 'stats', 'totalAbsensi', 'persentaseHadir', + 'trendBulanan', 'fromTab' )); } } \ No newline at end of file diff --git a/sim-pkpps/app/Http/Controllers/Santri/SantriBeritaController.php b/sim-pkpps/app/Http/Controllers/Santri/SantriBeritaController.php index 888b460..5b86928 100644 --- a/sim-pkpps/app/Http/Controllers/Santri/SantriBeritaController.php +++ b/sim-pkpps/app/Http/Controllers/Santri/SantriBeritaController.php @@ -1,4 +1,5 @@ user()->id_santri; + } + /** * Tampilkan daftar berita yang bisa diakses santri */ public function index(Request $request) { - $user = Auth::user(); + $idSantri = $this->getSantriId(); - $santri = Santri::where('id_santri', $user->role_id) + $santri = Santri::where('id_santri', $idSantri) ->select('id_santri') ->firstOrFail(); - // Ambil id kelas santri + // -- Ambil id kelas santri -- $kelasIds = SantriKelas::where('id_santri', $santri->id_santri) ->pluck('id_kelas')->toArray(); @@ -52,9 +58,9 @@ public function index(Request $request) */ public function show($id_berita) { - $user = Auth::user(); + $idSantri = $this->getSantriId(); - $santri = Santri::where('id_santri', $user->role_id) + $santri = Santri::where('id_santri', $idSantri) ->select('id_santri') ->firstOrFail(); diff --git a/sim-pkpps/app/Http/Controllers/Santri/SantriCapaianController.php b/sim-pkpps/app/Http/Controllers/Santri/SantriCapaianController.php index f71fc44..b4205bb 100644 --- a/sim-pkpps/app/Http/Controllers/Santri/SantriCapaianController.php +++ b/sim-pkpps/app/Http/Controllers/Santri/SantriCapaianController.php @@ -1,4 +1,5 @@ user()->id_santri; + } + public function index(Request $request) { - $user = Auth::user(); - - // Validasi role - if (!in_array($user->role, ['santri', 'wali'])) { - abort(403, 'Unauthorized access'); - } - - $idSantri = $user->role_id; - - // Cache data santri selama 10 menit - $santri = Cache::remember("santri_capaian_{$idSantri}", 600, function () use ($idSantri) { + $idSantri = $this->getSantriId(); + + // Ambil data santri + $santri = Cache::remember("santri_{$idSantri}_profile", 600, function () use ($idSantri) { return Santri::where('id_santri', $idSantri) - ->select('id_santri', 'nama_lengkap', 'kelas', 'nis') + ->with(['kelasPrimary.kelas']) + ->select('id_santri', 'nama_lengkap', 'nis', 'status') ->firstOrFail(); }); - - // Get semester aktif - $semesterAktif = Semester::aktif()->first(); - $selectedSemester = $request->input('id_semester', $semesterAktif?->id_semester); - - // Query capaian dengan relasi - $query = Capaian::with(['materi:id_materi,nama_kitab,kategori,total_halaman', 'semester:id_semester,nama_semester']) + + $semesterAktif = Semester::aktif()->first(); + $selectedSemester = $request->input('id_semester', + $semesterAktif ? $semesterAktif->id_semester : null + ); + + // Capaian untuk tab Ringkasan / Daftar / Grafik (filter semester) + $query = Capaian::with([ + 'materi:id_materi,nama_kitab,kategori,total_halaman,halaman_mulai,halaman_akhir', + 'semester:id_semester,nama_semester', + ]) ->where('id_santri', $idSantri) - ->select('id', 'id_capaian', 'id_santri', 'id_materi', 'id_semester', 'halaman_selesai', 'persentase', 'tanggal_input'); - - // Filter semester + ->select('id', 'id_capaian', 'id_santri', 'id_materi', + 'id_semester', 'halaman_selesai', 'persentase', 'tanggal_input'); + if ($selectedSemester) { $query->where('id_semester', $selectedSemester); } - + $capaians = $query->orderBy('tanggal_input', 'desc')->get(); - - // Statistik Umum - $totalCapaian = $capaians->count(); + + // Statistik umum + $totalCapaian = $capaians->count(); $rataRataPersentase = $capaians->avg('persentase') ?? 0; - $materiSelesai = $capaians->where('persentase', '>=', 100)->count(); - - // Statistik per Kategori + $materiSelesai = $capaians->where('persentase', '>=', 100)->count(); + + // Statistik per kategori $statistikKategori = [ - 'Al-Qur\'an' => [ - 'count' => 0, - 'avg' => 0, - 'selesai' => 0, - ], - 'Hadist' => [ - 'count' => 0, - 'avg' => 0, - 'selesai' => 0, - ], - 'Materi Tambahan' => [ - 'count' => 0, - 'avg' => 0, - 'selesai' => 0, - ], + "Al-Qur'an" => ['count' => 0, 'avg' => 0, 'selesai' => 0], + 'Hadist' => ['count' => 0, 'avg' => 0, 'selesai' => 0], + 'Materi Tambahan' => ['count' => 0, 'avg' => 0, 'selesai' => 0], ]; - + foreach ($capaians as $capaian) { - $kategori = $capaian->materi->kategori; - $statistikKategori[$kategori]['count']++; - $statistikKategori[$kategori]['avg'] += $capaian->persentase; - if ($capaian->persentase >= 100) { - $statistikKategori[$kategori]['selesai']++; - } + $kat = $capaian->materi->kategori ?? 'Materi Tambahan'; + if (!isset($statistikKategori[$kat])) continue; + $statistikKategori[$kat]['count']++; + $statistikKategori[$kat]['avg'] += $capaian->persentase; + if ($capaian->persentase >= 100) $statistikKategori[$kat]['selesai']++; } - - // Hitung rata-rata - foreach ($statistikKategori as $kategori => $data) { + foreach ($statistikKategori as $kat => $data) { if ($data['count'] > 0) { - $statistikKategori[$kategori]['avg'] = $data['avg'] / $data['count']; + $statistikKategori[$kat]['avg'] = round($data['avg'] / $data['count'], 2); } } - - // Distribusi persentase untuk chart + + // Distribusi persentase $distribusiPersentase = [ - '0-25%' => $capaians->whereBetween('persentase', [0, 25])->count(), - '26-50%' => $capaians->whereBetween('persentase', [26, 50])->count(), - '51-75%' => $capaians->whereBetween('persentase', [51, 75])->count(), - '76-99%' => $capaians->whereBetween('persentase', [76, 99])->count(), - '100%' => $capaians->where('persentase', '>=', 100)->count(), + '0-25%' => $capaians->filter(fn($c) => $c->persentase >= 0 && $c->persentase <= 25)->count(), + '26-50%' => $capaians->filter(fn($c) => $c->persentase > 25 && $c->persentase <= 50)->count(), + '51-75%' => $capaians->filter(fn($c) => $c->persentase > 50 && $c->persentase <= 75)->count(), + '76-99%' => $capaians->filter(fn($c) => $c->persentase > 75 && $c->persentase < 100)->count(), + '100%' => $capaians->where('persentase', '>=', 100)->count(), ]; - - // Data untuk semester dropdown + + // PREDIKSI: ambil SEMUA capaian tanpa filter semester + $allCapaians = Capaian::with([ + 'materi:id_materi,nama_kitab,kategori', + 'semester:id_semester,nama_semester,tahun_ajaran,periode', + ]) + ->where('id_santri', $idSantri) + ->select('id', 'id_santri', 'id_materi', 'id_semester', 'persentase') + ->get(); + + // Susun history per semester (urut cronologis) + $allSemesters = Semester::orderBy('tahun_ajaran')->orderBy('periode')->get(); + + $historyData = []; + foreach ($allSemesters as $sem) { + $semCap = $allCapaians->where('id_semester', $sem->id_semester); + if ($semCap->isNotEmpty()) { + $historyData[] = [ + 'sem' => $sem->nama_semester, + 'avg' => round($semCap->avg('persentase'), 2), + ]; + } + } + + // Hitung growth rate (rata-rata kenaikan antar semester) + $growthRate = 0; + if (count($historyData) >= 2) { + $diffs = []; + for ($i = 1; $i < count($historyData); $i++) { + $diffs[] = $historyData[$i]['avg'] - $historyData[$i - 1]['avg']; + } + $growthRate = round(array_sum($diffs) / count($diffs), 2); + } elseif (count($historyData) === 1) { + $growthRate = round($historyData[0]['avg'], 2); + } + + $progressHistory = [ + 'history' => $historyData, + 'growth_rate' => $growthRate, + 'all_capaians' => $allCapaians, + ]; + + // Semester dropdown $semesters = Semester::select('id_semester', 'nama_semester', 'tahun_ajaran') ->orderBy('tahun_ajaran', 'desc') ->orderBy('periode', 'desc') ->get(); - + + // Status akses input capaian mandiri + $capaianAccessOpen = CapaianAccessService::isOpen(); + $capaianAccessConfig = CapaianAccessService::getConfig(); + $capaianSisaWaktu = CapaianAccessService::getSisaWaktu(); + return view('santri.capaian.index', compact( 'santri', 'capaians', @@ -113,102 +144,28 @@ public function index(Request $request) 'materiSelesai', 'statistikKategori', 'distribusiPersentase', + 'progressHistory', 'semesters', 'selectedSemester', - 'semesterAktif' + 'semesterAktif', + 'capaianAccessOpen', + 'capaianAccessConfig', + 'capaianSisaWaktu' )); } - - /** - * Tampilkan detail capaian tertentu - */ + public function show($id) { - $user = Auth::user(); - - if (!in_array($user->role, ['santri', 'wali'])) { - abort(403, 'Unauthorized access'); - } - + $idSantri = $this->getSantriId(); + $capaian = Capaian::with([ 'materi:id_materi,nama_kitab,kategori,halaman_mulai,halaman_akhir,total_halaman', 'semester:id_semester,nama_semester,tahun_ajaran', - 'santri:id_santri,nama_lengkap,kelas' + 'santri:id_santri,nama_lengkap,nis', ]) - ->where('id_santri', $user->role_id) + ->where('id_santri', $idSantri) ->findOrFail($id); - + return view('santri.capaian.show', compact('capaian')); } - - /** - * API untuk data grafik (AJAX) - */ - public function apiGrafikData(Request $request) - { - $user = Auth::user(); - $type = $request->input('type', 'kategori'); - $idSemester = $request->input('id_semester'); - - $query = Capaian::with('materi:id_materi,kategori') - ->where('id_santri', $user->role_id) - ->select('id', 'id_materi', 'persentase', 'id_semester'); - - if ($idSemester) { - $query->where('id_semester', $idSemester); - } - - $capaians = $query->get(); - $data = []; - - switch ($type) { - case 'kategori': - $avgAlquran = $capaians->filter(fn($c) => $c->materi->kategori == 'Al-Qur\'an')->avg('persentase') ?? 0; - $avgHadist = $capaians->filter(fn($c) => $c->materi->kategori == 'Hadist')->avg('persentase') ?? 0; - $avgTambahan = $capaians->filter(fn($c) => $c->materi->kategori == 'Materi Tambahan')->avg('persentase') ?? 0; - - $data = [ - 'labels' => ['Al-Qur\'an', 'Hadist', 'Materi Tambahan'], - 'datasets' => [[ - 'label' => 'Rata-rata Progress (%)', - 'data' => [ - round($avgAlquran, 2), - round($avgHadist, 2), - round($avgTambahan, 2) - ], - 'backgroundColor' => [ - 'rgba(111, 186, 157, 0.8)', - 'rgba(129, 198, 232, 0.8)', - 'rgba(255, 213, 107, 0.8)', - ], - ]] - ]; - break; - - case 'distribusi': - $data = [ - 'labels' => ['0-25%', '26-50%', '51-75%', '76-99%', '100%'], - 'datasets' => [[ - 'label' => 'Jumlah Materi', - 'data' => [ - $capaians->whereBetween('persentase', [0, 25])->count(), - $capaians->whereBetween('persentase', [26, 50])->count(), - $capaians->whereBetween('persentase', [51, 75])->count(), - $capaians->whereBetween('persentase', [76, 99])->count(), - $capaians->where('persentase', '>=', 100)->count(), - ], - 'backgroundColor' => [ - 'rgba(255, 139, 148, 0.8)', - 'rgba(255, 171, 145, 0.8)', - 'rgba(255, 213, 107, 0.8)', - 'rgba(129, 198, 232, 0.8)', - 'rgba(111, 186, 157, 0.8)', - ], - ]] - ]; - break; - } - - return response()->json($data); - } } \ No newline at end of file diff --git a/sim-pkpps/app/Http/Controllers/Santri/SantriCapaianInputController.php b/sim-pkpps/app/Http/Controllers/Santri/SantriCapaianInputController.php new file mode 100644 index 0000000..e71665e --- /dev/null +++ b/sim-pkpps/app/Http/Controllers/Santri/SantriCapaianInputController.php @@ -0,0 +1,180 @@ +user()->id_santri; + return Santri::where('id_santri', $idSantri) + ->with(['kelasSantri.kelas']) + ->firstOrFail(); + } + + /** + * Form input capaian untuk santri. + * GET /santri/capaian/input + */ + public function create(Request $request) + { + // Cek apakah akses sedang dibuka + if (!CapaianAccessService::isOpen()) { + return redirect()->route('santri.capaian.index') + ->with('error', 'Saat ini belum ada jadwal input capaian. Silakan tunggu informasi dari admin.'); + } + + $santri = $this->getSantri(); + $accessConfig = CapaianAccessService::getConfig(); + $sisaWaktu = CapaianAccessService::getSisaWaktu(); + + // Ambil semester yang berlaku + $idSemesterConfig = $accessConfig['id_semester'] ?? null; + if ($idSemesterConfig) { + $semesterAktif = Semester::where('id_semester', $idSemesterConfig)->first(); + } else { + $semesterAktif = Semester::aktif()->first(); + } + + // Materi sesuai kelas santri + $kelasNames = $santri->kelasSantri->map(fn($sk) => $sk->kelas?->nama_kelas)->filter()->unique()->toArray(); + $materiOptions = Materi::whereIn('kelas', $kelasNames ?: ['']) + ->orderBy('kategori')->orderBy('nama_kitab')->get(); + + // Capaian yang sudah ada di semester ini + $existingCapaians = []; + if ($semesterAktif) { + $existingCapaians = Capaian::where('id_santri', $santri->id_santri) + ->where('id_semester', $semesterAktif->id_semester) + ->pluck('persentase', 'id_materi') + ->toArray(); + } + + $semesters = Semester::orderBy('tahun_ajaran', 'desc')->get(); + + return view('santri.capaian.input', compact( + 'santri', 'semesterAktif', 'semesters', 'materiOptions', + 'existingCapaians', 'accessConfig', 'sisaWaktu' + )); + } + + /** + * Simpan/update capaian oleh santri. + * POST /santri/capaian/input + */ + public function store(Request $request) + { + // Double-check akses masih terbuka + if (!CapaianAccessService::isOpen()) { + return redirect()->route('santri.capaian.index') + ->with('error', 'Waktu input capaian telah berakhir.'); + } + + $santri = $this->getSantri(); + + $validated = $request->validate([ + 'id_materi' => 'required|exists:materi,id_materi', + 'id_semester' => 'required|exists:semester,id_semester', + 'halaman_selesai'=> 'required|string', + 'catatan' => 'nullable|string|max:500', + 'tanggal_input' => 'required|date', + ]); + + // Pastikan semester yang dikirim sesuai dengan yang diizinkan + $accessConfig = CapaianAccessService::getConfig(); + if (!empty($accessConfig['id_semester']) && $accessConfig['id_semester'] !== $validated['id_semester']) { + return back()->with('error', 'Semester tidak sesuai dengan jadwal input yang dibuka admin.'); + } + + // Validasi materi sesuai kelas santri + $kelasNames = $santri->kelasSantri->map(fn($sk) => $sk->kelas?->nama_kelas)->filter()->unique()->toArray(); + $materi = Materi::where('id_materi', $validated['id_materi']) + ->whereIn('kelas', $kelasNames ?: [''])->first(); + + if (!$materi) { + return back()->with('error', 'Materi tidak sesuai dengan kelas Anda.'); + } + + // Upsert capaian (create or update) + $existing = Capaian::where('id_santri', $santri->id_santri) + ->where('id_materi', $validated['id_materi']) + ->where('id_semester', $validated['id_semester']) + ->first(); + + if ($existing) { + $existing->update([ + 'halaman_selesai' => $validated['halaman_selesai'], + 'catatan' => $validated['catatan'], + 'tanggal_input' => $validated['tanggal_input'], + ]); + $msg = "Capaian {$materi->nama_kitab} berhasil diperbarui."; + } else { + Capaian::create([ + 'id_santri' => $santri->id_santri, + 'id_materi' => $validated['id_materi'], + 'id_semester' => $validated['id_semester'], + 'halaman_selesai'=> $validated['halaman_selesai'], + 'catatan' => $validated['catatan'], + 'tanggal_input' => $validated['tanggal_input'], + ]); + $msg = "Capaian {$materi->nama_kitab} berhasil disimpan."; + } + + return redirect()->route('santri.capaian.input.create') + ->with('success', $msg); + } + + /** + * AJAX: Ambil detail materi + existing capaian santri ini. + * POST /santri/capaian/input/ajax/detail-materi + */ + public function ajaxDetailMateri(Request $request) + { + $santri = $this->getSantri(); + + $materi = Materi::where('id_materi', $request->id_materi)->first(); + if (!$materi) return response()->json(['error' => 'Materi tidak ditemukan'], 404); + + $existing = null; + if ($request->filled('id_semester')) { + $existing = Capaian::where('id_santri', $santri->id_santri) + ->where('id_materi', $request->id_materi) + ->where('id_semester', $request->id_semester) + ->first(); + } + + return response()->json([ + 'materi' => $materi, + 'existing_capaian' => $existing, + ]); + } + + /** + * AJAX: Hitung persentase preview. + */ + public function ajaxHitungPersentase(Request $request) + { + if (empty($request->halaman_selesai) || empty($request->id_materi)) { + return response()->json(['persentase' => 0, 'jumlah' => 0]); + } + try { + $persentase = Capaian::calculatePersentase($request->halaman_selesai, $request->id_materi); + $pages = Capaian::parseHalamanSelesai($request->halaman_selesai); + return response()->json([ + 'persentase' => number_format($persentase, 2), + 'jumlah' => count($pages), + ]); + } catch (\Exception $e) { + return response()->json(['error' => $e->getMessage()], 400); + } + } +} \ No newline at end of file diff --git a/sim-pkpps/app/Http/Controllers/Santri/SantriKepulanganController.php b/sim-pkpps/app/Http/Controllers/Santri/SantriKepulanganController.php index 598e069..05ecbcd 100644 --- a/sim-pkpps/app/Http/Controllers/Santri/SantriKepulanganController.php +++ b/sim-pkpps/app/Http/Controllers/Santri/SantriKepulanganController.php @@ -1,4 +1,5 @@ user()->id_santri; + } + /** * Tampilkan riwayat kepulangan santri yang sedang login */ public function index(Request $request) { - $user = Auth::user(); - - // Ambil data santri - $santri = Santri::where('id_santri', $user->role_id) - ->select('id_santri', 'nama_lengkap', 'kelas') - ->firstOrFail(); - - // Tahun untuk filter + $idSantri = $this->getSantriId(); + + // -- Ambil data santri (tanpa kolom 'kelas' yang mungkin tidak ada) -- + $santri = Santri::where('id_santri', $idSantri)->firstOrFail(); + + // -- Tahun untuk filter -- $tahunSekarang = $request->filled('tahun') ? $request->tahun : Carbon::now()->year; - - // Query riwayat kepulangan + + // -- Query riwayat kepulangan -- $query = Kepulangan::query() - ->select([ - 'id', - 'id_kepulangan', - 'id_santri', - 'tanggal_izin', - 'tanggal_pulang', - 'tanggal_kembali', - 'durasi_izin', - 'alasan', - 'status', - 'approved_at', - 'created_at' - ]) ->where('id_santri', $santri->id_santri) ->whereYear('tanggal_pulang', $tahunSekarang); - - // Filter status jika ada + + // -- Filter status jika ada -- if ($request->filled('status')) { $query->where('status', $request->status); } - - // Urutkan terbaru dan paginate + + // -- Urutkan terbaru dan paginate -- $riwayatKepulangan = $query->orderBy('tanggal_pulang', 'desc') ->paginate(10) ->appends($request->all()); - - // Hitung statistik tahun ini + + // -- Hitung statistik tahun ini -- + $allKepulanganTahunIni = Kepulangan::where('id_santri', $santri->id_santri) + ->whereYear('tanggal_pulang', $tahunSekarang) + ->get(); + $statistik = [ - 'total_izin' => Kepulangan::where('id_santri', $santri->id_santri) - ->whereYear('tanggal_pulang', $tahunSekarang) - ->count(), - 'disetujui' => Kepulangan::where('id_santri', $santri->id_santri) - ->where('status', 'Disetujui') - ->whereYear('tanggal_pulang', $tahunSekarang) - ->count(), - 'total_hari' => Kepulangan::where('id_santri', $santri->id_santri) - ->where('status', 'Disetujui') - ->whereYear('tanggal_pulang', $tahunSekarang) - ->sum('durasi_izin'), - 'menunggu' => Kepulangan::where('id_santri', $santri->id_santri) - ->where('status', 'Menunggu') - ->whereYear('tanggal_pulang', $tahunSekarang) - ->count(), + 'total_izin' => $allKepulanganTahunIni->count(), + 'disetujui' => $allKepulanganTahunIni->where('status', 'Disetujui')->count(), + 'ditolak' => $allKepulanganTahunIni->where('status', 'Ditolak')->count(), + 'menunggu' => $allKepulanganTahunIni->where('status', 'Menunggu')->count(), + 'selesai' => $allKepulanganTahunIni->where('status', 'Selesai')->count(), + 'total_hari' => $allKepulanganTahunIni->whereIn('status', ['Disetujui', 'Selesai'])->sum('durasi_izin'), ]; - - // Hitung sisa kuota (maksimal 12 hari/tahun) + $statistik['sisa_kuota'] = max(0, 12 - $statistik['total_hari']); $statistik['over_limit'] = $statistik['total_hari'] > 12; - - // Data untuk filter + $statistik['persen_kuota'] = min(100, round(($statistik['total_hari'] / 12) * 100)); + + // -- Cek apakah sedang aktif pulang -- + $sedangPulang = Kepulangan::where('id_santri', $santri->id_santri) + ->where('status', 'Disetujui') + ->whereDate('tanggal_pulang', '<=', Carbon::today()) + ->whereDate('tanggal_kembali', '>=', Carbon::today()) + ->first(); + + // -- Cek apakah ada yang terlambat -- + $terlambat = Kepulangan::where('id_santri', $santri->id_santri) + ->where('status', 'Disetujui') + ->whereDate('tanggal_kembali', '<', Carbon::today()) + ->first(); + + // -- Data untuk filter -- $statusOptions = [ 'Menunggu' => 'Menunggu Approval', 'Disetujui' => 'Disetujui', 'Ditolak' => 'Ditolak', 'Selesai' => 'Selesai' ]; - - // Tahun options (5 tahun terakhir) + + // -- Tahun options (5 tahun terakhir) -- $tahunOptions = range(Carbon::now()->year, Carbon::now()->year - 4); - + return view('santri.kepulangan.index', compact( 'riwayatKepulangan', 'santri', 'statistik', 'statusOptions', 'tahunOptions', - 'tahunSekarang' + 'tahunSekarang', + 'sedangPulang', + 'terlambat' )); } - + /** * Tampilkan detail kepulangan */ public function show($id_kepulangan) { - $user = Auth::user(); - - $santri = Santri::where('id_santri', $user->role_id) - ->select('id_santri', 'nama_lengkap', 'kelas') - ->firstOrFail(); - - // Ambil data kepulangan dengan validasi kepemilikan + $idSantri = $this->getSantriId(); + + $santri = Santri::where('id_santri', $idSantri)->firstOrFail(); + + // -- Ambil data kepulangan dengan validasi kepemilikan -- $kepulangan = Kepulangan::where('id_kepulangan', $id_kepulangan) ->where('id_santri', $santri->id_santri) ->firstOrFail(); - - // Hitung total hari izin tahun ini + + // -- Hitung total hari izin tahun ini -- $tahunSekarang = Carbon::now()->year; $totalHariTahunIni = Kepulangan::where('id_santri', $santri->id_santri) - ->where('status', 'Disetujui') + ->whereIn('status', ['Disetujui', 'Selesai']) ->whereYear('tanggal_pulang', $tahunSekarang) ->sum('durasi_izin'); - + $sisaKuota = max(0, 12 - $totalHariTahunIni); - - return view('santri.kepulangan.show', compact('kepulangan', 'santri', 'totalHariTahunIni', 'sisaKuota')); + $persenKuota = min(100, round(($totalHariTahunIni / 12) * 100)); + + // -- Riwayat kepulangan lain tahun ini -- + $riwayatLain = Kepulangan::where('id_santri', $santri->id_santri) + ->where('id_kepulangan', '!=', $id_kepulangan) + ->whereYear('tanggal_pulang', $tahunSekarang) + ->whereIn('status', ['Disetujui', 'Selesai']) + ->orderBy('tanggal_pulang', 'desc') + ->limit(5) + ->get(); + + return view('santri.kepulangan.show', compact( + 'kepulangan', + 'santri', + 'totalHariTahunIni', + 'sisaKuota', + 'persenKuota', + 'riwayatLain' + )); } } \ No newline at end of file diff --git a/sim-pkpps/app/Http/Controllers/Santri/SantriKesehatanController.php b/sim-pkpps/app/Http/Controllers/Santri/SantriKesehatanController.php index 8f4d62c..2a87a41 100644 --- a/sim-pkpps/app/Http/Controllers/Santri/SantriKesehatanController.php +++ b/sim-pkpps/app/Http/Controllers/Santri/SantriKesehatanController.php @@ -1,4 +1,5 @@ user()->id_santri; + } + /** - * Tampilkan riwayat kesehatan santri yang sedang login dengan filter tanggal + * Tampilkan riwayat kesehatan santri yang sedang login */ public function index(Request $request) { - $user = Auth::user(); - - // Ambil data santri - $santri = Santri::where('id_santri', $user->role_id) - ->select('id_santri', 'nama_lengkap', 'kelas') + $idSantri = $this->getSantriId(); + + // βœ… Fix: hapus 'kelas' dari select, tambah eager load kelasPrimary + $santri = Santri::with('kelasPrimary.kelas') + ->where('id_santri', $idSantri) + ->select('id_santri', 'nama_lengkap', 'jenis_kelamin', 'status') ->firstOrFail(); - - // βœ… TENTUKAN RANGE TANGGAL - // Jika tidak ada filter, default bulan ini - $tanggalDari = $request->filled('tanggal_dari') - ? Carbon::parse($request->tanggal_dari) + + // -- Tentukan range tanggal -- + $tanggalDari = $request->filled('tanggal_dari') + ? Carbon::parse($request->tanggal_dari) : Carbon::now()->startOfMonth(); - - $tanggalSampai = $request->filled('tanggal_sampai') - ? Carbon::parse($request->tanggal_sampai) + + $tanggalSampai = $request->filled('tanggal_sampai') + ? Carbon::parse($request->tanggal_sampai) : Carbon::now()->endOfMonth(); - - // Validasi: tanggal_sampai tidak boleh lebih kecil dari tanggal_dari + + // -- Validasi tanggal -- if ($tanggalSampai->lt($tanggalDari)) { return back()->withErrors([ - 'tanggal_sampai' => 'Tanggal sampai harus lebih besar atau sama dengan tanggal dari.' + 'tanggal_sampai' => 'Tanggal sampai harus lebih besar dari tanggal dari.' ])->withInput(); } - - // βœ… QUERY DASAR DENGAN FILTER TANGGAL - $baseQuery = KesehatanSantri::where('id_santri', $santri->id_santri) + + // -- Statistik berdasarkan filter tanggal -- + $baseQuery = KesehatanSantri::where('id_santri', $idSantri) ->whereBetween('tanggal_masuk', [ $tanggalDari->format('Y-m-d'), - $tanggalSampai->format('Y-m-d') + $tanggalSampai->format('Y-m-d'), ]); - - // βœ… HITUNG STATISTIK BERDASARKAN FILTER TANGGAL + $statistik = [ 'total_kunjungan' => (clone $baseQuery)->count(), - 'sedang_dirawat' => (clone $baseQuery)->where('status', 'dirawat')->count(), - 'sembuh' => (clone $baseQuery)->where('status', 'sembuh')->count(), - 'izin' => (clone $baseQuery)->where('status', 'izin')->count(), + 'sedang_dirawat' => (clone $baseQuery)->where('status', 'dirawat')->count(), + 'sembuh' => (clone $baseQuery)->where('status', 'sembuh')->count(), + 'izin' => (clone $baseQuery)->where('status', 'izin')->count(), ]; - - // βœ… QUERY RIWAYAT KESEHATAN UNTUK TABEL - $query = KesehatanSantri::query() - ->select([ - 'id', - 'id_kesehatan', - 'id_santri', - 'tanggal_masuk', - 'tanggal_keluar', - 'keluhan', - 'status', - 'created_at' - ]) - ->where('id_santri', $santri->id_santri) + + // -- Cek apakah SAAT INI sedang dirawat (semua waktu, bukan filter) -- + $sedangDirawatSekarang = KesehatanSantri::where('id_santri', $idSantri) + ->where('status', 'dirawat') + ->latest('tanggal_masuk') + ->first(); + + // -- Data grafik: kunjungan per bulan (6 bulan terakhir) -- + $dataGrafik = KesehatanSantri::where('id_santri', $idSantri) + ->where('tanggal_masuk', '>=', Carbon::now()->subMonths(6)->startOfMonth()) + ->select( + DB::raw('YEAR(tanggal_masuk) as tahun'), + DB::raw('MONTH(tanggal_masuk) as bulan'), + DB::raw('COUNT(*) as total'), + DB::raw('SUM(CASE WHEN status = "sembuh" THEN 1 ELSE 0 END) as sembuh'), + DB::raw('SUM(CASE WHEN status = "dirawat" THEN 1 ELSE 0 END) as dirawat'), + DB::raw('SUM(CASE WHEN status = "izin" THEN 1 ELSE 0 END) as izin') + ) + ->groupBy('tahun', 'bulan') + ->orderBy('tahun') + ->orderBy('bulan') + ->get() + ->map(fn($item) => [ + 'label' => Carbon::createFromDate($item->tahun, $item->bulan, 1) + ->locale('id')->isoFormat('MMM YY'), + 'total' => $item->total, + 'sembuh' => $item->sembuh, + 'dirawat' => $item->dirawat, + 'izin' => $item->izin, + ]); + + // -- Statistik total keseluruhan (all time) -- + $totalAllTime = KesehatanSantri::where('id_santri', $idSantri)->count(); + $totalHariDirawat = KesehatanSantri::where('id_santri', $idSantri)->get() + ->sum('lama_dirawat'); + + // -- Query riwayat dengan filter -- + $query = KesehatanSantri::where('id_santri', $idSantri) ->whereBetween('tanggal_masuk', [ $tanggalDari->format('Y-m-d'), - $tanggalSampai->format('Y-m-d') + $tanggalSampai->format('Y-m-d'), ]); - - // Filter status jika ada + if ($request->filled('status')) { $query->where('status', $request->status); } - - // Urutkan terbaru dan paginate + $riwayatKesehatan = $query->orderBy('tanggal_masuk', 'desc') ->paginate(10) - ->appends($request->all()); // Append query string untuk pagination - - // Data untuk filter + ->appends($request->all()); + $statusOptions = [ 'dirawat' => 'Sedang Dirawat', - 'sembuh' => 'Sembuh', - 'izin' => 'Izin Sakit' + 'sembuh' => 'Sembuh', + 'izin' => 'Izin Sakit', ]; - + return view('santri.kesehatan.index', compact( 'riwayatKesehatan', 'santri', 'statistik', 'statusOptions', 'tanggalDari', - 'tanggalSampai' + 'tanggalSampai', + 'sedangDirawatSekarang', + 'dataGrafik', + 'totalAllTime', + 'totalHariDirawat' )); } - + /** * Tampilkan detail riwayat kesehatan */ public function show($id) { - $user = Auth::user(); - - $santri = Santri::where('id_santri', $user->role_id) - ->select('id_santri', 'nama_lengkap', 'kelas') + $idSantri = $this->getSantriId(); + + // βœ… Fix: hapus 'kelas' dari select + $santri = Santri::with('kelasPrimary.kelas') + ->where('id_santri', $idSantri) + ->select('id_santri', 'nama_lengkap', 'jenis_kelamin', 'status') ->firstOrFail(); - - // Ambil data kesehatan dengan validasi kepemilikan + $kesehatanSantri = KesehatanSantri::where('id', $id) - ->where('id_santri', $santri->id_santri) + ->where('id_santri', $idSantri) ->firstOrFail(); - - return view('santri.kesehatan.show', compact('kesehatanSantri', 'santri')); + + // -- Riwayat lain santri ini (untuk konteks) -- + $riwayatLain = KesehatanSantri::where('id_santri', $idSantri) + ->where('id', '!=', $id) + ->orderBy('tanggal_masuk', 'desc') + ->take(3) + ->get(); + + return view('santri.kesehatan.show', compact( + 'kesehatanSantri', + 'santri', + 'riwayatLain' + )); } } \ No newline at end of file diff --git a/sim-pkpps/app/Http/Controllers/Santri/SantriPelanggaranController.php b/sim-pkpps/app/Http/Controllers/Santri/SantriPelanggaranController.php index e7e672b..fc5909b 100644 --- a/sim-pkpps/app/Http/Controllers/Santri/SantriPelanggaranController.php +++ b/sim-pkpps/app/Http/Controllers/Santri/SantriPelanggaranController.php @@ -7,26 +7,26 @@ use App\Models\RiwayatPelanggaran; use App\Models\KategoriPelanggaran; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; class SantriPelanggaranController extends Controller { + // -- Helper: Ambil id_santri dari akun yang login -- + private function getSantriId() + { + return auth('santri')->user()->id_santri; + } + /** * Tampilkan daftar riwayat pelanggaran santri yang sedang login */ public function index(Request $request) { - $user = Auth::user(); - - // Validasi role - if (!in_array($user->role, ['santri', 'wali'])) { - abort(403, 'Akses ditolak.'); - } - - // Query riwayat pelanggaran dengan relasi + $idSantri = $this->getSantriId(); + + // -- Query riwayat pelanggaran dengan relasi -- $query = RiwayatPelanggaran::with(['kategori:id,id_kategori,nama_pelanggaran,poin']) - ->where('id_santri', $user->role_id) + ->where('id_santri', $idSantri) ->select([ 'id', 'id_riwayat', @@ -37,31 +37,31 @@ public function index(Request $request) 'keterangan', 'created_at' ]); - - // Filter berdasarkan tanggal (opsional) + + // -- Filter berdasarkan tanggal -- if ($request->filled('tanggal_mulai')) { $query->whereDate('tanggal', '>=', $request->tanggal_mulai); } - + if ($request->filled('tanggal_selesai')) { $query->whereDate('tanggal', '<=', $request->tanggal_selesai); } - - // Filter bulan ini (jika ada parameter) + + // -- Filter bulan ini -- if ($request->has('bulan_ini') && $request->bulan_ini == '1') { $query->bulanIni(); } - - // Urutkan dari terbaru + + // -- Urutkan dari terbaru -- $riwayat = $query->terbaru()->paginate(15); - - // Statistik pelanggaran santri - $totalPelanggaran = RiwayatPelanggaran::where('id_santri', $user->role_id)->count(); - $totalPoin = RiwayatPelanggaran::where('id_santri', $user->role_id)->sum('poin'); - $pelanggaranBulanIni = RiwayatPelanggaran::where('id_santri', $user->role_id) + + // -- Statistik pelanggaran santri -- + $totalPelanggaran = RiwayatPelanggaran::where('id_santri', $idSantri)->count(); + $totalPoin = RiwayatPelanggaran::where('id_santri', $idSantri)->sum('poin'); + $pelanggaranBulanIni = RiwayatPelanggaran::where('id_santri', $idSantri) ->bulanIni() ->count(); - + return view('santri.pelanggaran.index', compact( 'riwayat', 'totalPelanggaran', @@ -69,31 +69,29 @@ public function index(Request $request) 'pelanggaranBulanIni' )); } - + /** * Tampilkan detail satu riwayat pelanggaran */ public function show(RiwayatPelanggaran $riwayatPelanggaran) { - $user = Auth::user(); - - // Validasi: pastikan pelanggaran milik santri yang login - if ($riwayatPelanggaran->id_santri !== $user->role_id) { + // -- Validasi: pastikan pelanggaran milik santri yang login -- + if ($riwayatPelanggaran->id_santri !== $this->getSantriId()) { abort(403, 'Anda tidak memiliki akses ke data ini.'); } - - // Load relasi kategori + + // -- Load relasi kategori -- $riwayatPelanggaran->load('kategori:id,id_kategori,nama_pelanggaran,poin'); - + return view('santri.pelanggaran.show', compact('riwayatPelanggaran')); } - + /** * Tampilkan daftar semua kategori pelanggaran beserta poinnya */ public function kategoriList() { - // Cache daftar kategori selama 1 jam + // -- Cache daftar kategori selama 1 jam -- $kategoriList = Cache::remember('kategori_pelanggaran_list', 3600, function () { return KategoriPelanggaran::select([ 'id', @@ -105,7 +103,7 @@ public function kategoriList() ->orderBy('nama_pelanggaran', 'asc') ->get(); }); - + return view('santri.pelanggaran.kategori', compact('kategoriList')); } } \ No newline at end of file diff --git a/sim-pkpps/app/Http/Controllers/Santri/SantriPembinaanController.php b/sim-pkpps/app/Http/Controllers/Santri/SantriPembinaanController.php new file mode 100644 index 0000000..2d6d404 --- /dev/null +++ b/sim-pkpps/app/Http/Controllers/Santri/SantriPembinaanController.php @@ -0,0 +1,72 @@ +byUrutan() + ->select(['id', 'id_pembinaan', 'judul', 'konten', 'urutan', 'updated_at']) + ->get(); + }); + + return view('santri.pembinaan.index', compact('pembinaanList')); + } + + /** + * Tampilkan detail satu konten pembinaan & sanksi + */ + public function show($id_pembinaan) + { + $pembinaan = PembinaanSanksi::aktif() + ->where('id_pembinaan', $id_pembinaan) + ->firstOrFail(); + + // Konten sebelum dan sesudah untuk navigasi + $prev = PembinaanSanksi::aktif() + ->byUrutan() + ->where('urutan', '<', $pembinaan->urutan) + ->orWhere(function ($q) use ($pembinaan) { + $q->where('urutan', $pembinaan->urutan) + ->where('id', '<', $pembinaan->id); + }) + ->orderBy('urutan', 'desc') + ->first(); + + $next = PembinaanSanksi::aktif() + ->byUrutan() + ->where('urutan', '>', $pembinaan->urutan) + ->orWhere(function ($q) use ($pembinaan) { + $q->where('urutan', $pembinaan->urutan) + ->where('id', '>', $pembinaan->id); + }) + ->orderBy('urutan', 'asc') + ->first(); + + // Semua konten untuk sidebar + $pembinaanList = Cache::remember('pembinaan_sanksi_aktif', 1800, function () { + return PembinaanSanksi::aktif()->byUrutan() + ->select(['id', 'id_pembinaan', 'judul', 'urutan']) + ->get(); + }); + + return view('santri.pembinaan.show', compact( + 'pembinaan', + 'pembinaanList', + 'prev', + 'next' + )); + } +} \ No newline at end of file diff --git a/sim-pkpps/app/Http/Controllers/Santri/SantriProfileController.php b/sim-pkpps/app/Http/Controllers/Santri/SantriProfileController.php index 3551737..26a617f 100644 --- a/sim-pkpps/app/Http/Controllers/Santri/SantriProfileController.php +++ b/sim-pkpps/app/Http/Controllers/Santri/SantriProfileController.php @@ -5,106 +5,48 @@ use App\Http\Controllers\Controller; use App\Models\Santri; -use Illuminate\Http\Request; -use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; class SantriProfileController extends Controller { + private function getSantriId() + { + return auth('santri')->user()->id_santri; + } + /** - * Tampilkan halaman profil santri yang sedang login + * Tampilkan halaman profil santri yang sedang login (READ ONLY) */ public function index() { - // Ambil data user yang sedang login - $user = Auth::guard('web')->user(); - - // Pastikan user adalah santri - if ($user->role !== 'santri') { - abort(403, 'Unauthorized access'); - } - - // Cache data santri selama 10 menit untuk mengurangi query database + $idSantri = $this->getSantriId(); + $santri = Cache::remember( - 'santri_profile_' . $user->role_id, - 600, // 10 menit - function () use ($user) { - return Santri::where('id_santri', $user->role_id) + 'santri_profile_' . $idSantri, + 600, + function () use ($idSantri) { + return Santri::with(['kelasSantri.kelas.kelompok', 'kelasPrimary.kelas.kelompok']) + ->where('id_santri', $idSantri) ->select([ 'id', 'id_santri', 'nis', 'nama_lengkap', 'jenis_kelamin', - 'kelas', 'status', 'alamat_santri', 'daerah_asal', 'nama_orang_tua', 'nomor_hp_ortu', 'rfid_uid', - 'foto', // βœ… TAMBAHAN INI - PENTING! - 'created_at' + 'foto', + 'created_at', + 'updated_at', ]) ->firstOrFail(); } ); - + return view('santri.profil.index', compact('santri')); } - - /** - * Tampilkan form edit profil (data terbatas yang bisa diedit santri) - */ - public function edit() - { - $user = Auth::guard('web')->user(); - - if ($user->role !== 'santri') { - abort(403, 'Unauthorized access'); - } - - $santri = Santri::where('id_santri', $user->role_id) - ->select([ - 'id', - 'id_santri', - 'nama_lengkap', - 'jenis_kelamin', // βœ… TAMBAHAN untuk fallback foto default - 'alamat_santri', - 'nomor_hp_ortu', - 'foto' // βœ… TAMBAHAN INI - PENTING! - ]) - ->firstOrFail(); - - return view('santri.profil.edit', compact('santri')); - } - - /** - * Update profil santri (hanya field tertentu yang boleh diedit) - */ - public function update(Request $request) - { - $user = Auth::guard('web')->user(); - - if ($user->role !== 'santri') { - abort(403, 'Unauthorized access'); - } - - $validated = $request->validate([ - 'alamat_santri' => 'nullable|string|max:500', - 'nomor_hp_ortu' => 'nullable|string|max:20|regex:/^[0-9+\-\s()]+$/', - ], [ - 'nomor_hp_ortu.regex' => 'Format nomor HP tidak valid. Hanya boleh berisi angka, +, -, spasi, dan tanda kurung.', - 'alamat_santri.max' => 'Alamat maksimal 500 karakter.', - ]); - - $santri = Santri::where('id_santri', $user->role_id)->firstOrFail(); - $santri->update($validated); - - // Clear cache setelah update - Cache::forget('santri_profile_' . $user->role_id); - - return redirect()->route('santri.profil.index') - ->with('success', 'Profil berhasil diperbarui.'); - } } \ No newline at end of file diff --git a/sim-pkpps/app/Http/Controllers/Santri/SantriUangSakuController.php b/sim-pkpps/app/Http/Controllers/Santri/SantriUangSakuController.php index 2aa63d4..43bcfe4 100644 --- a/sim-pkpps/app/Http/Controllers/Santri/SantriUangSakuController.php +++ b/sim-pkpps/app/Http/Controllers/Santri/SantriUangSakuController.php @@ -5,71 +5,62 @@ use App\Http\Controllers\Controller; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; use App\Models\UangSaku; use App\Models\Santri; class SantriUangSakuController extends Controller { + private function getSantriId() + { + return auth('santri')->user()->id_santri; + } + /** * Tampilkan riwayat uang saku santri yang sedang login */ public function index(Request $request) { try { - $user = Auth::user(); - - // Validasi role - if (!in_array($user->role, ['santri', 'wali'])) { - abort(403, 'Akses ditolak'); - } - - // Ambil data santri - $santri = Santri::where('id_santri', $user->role_id)->first(); - - if (!$santri) { - abort(404, 'Data santri tidak ditemukan'); - } - - // Query uang saku dengan pagination dan filter - $query = UangSaku::where('id_santri', $santri->id_santri) - ->with('santri:id_santri,nama_lengkap,kelas'); - - // Filter berdasarkan jenis transaksi + $idSantri = $this->getSantriId(); + + $santri = Santri::with(['kelasPrimary.kelas']) + ->where('id_santri', $idSantri) + ->firstOrFail(); + + // -- Query uang saku -- + $query = UangSaku::where('id_santri', $idSantri); + + // -- Filter jenis transaksi -- if ($request->filled('jenis_transaksi')) { $query->where('jenis_transaksi', $request->jenis_transaksi); } - - // Filter berdasarkan tanggal + + // -- Filter tanggal -- if ($request->filled('tanggal_dari')) { $query->whereDate('tanggal_transaksi', '>=', $request->tanggal_dari); } - if ($request->filled('tanggal_sampai')) { $query->whereDate('tanggal_transaksi', '<=', $request->tanggal_sampai); } - - // Search + + // -- Search keterangan -- if ($request->filled('search')) { $search = $request->search; - $query->where(function($q) use ($search) { + $query->where(function ($q) use ($search) { $q->where('keterangan', 'like', "%{$search}%") ->orWhere('id_uang_saku', 'like', "%{$search}%"); }); } - - // Urutkan dari yang terbaru - $query->orderBy('tanggal_transaksi', 'desc') - ->orderBy('created_at', 'desc'); - - // Pagination - $riwayatUangSaku = $query->paginate(15)->withQueryString(); - - // βœ… Hitung statistik berdasarkan filter atau bulan ini - $statistikQuery = UangSaku::where('id_santri', $santri->id_santri); - - // Jika ada filter tanggal, gunakan filter tersebut + + $riwayatUangSaku = $query->orderBy('tanggal_transaksi', 'desc') + ->orderBy('created_at', 'desc') + ->paginate(15) + ->withQueryString(); + + // -- Statistik: bulan ini atau sesuai filter tanggal -- + $statistikQuery = UangSaku::where('id_santri', $idSantri); + if ($request->filled('tanggal_dari') || $request->filled('tanggal_sampai')) { if ($request->filled('tanggal_dari')) { $statistikQuery->whereDate('tanggal_transaksi', '>=', $request->tanggal_dari); @@ -78,32 +69,14 @@ public function index(Request $request) $statistikQuery->whereDate('tanggal_transaksi', '<=', $request->tanggal_sampai); } } else { - // Jika tidak ada filter, tampilkan data bulan ini saja $statistikQuery->whereMonth('tanggal_transaksi', now()->month) - ->whereYear('tanggal_transaksi', now()->year); + ->whereYear('tanggal_transaksi', now()->year); } - - // Clone query untuk menghitung pemasukan dan pengeluaran - $totalPemasukan = (clone $statistikQuery)->where('jenis_transaksi', 'pemasukan')->sum('nominal'); + + $totalPemasukan = (clone $statistikQuery)->where('jenis_transaksi', 'pemasukan')->sum('nominal'); $totalPengeluaran = (clone $statistikQuery)->where('jenis_transaksi', 'pengeluaran')->sum('nominal'); - - // Saldo terakhir tetap dari data terbaru (tidak terpengaruh filter) - $saldoTerakhir = $santri->saldo_uang_saku; - - // Info periode untuk ditampilkan di view - if ($request->filled('tanggal_dari') || $request->filled('tanggal_sampai')) { - $periodeTeks = 'Periode: '; - if ($request->filled('tanggal_dari')) { - $periodeTeks .= \Carbon\Carbon::parse($request->tanggal_dari)->format('d/m/Y'); - } - $periodeTeks .= ' - '; - if ($request->filled('tanggal_sampai')) { - $periodeTeks .= \Carbon\Carbon::parse($request->tanggal_sampai)->format('d/m/Y'); - } - } else { - $periodeTeks = 'Bulan Ini: ' . now()->isoFormat('MMMM YYYY'); - } - + $saldoTerakhir = $santri->saldo_uang_saku; + return view('santri.uang-saku.index', compact( 'riwayatUangSaku', 'santri', @@ -111,41 +84,35 @@ public function index(Request $request) 'totalPengeluaran', 'saldoTerakhir' )); - + } catch (\Exception $e) { - Log::error('Error di Riwayat Uang Saku Santri: ' . $e->getMessage()); - - return back()->with('error', 'Terjadi kesalahan saat memuat riwayat uang saku'); + Log::error('Error Riwayat Uang Saku: ' . $e->getMessage()); + return back()->with('error', 'Terjadi kesalahan saat memuat data uang saku.'); } } - + /** - * Tampilkan detail transaksi + * Tampilkan detail satu transaksi */ public function show($id) { try { - $user = Auth::user(); - - // Ambil data santri - $santri = Santri::where('id_santri', $user->role_id)->first(); - - if (!$santri) { - abort(404, 'Data santri tidak ditemukan'); - } - - // Ambil transaksi dengan validasi kepemilikan + $idSantri = $this->getSantriId(); + + // Pastikan transaksi ini milik santri yang login $transaksi = UangSaku::where('id', $id) - ->where('id_santri', $santri->id_santri) - ->with('santri:id_santri,nama_lengkap,kelas') + ->where('id_santri', $idSantri) + ->with(['santri' => function ($q) { + $q->with('kelasPrimary.kelas') + ->select('id_santri', 'nama_lengkap'); + }]) ->firstOrFail(); - - return view('santri.uang-saku.show', compact('transaksi', 'santri')); - + + return view('santri.uang-saku.show', compact('transaksi')); + } catch (\Exception $e) { - Log::error('Error di Detail Uang Saku: ' . $e->getMessage()); - - return back()->with('error', 'Transaksi tidak ditemukan atau Anda tidak memiliki akses'); + Log::error('Error Detail Uang Saku: ' . $e->getMessage()); + return back()->with('error', 'Transaksi tidak ditemukan atau Anda tidak memiliki akses.'); } } } \ No newline at end of file diff --git a/sim-pkpps/app/Http/Kernel.php b/sim-pkpps/app/Http/Kernel.php index e6547fa..74dd288 100644 --- a/sim-pkpps/app/Http/Kernel.php +++ b/sim-pkpps/app/Http/Kernel.php @@ -36,8 +36,11 @@ class Kernel extends HttpKernel \Illuminate\View\Middleware\ShareErrorsFromSession::class, \App\Http\Middleware\VerifyCsrfToken::class, \Illuminate\Routing\Middleware\SubstituteBindings::class, - \Illuminate\Session\Middleware\AuthenticateSession::class, - \App\Http\Middleware\ClearStuckSession::class, + // AuthenticateSession dipindah dari global ke alias 'auth.session' + // agar tidak menyebabkan redirect loop pada user tanpa remember_token + // \Illuminate\Session\Middleware\AuthenticateSession::class, + // ClearStuckSession dihapus karena menyebabkan session flush setelah login + // \App\Http\Middleware\ClearStuckSession::class, ], 'api' => [ @@ -67,5 +70,6 @@ class Kernel extends HttpKernel 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, 'role' => \App\Http\Middleware\Role::class, + 'santri.auth' => \App\Http\Middleware\CheckSantriAuth::class, ]; } diff --git a/sim-pkpps/app/Http/Middleware/CheckSantriAuth.php b/sim-pkpps/app/Http/Middleware/CheckSantriAuth.php new file mode 100644 index 0000000..f29bd62 --- /dev/null +++ b/sim-pkpps/app/Http/Middleware/CheckSantriAuth.php @@ -0,0 +1,42 @@ +check()) { + // Jika request AJAX/API, return JSON + if ($request->expectsJson()) { + return response()->json(['message' => 'Unauthenticated.'], 401); + } + + return redirect()->route('santri.login') + ->with('error', 'Silakan login terlebih dahulu.'); + } + + $account = Auth::guard('santri')->user(); + + // PERBAIKAN: pastikan akun masih valid dan punya id_santri + if (!$account || !$account->id_santri) { + Auth::guard('santri')->logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('santri.login') + ->with('error', 'Akun tidak valid. Silakan login ulang.'); + } + + return $next($request); + } +} \ No newline at end of file diff --git a/sim-pkpps/app/Http/Middleware/RedirectIfAuthenticated.php b/sim-pkpps/app/Http/Middleware/RedirectIfAuthenticated.php index afc78c4..5010914 100644 --- a/sim-pkpps/app/Http/Middleware/RedirectIfAuthenticated.php +++ b/sim-pkpps/app/Http/Middleware/RedirectIfAuthenticated.php @@ -2,7 +2,6 @@ namespace App\Http\Middleware; -use App\Providers\RouteServiceProvider; use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -10,21 +9,22 @@ class RedirectIfAuthenticated { - /** - * Handle an incoming request. - * - * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next - */ public function handle(Request $request, Closure $next, string ...$guards): Response { - $guards = empty($guards) ? [null] : $guards; + $path = $request->path(); - foreach ($guards as $guard) { - if (Auth::guard($guard)->check()) { - return redirect(RouteServiceProvider::HOME); + if (str_starts_with($path, 'santri')) { + // Halaman guest santri β†’ redirect hanya jika guard santri aktif + if (Auth::guard('santri')->check()) { + return redirect()->route('santri.dashboard'); + } + } else { + // Halaman guest admin β†’ redirect hanya jika guard web aktif + if (Auth::check()) { + return redirect()->route('admin.dashboard'); } } return $next($request); } -} +} \ No newline at end of file diff --git a/sim-pkpps/app/Http/Middleware/Role.php b/sim-pkpps/app/Http/Middleware/Role.php index 5b2680a..5bf2bbd 100644 --- a/sim-pkpps/app/Http/Middleware/Role.php +++ b/sim-pkpps/app/Http/Middleware/Role.php @@ -9,45 +9,27 @@ class Role { - public function handle(Request $request, Closure $next, string $roles): Response + /** + * Handle an incoming request. + * Menerima daftar role yang diizinkan sebagai parameter middleware. + * Contoh: role:super_admin,akademik,pamong + * + * Laravel memecah parameter setelah ':' menjadi argumen terpisah per koma, + * sehingga kita harus gunakan variadic (...$roles) bukan string tunggal. + */ + public function handle(Request $request, Closure $next, string ...$roles): Response { - // 1. Cek apakah pengguna sudah login + // -- Cek apakah pengguna sudah login -- if (!Auth::check()) { - // Clear session jika belum login tapi masih ada session - $request->session()->flush(); - $request->session()->regenerate(); - - return redirect('/admin/login'); + return redirect()->route('admin.login'); } - // Ambil role pengguna saat ini - $currentRole = Auth::user()->role; - - // Pisahkan daftar role yang diizinkan - $allowedRoles = explode(',', $roles); - - // 2. Cek apakah role pengguna termasuk dalam daftar yang diizinkan - if (!in_array($currentRole, $allowedRoles)) { - // βœ… TAMBAHAN: Redirect ke dashboard yang sesuai, jangan abort - if ($currentRole === 'admin') { - return redirect()->route('admin.dashboard') - ->with('error', 'Anda tidak memiliki akses ke halaman tersebut.'); - } - - if ($currentRole === 'santri' || $currentRole === 'wali') { - return redirect()->route('santri.dashboard') - ->with('error', 'Anda tidak memiliki akses ke halaman tersebut.'); - } - - // Jika role tidak dikenali, logout paksa - Auth::logout(); - $request->session()->invalidate(); - $request->session()->regenerate(); - - return redirect('/admin/login') - ->with('error', 'Role tidak valid. Silakan login kembali.'); + // -- Cek apakah role pengguna termasuk dalam daftar yang diizinkan -- + if (!in_array(Auth::user()->role, $roles)) { + return redirect()->route('admin.dashboard') + ->with('error', 'Anda tidak memiliki akses ke halaman tersebut.'); } return $next($request); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/sim-pkpps/app/Mail/OtpMail.php b/sim-pkpps/app/Mail/OtpMail.php new file mode 100644 index 0000000..02270fa --- /dev/null +++ b/sim-pkpps/app/Mail/OtpMail.php @@ -0,0 +1,43 @@ +otp = $otp; + $this->nama = $nama; + } + + public function envelope(): Envelope + { + return new Envelope( + subject: 'Kode OTP Reset Password - SIM PKPPS', + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.otp', + ); + } + + public function attachments(): array + { + return []; + } +} diff --git a/sim-pkpps/app/Models/AbsensiKegiatan.php b/sim-pkpps/app/Models/AbsensiKegiatan.php index e76ed61..214612d 100644 --- a/sim-pkpps/app/Models/AbsensiKegiatan.php +++ b/sim-pkpps/app/Models/AbsensiKegiatan.php @@ -85,6 +85,8 @@ public function getStatusBadgeAttribute() 'Izin' => ' Izin', 'Sakit' => ' Sakit', 'Alpa' => ' Alpa', + 'Terlambat' => ' Terlambat', + 'Pulang' => ' Pulang', ]; return $badges[$this->status] ?? $this->status; @@ -120,6 +122,8 @@ public function getStatusBadgeClassAttribute() 'Izin' => 'badge-info', 'Sakit' => 'badge-warning', 'Alpa' => 'badge-danger', + 'Terlambat' => 'badge-warning', + 'Pulang' => 'badge-secondary', default => 'badge-secondary', }; } diff --git a/sim-pkpps/app/Models/PasswordResetOtp.php b/sim-pkpps/app/Models/PasswordResetOtp.php new file mode 100644 index 0000000..0e207c3 --- /dev/null +++ b/sim-pkpps/app/Models/PasswordResetOtp.php @@ -0,0 +1,30 @@ + 'datetime', + 'is_verified' => 'boolean', + ]; + + /** + * Cek apakah OTP sudah expired + */ + public function isExpired(): bool + { + return now()->greaterThan($this->expired_at); + } +} diff --git a/sim-pkpps/app/Models/PembayaranSpp.php b/sim-pkpps/app/Models/PembayaranSpp.php index a8bf376..1ad929d 100644 --- a/sim-pkpps/app/Models/PembayaranSpp.php +++ b/sim-pkpps/app/Models/PembayaranSpp.php @@ -27,15 +27,16 @@ class PembayaranSpp extends Model protected $casts = [ 'tanggal_bayar' => 'date', - 'batas_bayar' => 'date', - 'nominal' => 'decimal:2', - 'created_at' => 'datetime', - 'updated_at' => 'datetime', + 'batas_bayar' => 'date', + 'nominal' => 'decimal:2', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', ]; - /** - * Boot method untuk auto-generate ID - */ + // ══════════════════════════════════════════════════════ + // BOOT + // ══════════════════════════════════════════════════════ + protected static function boot() { parent::boot(); @@ -43,127 +44,208 @@ protected static function boot() static::creating(function ($model) { if (empty($model->id_pembayaran)) { $last = PembayaranSpp::orderBy('id', 'desc')->first(); - $num = $last ? intval(substr($last->id_pembayaran, 3)) + 1 : 1; + $num = $last ? intval(substr($last->id_pembayaran, 3)) + 1 : 1; $model->id_pembayaran = 'SPP' . str_pad($num, 3, '0', STR_PAD_LEFT); } }); } - /** - * Relasi: Pembayaran SPP milik satu Santri - */ + // ══════════════════════════════════════════════════════ + // RELASI + // ══════════════════════════════════════════════════════ + public function santri() { return $this->belongsTo(Santri::class, 'id_santri', 'id_santri'); } + // ══════════════════════════════════════════════════════ + // CICILAN HELPERS + // + // Status di DB tetap "Belum Lunas" (tidak ubah enum). + // Cicilan dideteksi dari keterangan berformat JSON: + // {"terbayar": 150000, "catatan": "Cicilan ke-1"} + // + // Keterangan teks biasa (non-JSON) tetap terbaca normal. + // ══════════════════════════════════════════════════════ + /** - * Accessor: Nama bulan dalam bahasa Indonesia + * Cek apakah record ini berstatus cicilan + * (status Belum Lunas + ada data terbayar di keterangan). */ - public function getBulanNamaAttribute() + public function isCicilan(): bool { - $bulanIndo = [ - 1 => 'Januari', 2 => 'Februari', 3 => 'Maret', - 4 => 'April', 5 => 'Mei', 6 => 'Juni', - 7 => 'Juli', 8 => 'Agustus', 9 => 'September', - 10 => 'Oktober', 11 => 'November', 12 => 'Desember' - ]; - - return $bulanIndo[$this->bulan] ?? '-'; + if ($this->status !== 'Belum Lunas') return false; + $data = $this->getCicilanData(); + return $data !== null && ($data['terbayar'] ?? 0) > 0; } /** - * Accessor: Periode lengkap (Januari 2024) + * Ambil array cicilan dari keterangan, atau null jika bukan JSON cicilan. */ - public function getPeriodeLengkapAttribute() + public function getCicilanData(): ?array + { + if (!$this->keterangan) return null; + $decoded = json_decode($this->keterangan, true); + if (json_last_error() !== JSON_ERROR_NONE) return null; + if (!array_key_exists('terbayar', $decoded)) return null; + return $decoded; + } + + /** + * Nominal yang sudah dibayar. + */ + public function getNominalTerbayarAttribute(): float + { + if ($this->status === 'Lunas') return (float) $this->nominal; + $data = $this->getCicilanData(); + return $data ? (float) ($data['terbayar'] ?? 0) : 0; + } + + /** + * Sisa yang belum dibayar. + */ + public function getNominalSisaAttribute(): float + { + return max(0, (float) $this->nominal - $this->nominal_terbayar); + } + + /** + * Persentase cicilan (0–100). + */ + public function getPorsentaseCicilanAttribute(): int + { + if (!$this->nominal || (float) $this->nominal == 0) return 0; + return (int) min(100, round(($this->nominal_terbayar / (float) $this->nominal) * 100)); + } + + /** + * Simpan progres cicilan ke keterangan (JSON). + * Status DB tidak diubah β€” tetap "Belum Lunas". + */ + public function setCicilan(float $terbayar, ?string $catatan = null): void + { + // Jika keterangan sebelumnya teks biasa, pindahkan sebagai catatan + if ($this->keterangan && !$this->getCicilanData()) { + $catatan = $catatan ?? $this->keterangan; + } + + $data = ['terbayar' => $terbayar]; + if ($catatan) $data['catatan'] = $catatan; + + $this->keterangan = json_encode($data); + } + + /** + * Baca catatan teks (dari JSON atau teks biasa). + */ + public function getCatatanTeksAttribute(): ?string + { + if (!$this->keterangan) return null; + $data = $this->getCicilanData(); + if ($data) return $data['catatan'] ?? null; + return $this->keterangan; + } + + // ══════════════════════════════════════════════════════ + // ACCESSORS + // ══════════════════════════════════════════════════════ + + public function getBulanNamaAttribute(): string + { + $bulanIndo = [ + 1 => 'Januari', 2 => 'Februari', 3 => 'Maret', + 4 => 'April', 5 => 'Mei', 6 => 'Juni', + 7 => 'Juli', 8 => 'Agustus', 9 => 'September', + 10 => 'Oktober',11 => 'November', 12 => 'Desember' + ]; + return $bulanIndo[$this->bulan] ?? '-'; + } + + public function getPeriodeLengkapAttribute(): string { return $this->bulan_nama . ' ' . $this->tahun; } - /** - * Accessor: Status Badge HTML - */ - public function getStatusBadgeAttribute() - { - if ($this->status === 'Lunas') { - return ' Lunas'; - } - - // Cek apakah telat - if ($this->isTelat()) { - return ' Belum Lunas (Telat)'; - } - - return ' Belum Lunas'; - } - - /** - * Cek apakah pembayaran sudah telat - */ - public function isTelat() - { - if ($this->status === 'Lunas') { - return false; - } - - return Carbon::now()->isAfter($this->batas_bayar); - } - - /** - * Accessor: Nominal format Rupiah - */ - public function getNominalFormatAttribute() + public function getNominalFormatAttribute(): string { return 'Rp ' . number_format($this->nominal, 0, ',', '.'); } + public function getNominalTerbayarFormatAttribute(): string + { + return 'Rp ' . number_format($this->nominal_terbayar, 0, ',', '.'); + } + + public function getNominalSisaFormatAttribute(): string + { + return 'Rp ' . number_format($this->nominal_sisa, 0, ',', '.'); + } + /** - * Scope: Filter pembayaran belum lunas + * Status Badge HTML β€” mengenali cicilan dari keterangan JSON, + * bukan dari nilai kolom status. */ + public function getStatusBadgeAttribute(): string + { + if ($this->status === 'Lunas') { + return ' Lunas'; + } + + if ($this->isCicilan()) { + return ' Cicilan ' . $this->porsentase_cicilan . '%'; + } + + if ($this->isTelat()) { + return ' Belum Lunas (Telat)'; + } + + return ' Belum Lunas'; + } + + // ══════════════════════════════════════════════════════ + // HELPERS + // ══════════════════════════════════════════════════════ + + public function isTelat(): bool + { + if ($this->status === 'Lunas') return false; + return Carbon::now()->isAfter($this->batas_bayar); + } + + // ══════════════════════════════════════════════════════ + // SCOPES + // ══════════════════════════════════════════════════════ + public function scopeBelumLunas($query) { return $query->where('status', 'Belum Lunas'); } - /** - * Scope: Filter pembayaran lunas - */ public function scopeLunas($query) { return $query->where('status', 'Lunas'); } - /** - * Scope: Filter pembayaran telat - */ public function scopeTelat($query) { return $query->where('status', 'Belum Lunas') ->where('batas_bayar', '<', Carbon::now()); } - /** - * Scope: Filter by tahun - */ public function scopeTahun($query, $tahun) { return $query->where('tahun', $tahun); } - /** - * Scope: Filter by bulan - */ public function scopeBulan($query, $bulan) { return $query->where('bulan', $bulan); } - /** - * Scope: Search - */ public function scopeSearch($query, $search) { - return $query->whereHas('santri', function($q) use ($search) { + return $query->whereHas('santri', function ($q) use ($search) { $q->where('nama_lengkap', 'like', "%{$search}%") ->orWhere('id_santri', 'like', "%{$search}%") ->orWhere('nis', 'like', "%{$search}%"); diff --git a/sim-pkpps/app/Models/Santri.php b/sim-pkpps/app/Models/Santri.php index 8e8cf8c..92fbd26 100644 --- a/sim-pkpps/app/Models/Santri.php +++ b/sim-pkpps/app/Models/Santri.php @@ -52,20 +52,28 @@ protected static function boot() } /** - * Relasi: Santri memiliki satu User Account (hasOne) + * Relasi: Santri memiliki banyak akun (santri_accounts) */ - public function user() + public function santriAccount() { - return $this->hasOne(User::class, 'role_id', 'id_santri') - ->where('role', 'santri'); + return $this->hasMany(SantriAccount::class, 'id_santri', 'id_santri'); } /** - * Relasi: Santri memiliki satu akun Wali (orang tua) + * Relasi: Santri memiliki satu User Account (hasOne) - LEGACY + */ + public function user() + { + return $this->hasOne(SantriAccount::class, 'id_santri', 'id_santri') + ->where('role', 'santri'); + } + + /** + * Relasi: Santri memiliki satu akun Wali (orang tua) - LEGACY */ public function waliUser() { - return $this->hasOne(User::class, 'role_id', 'id_santri') + return $this->hasOne(SantriAccount::class, 'id_santri', 'id_santri') ->where('role', 'wali'); } diff --git a/sim-pkpps/app/Models/SantriAccount.php b/sim-pkpps/app/Models/SantriAccount.php new file mode 100644 index 0000000..513832d --- /dev/null +++ b/sim-pkpps/app/Models/SantriAccount.php @@ -0,0 +1,63 @@ + 'hashed' β€” hanya Laravel 10+ + protected $casts = [ + 'last_login' => 'datetime', + ]; + + // Ganti dengan mutator manual + public function setPasswordAttribute(string $value): void + { + // Cegah double hash + if ( + !str_starts_with($value, '$2y$') && + !str_starts_with($value, '$argon2i$') && + !str_starts_with($value, '$argon2id$') + ) { + $this->attributes['password'] = bcrypt($value); + } else { + $this->attributes['password'] = $value; + } + } + + public function santri() + { + return $this->belongsTo(Santri::class, 'id_santri', 'id_santri'); + } + + public function isSantri(): bool + { + return $this->role === 'santri'; + } + + public function isWali(): bool + { + return $this->role === 'wali'; + } +} \ No newline at end of file diff --git a/sim-pkpps/app/Models/User.php b/sim-pkpps/app/Models/User.php index b54bdda..b705020 100644 --- a/sim-pkpps/app/Models/User.php +++ b/sim-pkpps/app/Models/User.php @@ -18,7 +18,6 @@ class User extends Authenticatable 'username', 'password', 'role', - 'role_id', ]; protected $hidden = [ @@ -31,35 +30,47 @@ class User extends Authenticatable 'password' => 'hashed', ]; - /** - * Relasi ke Santri - */ - public function santri() - { - return $this->belongsTo(Santri::class, 'role_id', 'id_santri'); - } + // ══════════════════ HELPER METHODS ══════════════════ /** - * Relasi ke Wali + * Cek apakah user adalah admin (semua role admin) */ - public function wali() - { - return $this->belongsTo(Wali::class, 'role_id', 'id_wali'); - } - - // Helper methods public function isAdmin() { - return $this->role === 'admin'; + return in_array($this->role, ['super_admin', 'akademik', 'pamong']); } - public function isSantri() + /** + * Cek apakah user adalah super admin + */ + public function isSuperAdmin() { - return $this->role === 'santri'; + return $this->role === 'super_admin'; } - public function isWali() + /** + * Cek apakah user adalah akademik + */ + public function isAkademik() { - return $this->role === 'wali'; + return $this->role === 'akademik'; + } + + /** + * Cek apakah user adalah pamong + */ + public function isPamong() + { + return $this->role === 'pamong'; + } + + /** + * Cek apakah user memiliki salah satu role yang diberikan. + * Contoh: $user->hasRole('super_admin', 'akademik') + */ + public function hasRole() + { + $roles = func_get_args(); + return in_array($this->role, $roles); } } \ No newline at end of file diff --git a/sim-pkpps/app/Providers/AppServiceProvider.php b/sim-pkpps/app/Providers/AppServiceProvider.php index 452e6b6..620f72d 100644 --- a/sim-pkpps/app/Providers/AppServiceProvider.php +++ b/sim-pkpps/app/Providers/AppServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use Illuminate\Support\ServiceProvider; +use Illuminate\Pagination\Paginator; class AppServiceProvider extends ServiceProvider { @@ -19,6 +20,7 @@ public function register(): void */ public function boot(): void { - // + Paginator::defaultView('vendor.pagination.custom'); + Paginator::defaultSimpleView('vendor.pagination.custom'); } } diff --git a/sim-pkpps/app/Services/CapaianAccessService.php b/sim-pkpps/app/Services/CapaianAccessService.php new file mode 100644 index 0000000..41c9bde --- /dev/null +++ b/sim-pkpps/app/Services/CapaianAccessService.php @@ -0,0 +1,119 @@ + false, + 'opened_by' => null, + 'opened_at' => null, + 'closed_at' => null, + 'catatan' => null, + 'id_semester' => null, + // Opsional: auto-close setelah X jam (null = manual) + 'auto_close_at'=> null, + ]); + } + + /** + * Buka akses input capaian untuk santri. + */ + public static function open(array $params = []): void + { + $config = self::getConfig(); + + $autoCloseAt = null; + if (!empty($params['durasi_jam'])) { + $autoCloseAt = now()->addHours((int) $params['durasi_jam'])->toIso8601String(); + } + + $config = array_merge($config, [ + 'is_open' => true, + 'opened_by' => $params['opened_by'] ?? auth()->user()?->name, + 'opened_at' => now()->toIso8601String(), + 'closed_at' => null, + 'catatan' => $params['catatan'] ?? null, + 'id_semester' => $params['id_semester'] ?? null, + 'auto_close_at' => $autoCloseAt, + ]); + + Cache::put(self::CACHE_KEY, $config, now()->addMinutes(self::CACHE_TTL)); + } + + /** + * Tutup akses input capaian. + */ + public static function close(): void + { + $config = self::getConfig(); + $config['is_open'] = false; + $config['closed_at'] = now()->toIso8601String(); + Cache::put(self::CACHE_KEY, $config, now()->addMinutes(self::CACHE_TTL)); + } + + /** + * Cek apakah akses sedang dibuka. + * Otomatis tutup jika sudah melewati auto_close_at. + */ + public static function isOpen(): bool + { + $config = self::getConfig(); + + if (!$config['is_open']) return false; + + // Auto-close check + if (!empty($config['auto_close_at'])) { + if (now()->isAfter($config['auto_close_at'])) { + self::close(); + return false; + } + } + + return true; + } + + /** + * Cek apakah semester yang dibuka cocok dengan semester tertentu. + * Jika id_semester di config null, berarti semua semester boleh. + */ + public static function isOpenForSemester(?string $idSemester): bool + { + if (!self::isOpen()) return false; + + $config = self::getConfig(); + if (empty($config['id_semester'])) return true; // semua semester + + return $config['id_semester'] === $idSemester; + } + + /** + * Ambil sisa waktu auto-close dalam format human readable. + */ + public static function getSisaWaktu(): ?string + { + $config = self::getConfig(); + if (empty($config['auto_close_at'])) return null; + + $close = \Carbon\Carbon::parse($config['auto_close_at']); + if (now()->isAfter($close)) return 'Sudah berakhir'; + + return now()->diffForHumans($close, ['parts' => 2, 'syntax' => \Carbon\CarbonInterface::DIFF_ABSOLUTE]); + } +} \ No newline at end of file diff --git a/sim-pkpps/bootstrap/cache/.gitignore b/sim-pkpps/bootstrap/cache/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/sim-pkpps/bootstrap/cache/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/sim-pkpps/bootstrap/cache/packages.php b/sim-pkpps/bootstrap/cache/packages.php new file mode 100644 index 0000000..cdd01f8 --- /dev/null +++ b/sim-pkpps/bootstrap/cache/packages.php @@ -0,0 +1,74 @@ + + array ( + 'aliases' => + array ( + 'PDF' => 'Barryvdh\\DomPDF\\Facade\\Pdf', + 'Pdf' => 'Barryvdh\\DomPDF\\Facade\\Pdf', + ), + 'providers' => + array ( + 0 => 'Barryvdh\\DomPDF\\ServiceProvider', + ), + ), + 'laravel/breeze' => + array ( + 'providers' => + array ( + 0 => 'Laravel\\Breeze\\BreezeServiceProvider', + ), + ), + 'laravel/sail' => + array ( + 'providers' => + array ( + 0 => 'Laravel\\Sail\\SailServiceProvider', + ), + ), + 'laravel/sanctum' => + array ( + 'providers' => + array ( + 0 => 'Laravel\\Sanctum\\SanctumServiceProvider', + ), + ), + 'laravel/tinker' => + array ( + 'providers' => + array ( + 0 => 'Laravel\\Tinker\\TinkerServiceProvider', + ), + ), + 'nesbot/carbon' => + array ( + 'providers' => + array ( + 0 => 'Carbon\\Laravel\\ServiceProvider', + ), + ), + 'nunomaduro/collision' => + array ( + 'providers' => + array ( + 0 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider', + ), + ), + 'nunomaduro/termwind' => + array ( + 'providers' => + array ( + 0 => 'Termwind\\Laravel\\TermwindServiceProvider', + ), + ), + 'spatie/laravel-ignition' => + array ( + 'aliases' => + array ( + 'Flare' => 'Spatie\\LaravelIgnition\\Facades\\Flare', + ), + 'providers' => + array ( + 0 => 'Spatie\\LaravelIgnition\\IgnitionServiceProvider', + ), + ), +); \ No newline at end of file diff --git a/sim-pkpps/bootstrap/cache/services.php b/sim-pkpps/bootstrap/cache/services.php new file mode 100644 index 0000000..47b50cc --- /dev/null +++ b/sim-pkpps/bootstrap/cache/services.php @@ -0,0 +1,257 @@ + + array ( + 0 => 'Illuminate\\Auth\\AuthServiceProvider', + 1 => 'Illuminate\\Broadcasting\\BroadcastServiceProvider', + 2 => 'Illuminate\\Bus\\BusServiceProvider', + 3 => 'Illuminate\\Cache\\CacheServiceProvider', + 4 => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 5 => 'Illuminate\\Cookie\\CookieServiceProvider', + 6 => 'Illuminate\\Database\\DatabaseServiceProvider', + 7 => 'Illuminate\\Encryption\\EncryptionServiceProvider', + 8 => 'Illuminate\\Filesystem\\FilesystemServiceProvider', + 9 => 'Illuminate\\Foundation\\Providers\\FoundationServiceProvider', + 10 => 'Illuminate\\Hashing\\HashServiceProvider', + 11 => 'Illuminate\\Mail\\MailServiceProvider', + 12 => 'Illuminate\\Notifications\\NotificationServiceProvider', + 13 => 'Illuminate\\Pagination\\PaginationServiceProvider', + 14 => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider', + 15 => 'Illuminate\\Pipeline\\PipelineServiceProvider', + 16 => 'Illuminate\\Queue\\QueueServiceProvider', + 17 => 'Illuminate\\Redis\\RedisServiceProvider', + 18 => 'Illuminate\\Session\\SessionServiceProvider', + 19 => 'Illuminate\\Translation\\TranslationServiceProvider', + 20 => 'Illuminate\\Validation\\ValidationServiceProvider', + 21 => 'Illuminate\\View\\ViewServiceProvider', + 22 => 'Barryvdh\\DomPDF\\ServiceProvider', + 23 => 'Laravel\\Breeze\\BreezeServiceProvider', + 24 => 'Laravel\\Sail\\SailServiceProvider', + 25 => 'Laravel\\Sanctum\\SanctumServiceProvider', + 26 => 'Laravel\\Tinker\\TinkerServiceProvider', + 27 => 'Carbon\\Laravel\\ServiceProvider', + 28 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider', + 29 => 'Termwind\\Laravel\\TermwindServiceProvider', + 30 => 'Spatie\\LaravelIgnition\\IgnitionServiceProvider', + 31 => 'App\\Providers\\AppServiceProvider', + 32 => 'App\\Providers\\AuthServiceProvider', + 33 => 'App\\Providers\\EventServiceProvider', + 34 => 'App\\Providers\\RouteServiceProvider', + ), + 'eager' => + array ( + 0 => 'Illuminate\\Auth\\AuthServiceProvider', + 1 => 'Illuminate\\Cookie\\CookieServiceProvider', + 2 => 'Illuminate\\Database\\DatabaseServiceProvider', + 3 => 'Illuminate\\Encryption\\EncryptionServiceProvider', + 4 => 'Illuminate\\Filesystem\\FilesystemServiceProvider', + 5 => 'Illuminate\\Foundation\\Providers\\FoundationServiceProvider', + 6 => 'Illuminate\\Notifications\\NotificationServiceProvider', + 7 => 'Illuminate\\Pagination\\PaginationServiceProvider', + 8 => 'Illuminate\\Session\\SessionServiceProvider', + 9 => 'Illuminate\\View\\ViewServiceProvider', + 10 => 'Barryvdh\\DomPDF\\ServiceProvider', + 11 => 'Laravel\\Sanctum\\SanctumServiceProvider', + 12 => 'Carbon\\Laravel\\ServiceProvider', + 13 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider', + 14 => 'Termwind\\Laravel\\TermwindServiceProvider', + 15 => 'Spatie\\LaravelIgnition\\IgnitionServiceProvider', + 16 => 'App\\Providers\\AppServiceProvider', + 17 => 'App\\Providers\\AuthServiceProvider', + 18 => 'App\\Providers\\EventServiceProvider', + 19 => 'App\\Providers\\RouteServiceProvider', + ), + 'deferred' => + array ( + 'Illuminate\\Broadcasting\\BroadcastManager' => 'Illuminate\\Broadcasting\\BroadcastServiceProvider', + 'Illuminate\\Contracts\\Broadcasting\\Factory' => 'Illuminate\\Broadcasting\\BroadcastServiceProvider', + 'Illuminate\\Contracts\\Broadcasting\\Broadcaster' => 'Illuminate\\Broadcasting\\BroadcastServiceProvider', + 'Illuminate\\Bus\\Dispatcher' => 'Illuminate\\Bus\\BusServiceProvider', + 'Illuminate\\Contracts\\Bus\\Dispatcher' => 'Illuminate\\Bus\\BusServiceProvider', + 'Illuminate\\Contracts\\Bus\\QueueingDispatcher' => 'Illuminate\\Bus\\BusServiceProvider', + 'Illuminate\\Bus\\BatchRepository' => 'Illuminate\\Bus\\BusServiceProvider', + 'Illuminate\\Bus\\DatabaseBatchRepository' => 'Illuminate\\Bus\\BusServiceProvider', + 'cache' => 'Illuminate\\Cache\\CacheServiceProvider', + 'cache.store' => 'Illuminate\\Cache\\CacheServiceProvider', + 'cache.psr6' => 'Illuminate\\Cache\\CacheServiceProvider', + 'memcached.connector' => 'Illuminate\\Cache\\CacheServiceProvider', + 'Illuminate\\Cache\\RateLimiter' => 'Illuminate\\Cache\\CacheServiceProvider', + 'Illuminate\\Foundation\\Console\\AboutCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Cache\\Console\\ClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Cache\\Console\\ForgetCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ClearCompiledCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Auth\\Console\\ClearResetsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ConfigCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ConfigClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ConfigShowCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\DbCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\MonitorCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\PruneCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\ShowCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\TableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\WipeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\DownCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\EnvironmentCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\EnvironmentDecryptCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\EnvironmentEncryptCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\EventCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\EventClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\EventListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\KeyGenerateCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\OptimizeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\OptimizeClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\PackageDiscoverCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Cache\\Console\\PruneStaleTagsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\ClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\ListFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\FlushFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\ForgetFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\ListenCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\MonitorCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\PruneBatchesCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\PruneFailedJobsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\RestartCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\RetryCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\RetryBatchCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\WorkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\RouteCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\RouteClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\RouteListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\DumpCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\Seeds\\SeedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Console\\Scheduling\\ScheduleFinishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Console\\Scheduling\\ScheduleListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Console\\Scheduling\\ScheduleRunCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Console\\Scheduling\\ScheduleClearCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Console\\Scheduling\\ScheduleTestCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Console\\Scheduling\\ScheduleWorkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Console\\Scheduling\\ScheduleInterruptCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\ShowModelCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\StorageLinkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\StorageUnlinkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\UpCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ViewCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ViewClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Cache\\Console\\CacheTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\CastMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ChannelListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ChannelMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ComponentMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ConsoleMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Routing\\Console\\ControllerMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\DocsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\EventGenerateCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\EventMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ExceptionMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\Factories\\FactoryMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\JobMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\LangPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ListenerMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\MailMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Routing\\Console\\MiddlewareMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ModelMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\NotificationMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Notifications\\Console\\NotificationTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ObserverMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\PolicyMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ProviderMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\FailedTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\TableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\BatchesTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\RequestMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ResourceMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\RuleMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ScopeMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\Seeds\\SeederMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Session\\Console\\SessionTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ServeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\StubPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\TestMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\VendorPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ViewMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'migrator' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'migration.repository' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'migration.creator' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\Migrations\\MigrateCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\Migrations\\FreshCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\Migrations\\InstallCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\Migrations\\RefreshCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\Migrations\\ResetCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\Migrations\\RollbackCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\Migrations\\StatusCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\Migrations\\MigrateMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'composer' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'hash' => 'Illuminate\\Hashing\\HashServiceProvider', + 'hash.driver' => 'Illuminate\\Hashing\\HashServiceProvider', + 'mail.manager' => 'Illuminate\\Mail\\MailServiceProvider', + 'mailer' => 'Illuminate\\Mail\\MailServiceProvider', + 'Illuminate\\Mail\\Markdown' => 'Illuminate\\Mail\\MailServiceProvider', + 'auth.password' => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider', + 'auth.password.broker' => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider', + 'Illuminate\\Contracts\\Pipeline\\Hub' => 'Illuminate\\Pipeline\\PipelineServiceProvider', + 'pipeline' => 'Illuminate\\Pipeline\\PipelineServiceProvider', + 'queue' => 'Illuminate\\Queue\\QueueServiceProvider', + 'queue.connection' => 'Illuminate\\Queue\\QueueServiceProvider', + 'queue.failer' => 'Illuminate\\Queue\\QueueServiceProvider', + 'queue.listener' => 'Illuminate\\Queue\\QueueServiceProvider', + 'queue.worker' => 'Illuminate\\Queue\\QueueServiceProvider', + 'redis' => 'Illuminate\\Redis\\RedisServiceProvider', + 'redis.connection' => 'Illuminate\\Redis\\RedisServiceProvider', + 'translator' => 'Illuminate\\Translation\\TranslationServiceProvider', + 'translation.loader' => 'Illuminate\\Translation\\TranslationServiceProvider', + 'validator' => 'Illuminate\\Validation\\ValidationServiceProvider', + 'validation.presence' => 'Illuminate\\Validation\\ValidationServiceProvider', + 'Illuminate\\Contracts\\Validation\\UncompromisedVerifier' => 'Illuminate\\Validation\\ValidationServiceProvider', + 'Laravel\\Breeze\\Console\\InstallCommand' => 'Laravel\\Breeze\\BreezeServiceProvider', + 'Laravel\\Sail\\Console\\InstallCommand' => 'Laravel\\Sail\\SailServiceProvider', + 'Laravel\\Sail\\Console\\PublishCommand' => 'Laravel\\Sail\\SailServiceProvider', + 'command.tinker' => 'Laravel\\Tinker\\TinkerServiceProvider', + ), + 'when' => + array ( + 'Illuminate\\Broadcasting\\BroadcastServiceProvider' => + array ( + ), + 'Illuminate\\Bus\\BusServiceProvider' => + array ( + ), + 'Illuminate\\Cache\\CacheServiceProvider' => + array ( + ), + 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider' => + array ( + ), + 'Illuminate\\Hashing\\HashServiceProvider' => + array ( + ), + 'Illuminate\\Mail\\MailServiceProvider' => + array ( + ), + 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider' => + array ( + ), + 'Illuminate\\Pipeline\\PipelineServiceProvider' => + array ( + ), + 'Illuminate\\Queue\\QueueServiceProvider' => + array ( + ), + 'Illuminate\\Redis\\RedisServiceProvider' => + array ( + ), + 'Illuminate\\Translation\\TranslationServiceProvider' => + array ( + ), + 'Illuminate\\Validation\\ValidationServiceProvider' => + array ( + ), + 'Laravel\\Breeze\\BreezeServiceProvider' => + array ( + ), + 'Laravel\\Sail\\SailServiceProvider' => + array ( + ), + 'Laravel\\Tinker\\TinkerServiceProvider' => + array ( + ), + ), +); \ No newline at end of file diff --git a/sim-pkpps/composer.json b/sim-pkpps/composer.json index 9e492b1..d03a879 100644 --- a/sim-pkpps/composer.json +++ b/sim-pkpps/composer.json @@ -7,10 +7,12 @@ "require": { "php": "^8.1", "barryvdh/laravel-dompdf": "^3.1", + "doctrine/dbal": "^3.10", "guzzlehttp/guzzle": "^7.2", "laravel/framework": "^10.10", "laravel/sanctum": "^3.3", - "laravel/tinker": "^2.8" + "laravel/tinker": "^2.8", + "mpdf/mpdf": "^8.2" }, "require-dev": { "fakerphp/faker": "^1.9.1", diff --git a/sim-pkpps/composer.lock b/sim-pkpps/composer.lock index 3eedc5f..a35ed48 100644 --- a/sim-pkpps/composer.lock +++ b/sim-pkpps/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3b130f909d8c7acd5973043a958c243d", + "content-hash": "a470717879bee7fca3c22f312f8d5be7", "packages": [ { "name": "barryvdh/laravel-dompdf", @@ -287,6 +287,259 @@ }, "time": "2024-07-08T12:26:09+00:00" }, + { + "name": "doctrine/dbal", + "version": "3.10.4", + "source": { + "type": "git", + "url": "https://github.com/doctrine/dbal.git", + "reference": "63a46cb5aa6f60991186cc98c1d1b50c09311868" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/63a46cb5aa6f60991186cc98c1d1b50c09311868", + "reference": "63a46cb5aa6f60991186cc98c1d1b50c09311868", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2", + "doctrine/deprecations": "^0.5.3|^1", + "doctrine/event-manager": "^1|^2", + "php": "^7.4 || ^8.0", + "psr/cache": "^1|^2|^3", + "psr/log": "^1|^2|^3" + }, + "conflict": { + "doctrine/cache": "< 1.11" + }, + "require-dev": { + "doctrine/cache": "^1.11|^2.0", + "doctrine/coding-standard": "14.0.0", + "fig/log-test": "^1", + "jetbrains/phpstorm-stubs": "2023.1", + "phpstan/phpstan": "2.1.30", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "9.6.29", + "slevomat/coding-standard": "8.24.0", + "squizlabs/php_codesniffer": "4.0.0", + "symfony/cache": "^5.4|^6.0|^7.0|^8.0", + "symfony/console": "^4.4|^5.4|^6.0|^7.0|^8.0" + }, + "suggest": { + "symfony/console": "For helpful console commands such as SQL execution and import of files." + }, + "bin": [ + "bin/doctrine-dbal" + ], + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\DBAL\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + } + ], + "description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.", + "homepage": "https://www.doctrine-project.org/projects/dbal.html", + "keywords": [ + "abstraction", + "database", + "db2", + "dbal", + "mariadb", + "mssql", + "mysql", + "oci8", + "oracle", + "pdo", + "pgsql", + "postgresql", + "queryobject", + "sasql", + "sql", + "sqlite", + "sqlserver", + "sqlsrv" + ], + "support": { + "issues": "https://github.com/doctrine/dbal/issues", + "source": "https://github.com/doctrine/dbal/tree/3.10.4" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal", + "type": "tidelift" + } + ], + "time": "2025-11-29T10:46:08+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, + { + "name": "doctrine/event-manager", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/event-manager.git", + "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/event-manager/zipball/dda33921b198841ca8dbad2eaa5d4d34769d18cf", + "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/common": "<2.9" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "phpdocumentor/guides-cli": "^1.4", + "phpstan/phpstan": "^2.1.32", + "phpunit/phpunit": "^10.5.58" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.", + "homepage": "https://www.doctrine-project.org/projects/event-manager.html", + "keywords": [ + "event", + "event dispatcher", + "event manager", + "event system", + "events" + ], + "support": { + "issues": "https://github.com/doctrine/event-manager/issues", + "source": "https://github.com/doctrine/event-manager/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager", + "type": "tidelift" + } + ], + "time": "2026-01-29T07:11:08+00:00" + }, { "name": "doctrine/inflector", "version": "2.1.0", @@ -2291,6 +2544,239 @@ ], "time": "2025-03-24T10:02:05+00:00" }, + { + "name": "mpdf/mpdf", + "version": "v8.2.7", + "source": { + "type": "git", + "url": "https://github.com/mpdf/mpdf.git", + "reference": "b59670a09498689c33ce639bac8f5ba26721dab3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mpdf/mpdf/zipball/b59670a09498689c33ce639bac8f5ba26721dab3", + "reference": "b59670a09498689c33ce639bac8f5ba26721dab3", + "shasum": "" + }, + "require": { + "ext-gd": "*", + "ext-mbstring": "*", + "mpdf/psr-http-message-shim": "^1.0 || ^2.0", + "mpdf/psr-log-aware-trait": "^2.0 || ^3.0", + "myclabs/deep-copy": "^1.7", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": "^5.6 || ^7.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "setasign/fpdi": "^2.1" + }, + "require-dev": { + "mockery/mockery": "^1.3.0", + "mpdf/qrcode": "^1.1.0", + "squizlabs/php_codesniffer": "^3.5.0", + "tracy/tracy": "~2.5", + "yoast/phpunit-polyfills": "^1.0" + }, + "suggest": { + "ext-bcmath": "Needed for generation of some types of barcodes", + "ext-xml": "Needed mainly for SVG manipulation", + "ext-zlib": "Needed for compression of embedded resources, such as fonts" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Mpdf\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-only" + ], + "authors": [ + { + "name": "MatΔ›j HumpΓ‘l", + "role": "Developer, maintainer" + }, + { + "name": "Ian Back", + "role": "Developer (retired)" + } + ], + "description": "PHP library generating PDF files from UTF-8 encoded HTML", + "homepage": "https://mpdf.github.io", + "keywords": [ + "pdf", + "php", + "utf-8" + ], + "support": { + "docs": "https://mpdf.github.io", + "issues": "https://github.com/mpdf/mpdf/issues", + "source": "https://github.com/mpdf/mpdf" + }, + "funding": [ + { + "url": "https://www.paypal.me/mpdf", + "type": "custom" + } + ], + "time": "2025-12-01T10:18:02+00:00" + }, + { + "name": "mpdf/psr-http-message-shim", + "version": "v2.0.1", + "source": { + "type": "git", + "url": "https://github.com/mpdf/psr-http-message-shim.git", + "reference": "f25a0153d645e234f9db42e5433b16d9b113920f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mpdf/psr-http-message-shim/zipball/f25a0153d645e234f9db42e5433b16d9b113920f", + "reference": "f25a0153d645e234f9db42e5433b16d9b113920f", + "shasum": "" + }, + "require": { + "psr/http-message": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Mpdf\\PsrHttpMessageShim\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Dorison", + "email": "mark@chromatichq.com" + }, + { + "name": "Kristofer Widholm", + "email": "kristofer@chromatichq.com" + }, + { + "name": "Nigel Cunningham", + "email": "nigel.cunningham@technocrat.com.au" + } + ], + "description": "Shim to allow support of different psr/message versions.", + "support": { + "issues": "https://github.com/mpdf/psr-http-message-shim/issues", + "source": "https://github.com/mpdf/psr-http-message-shim/tree/v2.0.1" + }, + "time": "2023-10-02T14:34:03+00:00" + }, + { + "name": "mpdf/psr-log-aware-trait", + "version": "v3.0.0", + "source": { + "type": "git", + "url": "https://github.com/mpdf/psr-log-aware-trait.git", + "reference": "a633da6065e946cc491e1c962850344bb0bf3e78" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mpdf/psr-log-aware-trait/zipball/a633da6065e946cc491e1c962850344bb0bf3e78", + "reference": "a633da6065e946cc491e1c962850344bb0bf3e78", + "shasum": "" + }, + "require": { + "psr/log": "^3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Mpdf\\PsrLogAwareTrait\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Dorison", + "email": "mark@chromatichq.com" + }, + { + "name": "Kristofer Widholm", + "email": "kristofer@chromatichq.com" + } + ], + "description": "Trait to allow support of different psr/log versions.", + "support": { + "issues": "https://github.com/mpdf/psr-log-aware-trait/issues", + "source": "https://github.com/mpdf/psr-log-aware-trait/tree/v3.0.0" + }, + "time": "2023-05-03T06:19:36+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, { "name": "nesbot/carbon", "version": "2.73.0", @@ -2692,6 +3178,56 @@ ], "time": "2024-11-21T10:36:35+00:00" }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.4", @@ -2767,6 +3303,55 @@ ], "time": "2025-08-21T11:53:16+00:00" }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, { "name": "psr/clock", "version": "1.0.0", @@ -3521,6 +4106,78 @@ }, "time": "2025-07-11T13:20:48+00:00" }, + { + "name": "setasign/fpdi", + "version": "v2.6.4", + "source": { + "type": "git", + "url": "https://github.com/Setasign/FPDI.git", + "reference": "4b53852fde2734ec6a07e458a085db627c60eada" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Setasign/FPDI/zipball/4b53852fde2734ec6a07e458a085db627c60eada", + "reference": "4b53852fde2734ec6a07e458a085db627c60eada", + "shasum": "" + }, + "require": { + "ext-zlib": "*", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "setasign/tfpdf": "<1.31" + }, + "require-dev": { + "phpunit/phpunit": "^7", + "setasign/fpdf": "~1.8.6", + "setasign/tfpdf": "~1.33", + "squizlabs/php_codesniffer": "^3.5", + "tecnickcom/tcpdf": "^6.8" + }, + "suggest": { + "setasign/fpdf": "FPDI will extend this class but as it is also possible to use TCPDF or tFPDF as an alternative. There's no fixed dependency configured." + }, + "type": "library", + "autoload": { + "psr-4": { + "setasign\\Fpdi\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Slabon", + "email": "jan.slabon@setasign.com", + "homepage": "https://www.setasign.com" + }, + { + "name": "Maximilian Kresse", + "email": "maximilian.kresse@setasign.com", + "homepage": "https://www.setasign.com" + } + ], + "description": "FPDI is a collection of PHP classes facilitating developers to read pages from existing PDF documents and use them as templates in FPDF. Because it is also possible to use FPDI with TCPDF, there are no fixed dependencies defined. Please see suggestions for packages which evaluates the dependencies automatically.", + "homepage": "https://www.setasign.com/fpdi", + "keywords": [ + "fpdf", + "fpdi", + "pdf" + ], + "support": { + "issues": "https://github.com/Setasign/FPDI/issues", + "source": "https://github.com/Setasign/FPDI/tree/v2.6.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/setasign/fpdi", + "type": "tidelift" + } + ], + "time": "2025-08-05T09:57:14+00:00" + }, { "name": "symfony/console", "version": "v6.4.25", @@ -6500,66 +7157,6 @@ }, "time": "2024-05-16T03:13:13+00:00" }, - { - "name": "myclabs/deep-copy", - "version": "1.13.4", - "source": { - "type": "git", - "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "shasum": "" - }, - "require": { - "php": "^7.1 || ^8.0" - }, - "conflict": { - "doctrine/collections": "<1.6.8", - "doctrine/common": "<2.13.3 || >=3 <3.2.2" - }, - "require-dev": { - "doctrine/collections": "^1.6.8", - "doctrine/common": "^2.13.3 || ^3.2.2", - "phpspec/prophecy": "^1.10", - "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" - }, - "type": "library", - "autoload": { - "files": [ - "src/DeepCopy/deep_copy.php" - ], - "psr-4": { - "DeepCopy\\": "src/DeepCopy/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Create deep copies (clones) of your objects", - "keywords": [ - "clone", - "copy", - "duplicate", - "object", - "object graph" - ], - "support": { - "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" - }, - "funding": [ - { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" - } - ], - "time": "2025-08-01T08:46:24+00:00" - }, { "name": "nunomaduro/collision", "version": "v7.12.0", @@ -8674,5 +9271,5 @@ "php": "^8.1" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/sim-pkpps/config/app.php b/sim-pkpps/config/app.php index 9207160..c303137 100644 --- a/sim-pkpps/config/app.php +++ b/sim-pkpps/config/app.php @@ -70,7 +70,7 @@ | */ - 'timezone' => 'UTC', + 'timezone' => 'Asia/Jakarta', /* |-------------------------------------------------------------------------- diff --git a/sim-pkpps/config/auth.php b/sim-pkpps/config/auth.php index 9548c15..2046cb8 100644 --- a/sim-pkpps/config/auth.php +++ b/sim-pkpps/config/auth.php @@ -40,6 +40,10 @@ 'driver' => 'session', 'provider' => 'users', ], + 'santri' => [ + 'driver' => 'session', + 'provider' => 'santri_accounts', + ], ], /* @@ -64,6 +68,10 @@ 'driver' => 'eloquent', 'model' => App\Models\User::class, ], + 'santri_accounts' => [ + 'driver' => 'eloquent', + 'model' => App\Models\SantriAccount::class, + ], // 'users' => [ // 'driver' => 'database', diff --git a/sim-pkpps/config/sanctum.php b/sim-pkpps/config/sanctum.php index 35d75b3..b62f835 100644 --- a/sim-pkpps/config/sanctum.php +++ b/sim-pkpps/config/sanctum.php @@ -33,7 +33,7 @@ | */ - 'guard' => ['web'], + 'guard' => ['santri'], /* |-------------------------------------------------------------------------- diff --git a/sim-pkpps/database/migrations/2026_02_24_000001_update_users_role_enum.php b/sim-pkpps/database/migrations/2026_02_24_000001_update_users_role_enum.php new file mode 100644 index 0000000..f4d6398 --- /dev/null +++ b/sim-pkpps/database/migrations/2026_02_24_000001_update_users_role_enum.php @@ -0,0 +1,44 @@ +where('role', 'admin') + ->update(['role' => 'super_admin']); + + // -- Langkah 3: Hapus 'admin' dari enum, set default baru -- + DB::statement("ALTER TABLE `users` MODIFY `role` ENUM('super_admin','akademik','pamong','santri','wali') NOT NULL DEFAULT 'super_admin'"); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // -- Langkah 1: Perluas enum kembali dengan 'admin' -- + DB::statement("ALTER TABLE `users` MODIFY `role` ENUM('admin','super_admin','akademik','pamong','santri','wali') NOT NULL DEFAULT 'super_admin'"); + + // -- Langkah 2: Kembalikan role admin roles ke 'admin' -- + DB::table('users') + ->whereIn('role', ['super_admin', 'akademik', 'pamong']) + ->update(['role' => 'admin']); + + // -- Langkah 3: Kembalikan ke enum lama -- + DB::statement("ALTER TABLE `users` MODIFY `role` ENUM('admin','santri','wali') NOT NULL DEFAULT 'admin'"); + } +}; diff --git a/sim-pkpps/database/migrations/2026_02_25_083524_add_terlambat_pulang_to_absensi_kegiatans.php b/sim-pkpps/database/migrations/2026_02_25_083524_add_terlambat_pulang_to_absensi_kegiatans.php new file mode 100644 index 0000000..9b139c2 --- /dev/null +++ b/sim-pkpps/database/migrations/2026_02_25_083524_add_terlambat_pulang_to_absensi_kegiatans.php @@ -0,0 +1,26 @@ +id(); + $table->string('id_santri'); + $table->string('username')->unique(); + $table->string('password'); + $table->enum('role', ['santri', 'wali'])->default('santri'); + $table->rememberToken(); + $table->timestamp('last_login')->nullable(); + $table->timestamps(); + + $table->foreign('id_santri') + ->references('id_santri') + ->on('santris') + ->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('santri_accounts'); + } +}; diff --git a/sim-pkpps/database/migrations/2026_02_25_100002_remove_santri_wali_from_users_table.php b/sim-pkpps/database/migrations/2026_02_25_100002_remove_santri_wali_from_users_table.php new file mode 100644 index 0000000..204ad84 --- /dev/null +++ b/sim-pkpps/database/migrations/2026_02_25_100002_remove_santri_wali_from_users_table.php @@ -0,0 +1,43 @@ +whereIn('role', ['santri', 'wali'])->delete(); + + // -- Ubah enum role hanya untuk admin (raw SQL karena DBAL tidak support enum) -- + DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('super_admin','akademik','pamong') NOT NULL DEFAULT 'akademik'"); + + // -- Hapus kolom role_id jika ada -- + if (Schema::hasColumn('users', 'role_id')) { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('role_id'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('super_admin','akademik','pamong','santri','wali') NOT NULL DEFAULT 'akademik'"); + + if (!Schema::hasColumn('users', 'role_id')) { + Schema::table('users', function (Blueprint $table) { + $table->string('role_id')->nullable()->after('role'); + }); + } + } +}; diff --git a/sim-pkpps/database/migrations/2026_02_28_153436_add_khatam_to_santris_status_enum.php b/sim-pkpps/database/migrations/2026_02_28_153436_add_khatam_to_santris_status_enum.php new file mode 100644 index 0000000..2d02df0 --- /dev/null +++ b/sim-pkpps/database/migrations/2026_02_28_153436_add_khatam_to_santris_status_enum.php @@ -0,0 +1,26 @@ +id(); + $table->string('email')->index(); + $table->string('otp', 6); + $table->timestamp('expired_at'); + $table->boolean('is_verified')->default(false); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('password_reset_otps'); + } +}; diff --git a/sim-pkpps/database/seeders/DatabaseSeeder.php b/sim-pkpps/database/seeders/DatabaseSeeder.php index a9f4519..3dc37bc 100644 --- a/sim-pkpps/database/seeders/DatabaseSeeder.php +++ b/sim-pkpps/database/seeders/DatabaseSeeder.php @@ -12,11 +12,8 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - // \App\Models\User::factory(10)->create(); - - // \App\Models\User::factory()->create([ - // 'name' => 'Test User', - // 'email' => 'test@example.com', - // ]); + $this->call([ + SantriSeeder::class, + ]); } } diff --git a/sim-pkpps/database/seeders/SantriSeeder.php b/sim-pkpps/database/seeders/SantriSeeder.php new file mode 100644 index 0000000..09968a7 --- /dev/null +++ b/sim-pkpps/database/seeders/SantriSeeder.php @@ -0,0 +1,329 @@ +whereIn('id_santri', array_map(fn($n) => 'S' . str_pad($n, 3, '0', STR_PAD_LEFT), range(3, 99))) + ->delete(); + DB::table('santris') + ->whereIn('id_santri', array_map(fn($n) => 'S' . str_pad($n, 3, '0', STR_PAD_LEFT), range(3, 99))) + ->delete(); + DB::statement('SET FOREIGN_KEY_CHECKS=1;'); + + $tahunAjaran = '2024/2025'; + $now = Carbon::now(); + + $santriList = [ + // ============================================================ + // S003 - S034: Edit bagian 'kelas' sesuai data asli! + // ============================================================ + [ + 'santri' => ['id_santri' => 'S003', 'nis' => '510035160089253003', 'nama_lengkap' => 'Altaf Baihaqi Amrullah', 'jenis_kelamin' => 'Laki-laki', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Tiebuk RT 006 RW 003 Wiyu, Pacet, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => 'Baihaqi', 'nomor_hp_ortu' => '+6281234560003', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS001', 'is_primary' => true], // PB ← UBAH SESUAI DATA + ['kode' => 'KLS004', 'is_primary' => false], // SD 1 ← UBAH SESUAI DATA + ], + ], + [ + 'santri' => ['id_santri' => 'S004', 'nis' => '510035160089253004', 'nama_lengkap' => 'Aminati Yusrin Isnaini', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Brangkal RT 002 RW 001 Brangkal, Sooko, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => 'Yusrin', 'nomor_hp_ortu' => '+6281234560004', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS001', 'is_primary' => true], // PB ← UBAH + ['kode' => 'KLS005', 'is_primary' => false], // SD 2 ← UBAH + ], + ], + [ + 'santri' => ['id_santri' => 'S005', 'nis' => '510035160089253005', 'nama_lengkap' => 'Ananda Novreandis', 'jenis_kelamin' => 'Laki-laki', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Jampirogo RT 006 RW 001 Jampirogo, Sooko, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => 'Novreandis Sr.', 'nomor_hp_ortu' => '+6281234560005', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS001', 'is_primary' => true], // PB ← UBAH + ['kode' => 'KLS004', 'is_primary' => false], // SD 1 ← UBAH + ], + ], + [ + 'santri' => ['id_santri' => 'S006', 'nis' => '510035160089253006', 'nama_lengkap' => 'Andika Maulana Ishaq', 'jenis_kelamin' => 'Laki-laki', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Jemparing RT 001 RW 001 Pakel, Bareng, Jombang', 'daerah_asal' => 'Jombang Selatan', 'nama_orang_tua' => 'Ishaq', 'nomor_hp_ortu' => '+6281234560006', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS002', 'is_primary' => true], // Lambatan ← UBAH + ['kode' => 'KLS010', 'is_primary' => false], // SMP 7 ← UBAH + ], + ], + [ + 'santri' => ['id_santri' => 'S007', 'nis' => '510035160089253007', 'nama_lengkap' => 'Anggraini Nur Dina Fahma', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Plosarejo RT 002 RW 001 Tlogoagung, Kedungadem, Bojonegoro', 'daerah_asal' => 'Bojonegoro Timur', 'nama_orang_tua' => 'Nur Fahma', 'nomor_hp_ortu' => '+6281234560007', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS002', 'is_primary' => true], // Lambatan ← UBAH + ['kode' => 'KLS011', 'is_primary' => false], // SMP 8 ← UBAH + ], + ], + [ + 'santri' => ['id_santri' => 'S008', 'nis' => '510035160089253008', 'nama_lengkap' => 'Azalia Calysta Salsabila', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Bulu RT 002 RW 001 Gedangan, Kutorejo, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => 'Calysta Sr.', 'nomor_hp_ortu' => '+6281234560008', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS001', 'is_primary' => true], // PB ← UBAH + ['kode' => 'KLS006', 'is_primary' => false], // SD 3 ← UBAH + ], + ], + [ + 'santri' => ['id_santri' => 'S009', 'nis' => '510035160089253009', 'nama_lengkap' => 'Bustomi Firman Amrulloh', 'jenis_kelamin' => 'Laki-laki', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Kuwik RT 001 RW 001 Bareng, Bareng, Jombang', 'daerah_asal' => 'Jombang Selatan', 'nama_orang_tua' => 'Firman', 'nomor_hp_ortu' => '+6281234560009', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS002', 'is_primary' => true], // Lambatan ← UBAH + ['kode' => 'KLS007', 'is_primary' => false], // SD 4 ← UBAH + ], + ], + [ + 'santri' => ['id_santri' => 'S010', 'nis' => '510035160089253010', 'nama_lengkap' => 'Cresya Nirva Arvenda', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Kedungmaling II RT 014 RW 006 Kedungmaling, Sooko, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => 'Nirva Sr.', 'nomor_hp_ortu' => '+6281234560010', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS003', 'is_primary' => true], // Cepatan ← UBAH + ['kode' => 'KLS013', 'is_primary' => false], // SMA 10 ← UBAH + ], + ], + [ + 'santri' => ['id_santri' => 'S011', 'nis' => '510035160089253011', 'nama_lengkap' => 'Daud Fasal', 'jenis_kelamin' => 'Laki-laki', 'status' => 'Aktif', 'alamat_santri' => 'Plumpang RT 017 RW 004 Penambangan, Balongbendo, Sidoarjo', 'daerah_asal' => 'Sidoarjo Utara', 'nama_orang_tua' => 'Fasal Sr.', 'nomor_hp_ortu' => '+6281234560011', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS001', 'is_primary' => true], // PB ← UBAH + ['kode' => 'KLS010', 'is_primary' => false], // SMP 7 ← UBAH + ], + ], + [ + 'santri' => ['id_santri' => 'S012', 'nis' => '510035160089253012', 'nama_lengkap' => 'Dwi Melviana Putri', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Panggih RT 003 RW 003 Panggih, Trowulan, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => 'Melviana Sr.', 'nomor_hp_ortu' => '+6281234560012', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS002', 'is_primary' => true], // Lambatan ← UBAH + ['kode' => 'KLS014', 'is_primary' => false], // SMA 11 ← UBAH + ], + ], + [ + 'santri' => ['id_santri' => 'S013', 'nis' => '510035160089253013', 'nama_lengkap' => 'Fina Yusrina Jannah', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Kepuhanyar RT 001 RW 001 Kepuhanyar, Mojoanyar, Mojokerto', 'daerah_asal' => 'Mojokerto Kota', 'nama_orang_tua' => 'Yusrina Sr.', 'nomor_hp_ortu' => '+6281234560013', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS003', 'is_primary' => true], // Cepatan ← UBAH + ['kode' => 'KLS015', 'is_primary' => false], // SMA 12 ← UBAH + ], + ], + [ + 'santri' => ['id_santri' => 'S014', 'nis' => '510035160089253014', 'nama_lengkap' => 'Gadis Sholikhah', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Brangkal RT 002 RW 001 Brangkal, Sooko, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => 'Sholikhah Sr.', 'nomor_hp_ortu' => '+6281234560014', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS001', 'is_primary' => true], // PB ← UBAH + ['kode' => 'KLS008', 'is_primary' => false], // SD 5 ← UBAH + ], + ], + [ + 'santri' => ['id_santri' => 'S015', 'nis' => '510035160089253015', 'nama_lengkap' => 'Gilang Aswin Nahar', 'jenis_kelamin' => 'Laki-laki', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Panggih RT 001 RW 003 Panggih, Trowulan, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => 'Aswin', 'nomor_hp_ortu' => '+6281234560015', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS002', 'is_primary' => true], // Lambatan ← UBAH + ['kode' => 'KLS009', 'is_primary' => false], // SD 6 ← UBAH + ], + ], + [ + 'santri' => ['id_santri' => 'S016', 'nis' => '510035160089253016', 'nama_lengkap' => 'Gustiyar Abdullah Manshurin', 'jenis_kelamin' => 'Laki-laki', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Setro RT 007 RW 001 Jatirejo, Kasreman, Ngawi', 'daerah_asal' => 'Ngawi Kota', 'nama_orang_tua' => 'Abdullah', 'nomor_hp_ortu' => '+6281234560016', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS001', 'is_primary' => true], // PB ← UBAH + ['kode' => 'KLS012', 'is_primary' => false], // SMP 9 ← UBAH + ], + ], + [ + 'santri' => ['id_santri' => 'S017', 'nis' => '510035160089253017', 'nama_lengkap' => 'Ilham Maulana Abdillah', 'jenis_kelamin' => 'Laki-laki', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Tundungan RT 004 RW 002 Sidomojo, Krian, Sidoarjo', 'daerah_asal' => 'Sidoarjo Utara', 'nama_orang_tua' => 'Maulana', 'nomor_hp_ortu' => '+6281234560017', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS003', 'is_primary' => true], // Cepatan ← UBAH + ['kode' => 'KLS013', 'is_primary' => false], // SMA 10 ← UBAH + ], + ], + [ + 'santri' => ['id_santri' => 'S018', 'nis' => '510035160089253018', 'nama_lengkap' => 'Kafa Septian Ramdan Efendi', 'jenis_kelamin' => 'Laki-laki', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Brangkal RT 002 RW001 Brangkal, Sooko, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => 'Septian', 'nomor_hp_ortu' => '+6281234560018', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS002', 'is_primary' => true], // Lambatan ← UBAH + ['kode' => 'KLS011', 'is_primary' => false], // SMP 8 ← UBAH + ], + ], + [ + 'santri' => ['id_santri' => 'S019', 'nis' => '510035160089253019', 'nama_lengkap' => "Khalisa Syifa'ul Aini", 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Tiebuk RT 007 RW 003 Wiyu, Pacet, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => "Syifa'ul Sr.", 'nomor_hp_ortu' => '+6281234560019', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS001', 'is_primary' => true], // PB ← UBAH + ['kode' => 'KLS005', 'is_primary' => false], // SD 2 ← UBAH + ], + ], + [ + 'santri' => ['id_santri' => 'S020', 'nis' => '510035160089253020', 'nama_lengkap' => 'Kharisa Nur Qalbi', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Pasinan Lemah Putih RT 011 RW 003, Wringinanom, Gresik', 'daerah_asal' => 'Gresik Selatan', 'nama_orang_tua' => 'Nur Qalbi Sr.', 'nomor_hp_ortu' => '+6281234560020', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS003', 'is_primary' => true], // Cepatan ← UBAH + ['kode' => 'KLS014', 'is_primary' => false], // SMA 11 ← UBAH + ], + ], + [ + 'santri' => ['id_santri' => 'S021', 'nis' => '510035160089253021', 'nama_lengkap' => 'Lana Novpriyanto', 'jenis_kelamin' => 'Laki-laki', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Parengan RT 019 RW 004 Kraton, Krian, Sidoarjo', 'daerah_asal' => 'Sidoarjo Utara', 'nama_orang_tua' => 'Novpriyanto Sr.', 'nomor_hp_ortu' => '+6281234560021', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS001', 'is_primary' => true], // PB ← UBAH + ['kode' => 'KLS010', 'is_primary' => false], // SMP 7 ← UBAH + ], + ], + [ + 'santri' => ['id_santri' => 'S022', 'nis' => '510035160089253022', 'nama_lengkap' => 'M. Reyhan Firdaus', 'jenis_kelamin' => 'Laki-laki', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Jampirogo RT 005 RW 001 Jampirogo, Sooko, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => 'Reyhan Sr.', 'nomor_hp_ortu' => '+6281234560022', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS002', 'is_primary' => true], // Lambatan ← UBAH + ['kode' => 'KLS006', 'is_primary' => false], // SD 3 ← UBAH + ], + ], + [ + 'santri' => ['id_santri' => 'S023', 'nis' => '510035160089253023', 'nama_lengkap' => 'Masrurotin Fatma Ayu Wulandari', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Pakis Kulon RT 003 RW 003 Pakis, Trowulan, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => 'Fatma Sr.', 'nomor_hp_ortu' => '+6281234560023', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS003', 'is_primary' => true], // Cepatan ← UBAH + ['kode' => 'KLS015', 'is_primary' => false], // SMA 12 ← UBAH + ], + ], + [ + 'santri' => ['id_santri' => 'S024', 'nis' => '510035160089253024', 'nama_lengkap' => 'Mochammad Adam Madinata', 'jenis_kelamin' => 'Laki-laki', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Kepuhanyar RT 001 RW001 Kepuhanyar, Mojoanyar, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => 'Adam Sr.', 'nomor_hp_ortu' => '+6281234560024', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS001', 'is_primary' => true], // PB ← UBAH + ['kode' => 'KLS007', 'is_primary' => false], // SD 4 ← UBAH + ], + ], + [ + 'santri' => ['id_santri' => 'S025', 'nis' => '510035160089253025', 'nama_lengkap' => "Muchammad Fachrizal Ta'awamu Insan", 'jenis_kelamin' => 'Laki-laki', 'status' => 'Aktif', 'alamat_santri' => 'Sidokepung RT 015 RW 003 Buduran, Sidoarjo', 'daerah_asal' => 'Sidoarjo Tengah', 'nama_orang_tua' => 'Fachrizal', 'nomor_hp_ortu' => '+6281234560025', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS002', 'is_primary' => true], // Lambatan ← UBAH + ['kode' => 'KLS011', 'is_primary' => false], // SMP 8 ← UBAH + ], + ], + [ + 'santri' => ['id_santri' => 'S026', 'nis' => '510035160089253026', 'nama_lengkap' => 'Muhammad Revano Fadillah Ramadhan', 'jenis_kelamin' => 'Laki-laki', 'status' => 'Aktif', 'alamat_santri' => 'Kemlagi Timur RT 001 RW 003 Kemlagi, Kemlagi, Mojokerto', 'daerah_asal' => 'Mojokerto Kota', 'nama_orang_tua' => 'Fadillah', 'nomor_hp_ortu' => '+6281234560026', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS003', 'is_primary' => true], // Cepatan ← UBAH + ['kode' => 'KLS012', 'is_primary' => false], // SMP 9 ← UBAH + ], + ], + [ + 'santri' => ['id_santri' => 'S027', 'nis' => '510035160089253027', 'nama_lengkap' => 'Muhammad Ibrahim Try Aji', 'jenis_kelamin' => 'Laki-laki', 'status' => 'Aktif', 'alamat_santri' => 'Jln. Patimura RT 002 RW 002 Keboan, Ngusikan, Jombang', 'daerah_asal' => 'Mojokerto Kota', 'nama_orang_tua' => 'Ibrahim', 'nomor_hp_ortu' => '+6281234560027', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS001', 'is_primary' => true], // PB ← UBAH + ['kode' => 'KLS013', 'is_primary' => false], // SMA 10 ← UBAH + ], + ], + [ + 'santri' => ['id_santri' => 'S028', 'nis' => '510035160089253028', 'nama_lengkap' => 'Mutiara Dira Ardiana', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Tiebuk RT 008 RW 001 Wiyu, Pacet, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => 'Dira Sr.', 'nomor_hp_ortu' => '+6281234560028', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS002', 'is_primary' => true], // Lambatan ← UBAH + ['kode' => 'KLS008', 'is_primary' => false], // SD 5 ← UBAH + ], + ], + [ + 'santri' => ['id_santri' => 'S029', 'nis' => '510035160089253029', 'nama_lengkap' => 'Nerissa Arviana Maharani', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Ngangkrik Kidul RT 001 RW 003 Gebangangkrik, Ngimbang, Lamongan', 'daerah_asal' => 'Kambangan', 'nama_orang_tua' => 'Arviana Sr.', 'nomor_hp_ortu' => '+6281234560029', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS003', 'is_primary' => true], // Cepatan ← UBAH + ['kode' => 'KLS009', 'is_primary' => false], // SD 6 ← UBAH + ], + ], + [ + 'santri' => ['id_santri' => 'S030', 'nis' => '510035160089253030', 'nama_lengkap' => 'Prisca Zuzin Firdaus', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Ngrayung RT 004 RW 001 Brayung, Puri, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => 'Zuzin Sr.', 'nomor_hp_ortu' => '+6281234560030', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS001', 'is_primary' => true], // PB ← UBAH + ['kode' => 'KLS004', 'is_primary' => false], // SD 1 ← UBAH + ], + ], + [ + 'santri' => ['id_santri' => 'S031', 'nis' => '510035160089253031', 'nama_lengkap' => 'Shoffiya Fitriani Az Zahra', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Dadi RT 027 RW 014 Dadi, Plaosan, Magetan', 'daerah_asal' => 'Magetan', 'nama_orang_tua' => 'Fitriani Sr.', 'nomor_hp_ortu' => '+6281234560031', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS002', 'is_primary' => true], // Lambatan ← UBAH + ['kode' => 'KLS005', 'is_primary' => false], // SD 2 ← UBAH + ], + ], + [ + 'santri' => ['id_santri' => 'S032', 'nis' => '510035160089253032', 'nama_lengkap' => 'Syifa Putri Ramahdani', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Sumbertempur Perum Puri Kencana RT 003 RW003 Sumbergirang, Puri, Mojokerto', 'daerah_asal' => 'Mojokerto Barat', 'nama_orang_tua' => 'Putri Sr.', 'nomor_hp_ortu' => '+6281234560032', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS003', 'is_primary' => true], // Cepatan ← UBAH + ['kode' => 'KLS006', 'is_primary' => false], // SD 3 ← UBAH + ], + ], + [ + 'santri' => ['id_santri' => 'S033', 'nis' => '510035160089253033', 'nama_lengkap' => 'Tiara Rahmadhani Faradilah', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Bulurejo RT 001 RW 002 Kepuhkajang, Perak, Jombang', 'daerah_asal' => 'Mojokerto Kota', 'nama_orang_tua' => 'Rahmadhani Sr.', 'nomor_hp_ortu' => '+6281234560033', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS001', 'is_primary' => true], // PB ← UBAH + ['kode' => 'KLS014', 'is_primary' => false], // SMA 11 ← UBAH + ], + ], + [ + 'santri' => ['id_santri' => 'S034', 'nis' => '510035160089253034', 'nama_lengkap' => 'Virlye Andyra Zahra', 'jenis_kelamin' => 'Perempuan', 'status' => 'Aktif', 'alamat_santri' => 'Dsn. Randuwates RT 005 RW 003 Mojowatesrejo, Kemlagi, Mojokerto', 'daerah_asal' => 'Mojokerto Kota', 'nama_orang_tua' => 'Andyra Sr.', 'nomor_hp_ortu' => '+6281234560034', 'rfid_uid' => null, 'foto' => null, 'created_at' => $now, 'updated_at' => $now], + 'kelas' => [ + ['kode' => 'KLS002', 'is_primary' => true], // Lambatan ← UBAH + ['kode' => 'KLS015', 'is_primary' => false], // SMA 12 ← UBAH + ], + ], + ]; + + // ============================================================ + // PROSES INSERT β€” jangan ubah bagian ini + // ============================================================ + + // Ambil mapping kode_kelas -> id dari DB + $kelasMap = DB::table('kelas')->pluck('id', 'kode_kelas')->toArray(); + + $totalSantri = 0; + $totalKelas = 0; + $errors = []; + + foreach ($santriList as $item) { + DB::table('santris')->insert($item['santri']); + $totalSantri++; + + $idSantri = $item['santri']['id_santri']; + + foreach ($item['kelas'] as $kelasItem) { + $kode = $kelasItem['kode']; + + if (!isset($kelasMap[$kode])) { + $errors[] = "⚠️ Kelas [{$kode}] tidak ada di DB! Santri {$idSantri} dilewati untuk kelas ini."; + continue; + } + + DB::table('santri_kelas')->insert([ + 'id_santri' => $idSantri, + 'id_kelas' => $kelasMap[$kode], + 'tahun_ajaran' => $tahunAjaran, + 'is_primary' => $kelasItem['is_primary'], + 'created_at' => $now, + 'updated_at' => $now, + ]); + $totalKelas++; + } + } + + $this->command->info("βœ… SantriSeeder selesai!"); + $this->command->info(" β†’ {$totalSantri} santri ditambahkan (S003–S034)"); + $this->command->info(" β†’ {$totalKelas} record santri_kelas ditambahkan"); + $this->command->info(" β†’ Tahun ajaran: {$tahunAjaran}"); + $this->command->info(" β†’ S001 & S002 tidak tersentuh βœ“"); + + if (!empty($errors)) { + $this->command->warn("\nAda masalah:"); + foreach ($errors as $err) { + $this->command->warn(" " . $err); + } + } + } +} \ No newline at end of file diff --git a/sim-pkpps/public/css/app.css b/sim-pkpps/public/css/app.css index 4a08790..54b42b4 100644 --- a/sim-pkpps/public/css/app.css +++ b/sim-pkpps/public/css/app.css @@ -64,8 +64,12 @@ :root { padding: 0; } +html { + font-size: clamp(12px, 1vw, 14px); +} + body { - font: 15px/1.6 'Inter', 'Segoe UI', 'Roboto', sans-serif; + font: 1rem/1.6 'Inter', 'Segoe UI', 'Roboto', sans-serif; background-color: var(--bg-color); color: var(--text-color); scroll-behavior: smooth; @@ -92,20 +96,20 @@ .splash-content { } .splash-logo { - width: 110px; - height: 110px; + width: 80px; + height: 80px; object-fit: contain; - margin: 0 auto 16px; + margin: 0 auto 10px; display: block; } .splash-title { font-family: 'Cinzel', serif; font-weight: 700; - font-size: 1.1rem; + font-size: 0.9rem; color: #2C3E50; letter-spacing: 0.15em; - margin: 0 0 20px; + margin: 0 0 14px; } .spinner, @@ -144,7 +148,7 @@ .app-wrapper { 5. SIDEBAR =================================== */ .sidebar { - width: 260px; + width: clamp(160px, 14vw, 200px); background: var(--sidebar-bg); color: var(--sidebar-text); transition: var(--transition-base); @@ -155,14 +159,14 @@ .sidebar { } .sidebar-header { - padding: 30px 20px; + padding: 16px 12px; text-align: center; border-bottom: 1px solid rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.05); } .sidebar-header h3 { - font: 700 1.6rem/1 inherit; + font: 700 1.15rem/1 inherit; margin: 0; letter-spacing: 0.5px; color: #FFFFFF; @@ -171,7 +175,7 @@ .sidebar-header h3 { .sidebar-menu { list-style: none; - padding: 20px 0; + padding: 10px 0; } .sidebar-menu li { @@ -181,26 +185,26 @@ .sidebar-menu li { .sidebar-menu li a { display: flex; align-items: center; - padding: 14px 20px; + padding: 8px 14px; color: var(--sidebar-text); text-decoration: none; transition: var(--transition-base); - font-size: 0.95rem; + font-size: 0.88rem; position: relative; border-left: 3px solid transparent; } .sidebar-menu li a i { - margin-right: 12px; - width: 22px; + margin-right: 8px; + width: 18px; text-align: center; - font-size: 1.1rem; + font-size: 0.95rem; } .sidebar-menu li a:hover { background-color: var(--sidebar-hover); border-left-color: var(--accent-yellow); - padding-left: 25px; + padding-left: 18px; box-shadow: inset 4px 0 8px rgba(0, 0, 0, 0.1); } @@ -226,13 +230,13 @@ .menu-toggle.active > .submenu { } .sidebar-menu .submenu li a { - padding-left: 50px; - font-size: 0.9rem; + padding-left: 36px; + font-size: 0.82rem; border-left: none; } .sidebar-menu .submenu li a:hover { - padding-left: 55px; + padding-left: 40px; background-color: rgba(255, 255, 255, 0.08); } @@ -253,7 +257,7 @@ .menu-toggle.active .toggle-icon { /* Sidebar Collapsed */ .sidebar.collapsed { - width: 70px; + width: 55px; } .sidebar.collapsed .sidebar-header h3, @@ -262,12 +266,12 @@ .sidebar.collapsed .sidebar-menu li a span { } .sidebar.collapsed .sidebar-header { - padding: 25px 0; + padding: 16px 0; } .sidebar.collapsed .sidebar-menu li a { justify-content: center; - padding: 14px 0; + padding: 8px 0; border-left: none; } @@ -323,7 +327,7 @@ .main-content-wrapper { .main-header { background: linear-gradient(135deg, #FFFFFF 0%, #F8FBF9 100%); - padding: 18px 30px; + padding: clamp(6px, 0.6vw, 10px) clamp(10px, 1vw, 16px); box-shadow: var(--shadow-sm); display: flex; justify-content: space-between; @@ -337,10 +341,10 @@ .main-header { .sidebar-toggle-btn { background: none; border: none; - font-size: 1.4rem; + font-size: 1.1rem; cursor: pointer; color: var(--primary-color); - padding: 8px; + padding: 5px; border-radius: var(--border-radius-sm); transition: var(--transition-base); } @@ -352,7 +356,7 @@ .sidebar-toggle-btn:hover { } .main-content { - padding: 30px; + padding: clamp(10px, 1.2vw, 16px); flex-grow: 1; } @@ -360,19 +364,19 @@ .main-content { 7. PAGE HEADER =================================== */ .page-header { - margin-bottom: 30px; - padding-bottom: 15px; - border-bottom: 3px solid; + margin-bottom: 16px; + padding-bottom: 10px; + border-bottom: 2px solid; border-image: linear-gradient(90deg, var(--primary-color), var(--secondary-color)) 1; display: flex; align-items: center; justify-content: space-between; - gap: 15px; + gap: 10px; flex-wrap: wrap; } .page-header h2 { - font: 700 1.8rem/1 inherit; + font: 700 clamp(1rem, 1.3vw, 1.2rem)/1 inherit; margin: 0; background: linear-gradient(135deg, var(--primary-color), var(--primary-dark)); -webkit-background-clip: text; @@ -385,16 +389,16 @@ .page-header h2 { =================================== */ .row-cards { display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 24px; - margin-bottom: 30px; + grid-template-columns: repeat(auto-fit, minmax(clamp(140px, 15vw, 200px), 1fr)); + gap: clamp(8px, 1vw, 14px); + margin-bottom: 16px; } .card { background: linear-gradient(135deg, #FFFFFF 0%, #FEFFFE 100%); border-radius: var(--border-radius); box-shadow: var(--shadow-sm); - padding: 24px; + padding: clamp(10px, 1vw, 16px); display: flex; flex-direction: column; position: relative; @@ -426,30 +430,30 @@ .card:hover::before { } .card h3 { - margin: 0 0 8px 0; - font: 600 0.95rem/1 inherit; + margin: 0 0 4px 0; + font: 600 0.8rem/1 inherit; color: var(--text-light); text-transform: uppercase; letter-spacing: 0.5px; } .card-value { - font: 700 2.5rem/1 inherit; - margin: 12px 0; + font: 700 clamp(1.2rem, 1.8vw, 1.6rem)/1 inherit; + margin: 8px 0; color: var(--text-color); } .card-value-small { - font: 700 1.8rem/1 inherit; - margin: 12px 0; + font: 700 1.3rem/1 inherit; + margin: 8px 0; color: var(--text-color); } .card-icon { position: absolute; - top: 20px; - right: 20px; - font-size: 3.5rem; + top: 14px; + right: 14px; + font-size: 2rem; opacity: 0.1; } @@ -483,7 +487,7 @@ .card-secondary .card-value { color: var(--secondary-color); } =================================== */ .content-box { background-color: var(--white); - padding: 24px; + padding: clamp(10px, 1vw, 14px); border-radius: var(--border-radius); box-shadow: var(--shadow-sm); border: 1px solid var(--primary-light); @@ -493,8 +497,8 @@ .content-box { 10. ALERTS =================================== */ .alert { - padding: 16px 20px; - margin-bottom: 20px; + padding: 10px 14px; + margin-bottom: 14px; border-radius: var(--border-radius-sm); border-left: 4px solid; display: flex; @@ -541,14 +545,14 @@ @keyframes slideInDown { 11. FORMS =================================== */ .form-group { - margin-bottom: 20px; + margin-bottom: 14px; } .form-group label { display: flex; align-items: center; - margin-bottom: 8px; - font: 600 0.9rem/1 inherit; + margin-bottom: 5px; + font: 600 0.8rem/1 inherit; color: var(--text-color); } @@ -559,10 +563,10 @@ .form-icon { .form-control { width: 100%; - padding: 12px 16px; + padding: 7px 10px; border: 2px solid #E0F0EC; border-radius: var(--border-radius-sm); - font-size: 0.95rem; + font-size: 0.82rem; transition: var(--transition-base); background-color: var(--white); color: var(--text-color); @@ -601,7 +605,7 @@ .form-text { .form-container { background: white; border-radius: var(--border-radius); - padding: 30px; + padding: 18px; box-shadow: var(--shadow-sm); border: 1px solid var(--primary-light); } @@ -610,11 +614,11 @@ .form-container { 12. BUTTONS =================================== */ .btn { - padding: 12px 24px; + padding: clamp(5px, 0.5vw, 8px) clamp(8px, 0.9vw, 14px); border: none; border-radius: var(--border-radius-sm); cursor: pointer; - font: 600 0.95rem/1 inherit; + font: 600 clamp(0.72rem, 0.8vw, 0.82rem)/1 inherit; text-decoration: none; display: inline-flex; align-items: center; @@ -729,13 +733,13 @@ .btn-outline-primary:hover { } .btn-sm { - padding: 8px 16px; - font-size: 0.85rem; + padding: clamp(3px, 0.3vw, 5px) clamp(6px, 0.6vw, 10px); + font-size: 0.75rem; } .btn-lg { - padding: 15px 30px; - font-size: 1.1em; + padding: 10px 20px; + font-size: 0.95em; } .hover-shadow { @@ -778,14 +782,15 @@ .data-table { .data-table th, .data-table td { - padding: 16px 20px; + padding: clamp(5px, 0.5vw, 8px) clamp(6px, 0.7vw, 10px); text-align: left; border-bottom: 1px solid #E8F7F2; + font-size: clamp(0.72rem, 0.8vw, 0.82rem); } .data-table th { background: linear-gradient(135deg, #E8F7F2 0%, #D4F1E3 100%); - font: 700 0.85rem/1 inherit; + font: 700 0.75rem/1 inherit; color: var(--primary-dark); text-transform: uppercase; letter-spacing: 0.5px; @@ -829,20 +834,20 @@ .detail-table tr:hover { .detail-table th, .detail-table td { - padding: 14px 16px; + padding: 8px 10px; text-align: left; } .detail-table th { background: linear-gradient(135deg, #E8F7F2 0%, #D4F1E3 100%); - font: 600 0.9rem/1 inherit; + font: 600 0.8rem/1 inherit; color: var(--primary-dark); - width: 200px; + width: 150px; } .detail-table td { color: var(--text-color); - font-size: 0.95rem; + font-size: 0.82rem; } /* SPP Table Row Highlight */ @@ -863,7 +868,7 @@ .content-header-flex { .table-container { background: white; border-radius: var(--border-radius); - padding: 20px; + padding: 12px; box-shadow: var(--shadow-sm); border: 1px solid var(--primary-light); } @@ -875,27 +880,27 @@ .detail-header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 20px; + margin-bottom: 14px; flex-wrap: wrap; - gap: 15px; + gap: 10px; } .detail-header h3 { margin: 0; color: var(--text-color); - font: 700 1.4rem/1 inherit; + font: 700 1.1rem/1 inherit; } .detail-section { - margin-bottom: 30px; + margin-bottom: 18px; } .detail-section h4 { color: var(--primary-dark); border-bottom: 2px solid var(--primary-color); - padding-bottom: 10px; - margin-bottom: 15px; - font: 700 1.1rem/1 inherit; + padding-bottom: 6px; + margin-bottom: 10px; + font: 700 0.92rem/1 inherit; display: flex; align-items: center; } @@ -909,12 +914,12 @@ .detail-section h4 i { 15. BADGES =================================== */ .badge { - padding: 6px 12px; + padding: 3px 8px; border-radius: var(--border-radius-sm); - font: 600 0.85em/1 inherit; + font: 600 0.75em/1 inherit; display: inline-flex; align-items: center; - gap: 5px; + gap: 4px; } .badge-success { @@ -949,28 +954,28 @@ .badge-secondary { } .badge-lg { - padding: 10px 18px; - font-size: 1em; + padding: 6px 12px; + font-size: 0.85em; } /* =================================== 16. AUTH PAGES =================================== */ .logo-circle { - width: 70px; - height: 70px; + width: 50px; + height: 50px; background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); border-radius: 50%; display: flex; justify-content: center; align-items: center; - margin: 0 auto 20px; + margin: 0 auto 14px; box-shadow: 0 8px 20px rgba(111, 186, 157, 0.3); } .logo-circle i { color: white; - font-size: 2rem; + font-size: 1.4rem; } .auth-header h2 { @@ -1019,7 +1024,7 @@ .spp-telat-indicator { .nominal-highlight { color: var(--primary-color); - font: 700 1.1rem/1 inherit; + font: 700 0.92rem/1 inherit; } .status-cell { @@ -1028,10 +1033,10 @@ .status-cell { .info-box { background: linear-gradient(135deg, #E3F2FD 0%, #D1E9F9 100%); - padding: 20px; + padding: 12px; border-radius: var(--border-radius-sm); border-left: 4px solid var(--info-color); - margin: 20px 0; + margin: 12px 0; } .info-box p { @@ -1061,7 +1066,7 @@ .filter-form-inline .form-control { .berita-card { background: white; border-radius: var(--border-radius); - padding: 20px; + padding: 14px; box-shadow: var(--shadow-sm); transition: var(--transition-base); border: 2px solid transparent; @@ -1075,13 +1080,13 @@ .berita-card:hover { .santri-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 12px; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 10px; } .santri-item { background: white; - padding: 12px; + padding: 8px; border-radius: var(--border-radius-sm); box-shadow: var(--shadow-sm); transition: var(--transition-fast); @@ -1099,8 +1104,8 @@ .santri-item input[type="checkbox"]:checked + label { .kelas-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 15px; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 10px; } /* Progress Bar */ @@ -1132,8 +1137,8 @@ .content-preview { /* Santri Avatar */ .santri-avatar, .santri-avatar-initial { - width: 40px; - height: 40px; + width: 32px; + height: 32px; border-radius: 50%; flex-shrink: 0; } @@ -1153,21 +1158,21 @@ .santri-avatar-initial { } .santri-avatar-lg { - width: 60px; - height: 60px; + width: 46px; + height: 46px; border: 3px solid var(--primary-color); } .santri-avatar-initial-lg { - width: 60px; - height: 60px; - font-size: 1.5em; + width: 46px; + height: 46px; + font-size: 1.2em; } /* Image Preview */ .image-preview { - max-width: 200px; - max-height: 150px; + max-width: 150px; + max-height: 110px; border-radius: var(--border-radius-sm); border: 2px solid var(--primary-light); margin-top: 8px; @@ -1188,7 +1193,7 @@ .pagination { .pagination a, .pagination span { - padding: 8px 12px; + padding: 4px 8px; border: 1px solid var(--primary-light); border-radius: var(--border-radius-sm); color: var(--primary-color); @@ -1197,8 +1202,8 @@ .pagination span { display: inline-flex; align-items: center; justify-content: center; - min-width: 40px; - min-height: 40px; + min-width: 30px; + min-height: 30px; } /* Fix SVG icon size in pagination */ @@ -1216,17 +1221,18 @@ .pagination a:hover { box-shadow: var(--shadow-sm); } -.pagination .active span { +.pagination .active { background: var(--primary-color); color: white; border-color: var(--primary-color); font-weight: 600; } -.pagination .disabled span { +.pagination .disabled { color: var(--text-light); cursor: not-allowed; opacity: 0.5; + pointer-events: none; } /* =================================== @@ -1234,21 +1240,21 @@ .pagination .disabled span { =================================== */ .empty-state { text-align: center; - padding: 60px 20px; + padding: 36px 14px; color: var(--text-light); } .empty-state i { - font-size: 4em; + font-size: 3em; color: #ccc; - margin-bottom: 20px; + margin-bottom: 14px; display: block; } .empty-state h3 { color: var(--text-light); - margin-bottom: 15px; - font-size: 1.3rem; + margin-bottom: 10px; + font-size: 1rem; } .empty-state p { @@ -1263,7 +1269,7 @@ .loading { display: flex; justify-content: center; align-items: center; - padding: 40px; + padding: 24px; } .generating-overlay { @@ -1278,7 +1284,7 @@ .generating-overlay { .generating-content { background: white; - padding: 40px; + padding: 24px; border-radius: var(--border-radius); text-align: center; box-shadow: var(--shadow-lg); @@ -1288,10 +1294,10 @@ .generating-spinner { border: 4px solid var(--primary-light); border-top-color: var(--primary-color); border-radius: 50%; - width: 50px; - height: 50px; + width: 36px; + height: 36px; animation: spin 1s linear infinite; - margin: 0 auto 20px; + margin: 0 auto 14px; } /* =================================== @@ -1316,14 +1322,14 @@ .header-section { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 25px; + margin-bottom: 16px; flex-wrap: wrap; - gap: 15px; + gap: 10px; } .header-section h1 { color: var(--primary-color); - font: 700 1.8rem/1 inherit; + font: 700 1.3rem/1 inherit; margin: 0; } @@ -1403,12 +1409,12 @@ .santri-section::-webkit-scrollbar-thumb:hover { /* Tablet (768px - 1023px) */ @media (max-width: 1024px) { .row-cards { - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - gap: 20px; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; } .main-content { - padding: 25px; + padding: 14px; } } @@ -1417,14 +1423,16 @@ @media (max-width: 768px) { /* Sidebar Mobile */ .sidebar { position: fixed; - left: -260px; + left: 0; + transform: translateX(-100%); height: 100vh; z-index: 1000; + width: clamp(180px, 55vw, 220px); box-shadow: 4px 0 20px rgba(111, 186, 157, 0.3); } .sidebar.mobile-active { - left: 0; + transform: translateX(0); } .sidebar-toggle-btn-mobile { @@ -1432,12 +1440,12 @@ @media (max-width: 768px) { } .sidebar.collapsed { - width: 260px; - left: -260px; + width: clamp(180px, 55vw, 220px); + transform: translateX(-100%); } .sidebar.collapsed.mobile-active { - left: 0; + transform: translateX(0); } .main-content-wrapper { @@ -1446,15 +1454,15 @@ @media (max-width: 768px) { /* Header & Content */ .main-header { - padding: 15px 20px; + padding: 6px 10px; } .main-content { - padding: 20px; + padding: 10px; } .page-header h2 { - font-size: 1.5rem; + font-size: clamp(0.9rem, 1.5vw, 1.1rem); } /* Cards */ @@ -1464,17 +1472,17 @@ @media (max-width: 768px) { } .card-value { - font-size: 2rem; + font-size: 1.3rem; } /* Tables */ .data-table { - font-size: 0.9rem; + font-size: 0.78rem; } .data-table th, .data-table td { - padding: 12px; + padding: 6px 8px; } /* Detail Components */ @@ -1484,37 +1492,37 @@ @media (max-width: 768px) { } .detail-header h3 { - font-size: 1.2rem; + font-size: 1rem; } .detail-table th { - width: 120px; - font-size: 0.85rem; - padding: 12px; + width: 100px; + font-size: 0.75rem; + padding: 6px 8px; } .detail-table td { - font-size: 0.9rem; - padding: 12px; + font-size: 0.78rem; + padding: 6px 8px; } .detail-section h4 { - font-size: 1rem; + font-size: 0.85rem; } /* Auth Pages */ .auth-container { - margin: 15px; - padding: 20px; + margin: 10px; + padding: 14px; } .auth-header h2 { - font-size: 1.4rem; + font-size: 1.1rem; } .logo-circle { - width: 60px; - height: 60px; + width: 44px; + height: 44px; } /* Forms */ @@ -1567,12 +1575,12 @@ @media (max-width: 768px) { } .berita-card { - padding: 15px; + padding: 10px; } .badge { - padding: 4px 8px; - font-size: 0.75em; + padding: 2px 6px; + font-size: 0.68em; } /* Button Group */ @@ -1593,52 +1601,52 @@ @media (max-width: 768px) { } .header-section h1 { - font-size: 1.5rem; + font-size: 1.1rem; } /* Pagination */ .pagination { - font-size: 0.85rem; + font-size: 0.75rem; } .pagination a, .pagination span { - padding: 6px 10px; - min-width: 36px; - min-height: 36px; + padding: 3px 6px; + min-width: 26px; + min-height: 26px; } .pagination a svg, .pagination span svg { - width: 14px; - height: 14px; + width: 12px; + height: 12px; } } /* Mobile Small (max-width: 480px) */ @media (max-width: 480px) { .main-content { - padding: 15px; + padding: 8px; } /* Buttons */ .btn { - padding: 10px 18px; - font-size: 0.9rem; + padding: 5px 10px; + font-size: 0.75rem; } .card { - padding: 20px; + padding: 10px; } .form-control { - padding: 10px 14px; + padding: 6px 8px; } /* Tables - Stack Layout */ .detail-table, .data-table { - font-size: 0.85rem; + font-size: 0.72rem; } .detail-table th, @@ -1670,7 +1678,7 @@ @media (max-width: 480px) { .data-table th, .data-table td { - padding: 10px 8px; + padding: 5px 4px; } /* Hide less important columns */ @@ -1686,7 +1694,7 @@ @media (max-width: 480px) { } .detail-header h3 { - font-size: 1.2rem; + font-size: 0.95rem; } .detail-header > div:last-child { @@ -1707,11 +1715,11 @@ @media (max-width: 480px) { /* Grids */ .santri-grid { - gap: 8px; + gap: 6px; } .santri-item { - padding: 10px; + padding: 6px; } .kelas-grid { @@ -1749,8 +1757,9 @@ @media print { body { background: white; } +} - /* =================================== +/* =================================== UANG SAKU - ADDITIONAL STYLES =================================== */ @@ -1781,39 +1790,37 @@ .badge i { margin-right: 4px; } -/* Responsive Chart */ +/* Responsive Chart & Dashboard Santri */ @media (max-width: 768px) { #chartUangSaku { - max-height: 300px !important; + max-height: 200px !important; } - /* Responsive untuk Dashboard Santri */ -@media (max-width: 768px) { /* Cards Statistik */ .row-cards { grid-template-columns: 1fr; - gap: 15px; + gap: 8px; } /* Quick Links Grid */ .content-box > div[style*="grid"] { grid-template-columns: repeat(2, 1fr) !important; - gap: 10px !important; + gap: 6px !important; } /* Alert Kesehatan */ .alert { - font-size: 0.9rem; - padding: 12px 15px; + font-size: 0.78rem; + padding: 6px 10px; } /* Berita Terbaru */ .content-box a[href*="berita.show"] { - padding: 12px !important; + padding: 8px !important; } .content-box a[href*="berita.show"] h4 { - font-size: 0.85rem !important; + font-size: 0.75rem !important; } } @@ -1825,18 +1832,19 @@ @media (max-width: 480px) { /* Card Values */ .card-value { - font-size: 2rem !important; + font-size: 1.3rem !important; } /* Page Header */ .page-header h2 { - font-size: 1.3rem; + font-size: 0.95rem; } .page-header p { - font-size: 0.85rem; + font-size: 0.72rem; } } + /* Filter Form Kesehatan Santri */ @media (max-width: 768px) { #filterForm > div { @@ -1844,12 +1852,12 @@ @media (max-width: 768px) { } #filterForm .form-group label { - font-size: 0.9rem; + font-size: 0.78rem; } #filterForm input[type="date"], #filterForm select { - font-size: 0.9rem; + font-size: 0.78rem; } #filterForm > div > div:last-child { @@ -1867,13 +1875,10 @@ @media (max-width: 480px) { } .card-value { - font-size: 2rem !important; + font-size: 1.3rem !important; } } -} -} - /* =================================== DASHBOARD ADMIN β€” Extra Styles =================================== */ @@ -1882,13 +1887,13 @@ @media (max-width: 480px) { .row-cards-5 { display: grid; grid-template-columns: repeat(5, 1fr); - gap: 16px; - margin-bottom: 24px; + gap: 10px; + margin-bottom: 16px; } .card-sub { display: block; - font-size: 0.78rem; + font-size: 0.68rem; color: var(--text-light); margin-top: -4px; } @@ -1897,8 +1902,8 @@ .card-sub { .dash-grid-2 { display: grid; grid-template-columns: 3fr 2fr; - gap: 20px; - margin-bottom: 24px; + gap: 14px; + margin-bottom: 16px; } /* Chart containers */ @@ -1908,20 +1913,20 @@ .dash-chart-box { } .dash-chart-box h4 { - margin: 0 0 16px 0; - font-size: 1rem; + margin: 0 0 10px 0; + font-size: 0.88rem; font-weight: 600; color: var(--text-color); } .chart-container { position: relative; - height: 280px; + height: 200px; width: 100%; } .chart-container-sm { - height: 200px; + height: 150px; } /* Jadwal kegiatan table enhancements */ @@ -1975,8 +1980,8 @@ .alert-list { } .alert-list li { - padding: 4px 0; - font-size: 0.88rem; + padding: 3px 0; + font-size: 0.78rem; } /* SPP summary */ @@ -1993,7 +1998,7 @@ .spp-stat { } .spp-label { - font-size: 0.78rem; + font-size: 0.68rem; color: var(--text-light); } @@ -2010,8 +2015,8 @@ .feed-list { .feed-item { display: flex; align-items: flex-start; - gap: 12px; - padding: 10px 0; + gap: 8px; + padding: 6px 0; border-bottom: 1px solid var(--primary-light); } @@ -2021,13 +2026,13 @@ .feed-item:last-child { .feed-icon { flex-shrink: 0; - width: 32px; - height: 32px; + width: 26px; + height: 26px; border-radius: 50%; display: flex; align-items: center; justify-content: center; - font-size: 0.8rem; + font-size: 0.68rem; color: #fff; } @@ -2038,12 +2043,12 @@ .feed-icon-warning { background: var(--warning-color); } .feed-body p { margin: 0; - font-size: 0.88rem; + font-size: 0.78rem; color: var(--text-color); } .feed-body small { - font-size: 0.75rem; + font-size: 0.65rem; } .text-muted { @@ -2075,6 +2080,505 @@ @media (max-width: 480px) { } } +/* =================================== + DASHBOARD KEGIATAN + =================================== */ + +/* -- KPI Grid (4 cols, gradient cards) -- */ +.kpi-grid-kegiatan { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; + margin-bottom: 20px; +} + +.kpi-kegiatan { + color: #fff; + padding: 22px 20px; + border-radius: var(--border-radius); + box-shadow: var(--shadow-md); + position: relative; + overflow: hidden; + transition: var(--transition-base); +} + +.kpi-kegiatan:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-lg); +} + +.kpi-kegiatan .kpi-icon { + position: absolute; + top: 14px; + right: 14px; + font-size: 2.8rem; + opacity: 0.2; +} + +.kpi-kegiatan .kpi-value { + font-size: 2.6rem; + font-weight: 800; + line-height: 1.1; + margin: 0 0 6px; + letter-spacing: -0.5px; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); +} + +.kpi-kegiatan .kpi-label { + font-size: 0.95rem; + font-weight: 600; + opacity: 0.95; + letter-spacing: 0.3px; +} + +.kpi-kegiatan .kpi-sub { + font-size: 0.82rem; + margin-top: 10px; + display: flex; + align-items: center; + gap: 4px; + opacity: 0.9; +} + +.kpi-kegiatan.bg-primary { background: linear-gradient(135deg, var(--primary-color), var(--primary-dark)); } +.kpi-kegiatan.bg-success { background: linear-gradient(135deg, #28a745, #218838); } +.kpi-kegiatan.bg-warning { background: linear-gradient(135deg, #ffc107, #e0a800); } +.kpi-kegiatan.bg-info { background: linear-gradient(135deg, #17a2b8, #138496); } + +/* -- Day Tabs (Senin-Ahad navigation) -- */ +.day-tabs { + display: flex; + gap: 4px; + margin-bottom: 16px; + border-bottom: 2px solid var(--primary-light); + overflow-x: auto; + scrollbar-width: thin; +} + +.day-tab { + padding: 10px 18px; + cursor: pointer; + border: none; + background: transparent; + color: var(--text-light); + font-weight: 500; + font-size: 0.88rem; + transition: var(--transition-fast); + border-bottom: 3px solid transparent; + white-space: nowrap; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + line-height: 1; + text-decoration: none; +} + +.day-tab:hover { + color: var(--primary-color); + background: var(--primary-light); +} + +.day-tab.active { + color: var(--primary-color); + border-bottom-color: var(--primary-color); + background: var(--primary-light); +} + +.day-tab .day-date { + font-size: 0.72rem; + color: var(--text-light); + font-weight: 400; +} + +.day-tab.active .day-date { + color: var(--primary-color); +} + +.day-tab.today-tab .day-name::after { + content: ''; + display: inline-block; + width: 5px; + height: 5px; + background: var(--primary-color); + border-radius: 50%; + margin-left: 4px; + vertical-align: middle; +} + +/* -- Insight Items -- */ +.insight-item { + padding: 12px 15px; + border-radius: var(--border-radius-sm); + margin-bottom: 10px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 11px; +} + +.insight-item.warning { + background: #fff3cd; + border-left: 4px solid #ffc107; +} + +.insight-item.success { + background: #d4edda; + border-left: 4px solid #28a745; +} + +.insight-item.info { + background: #d1ecf1; + border-left: 4px solid #17a2b8; +} + +.insight-item.danger { + background: #f8d7da; + border-left: 4px solid #dc3545; +} + +.insight-content { flex: 1; } + + +.insight-message { + font-weight: 600; + margin-bottom: 3px; +} + +.insight-detail { + font-size: 0.85rem; + opacity: 0.8; +} + +/* -- Main Layout (2/3 + 1/3) -- */ +.layout-kegiatan { + display: grid; + grid-template-columns: 1fr 340px; + gap: 20px; +} + +/* -- Kegiatan Cards -- */ +.kegiatan-list { display: grid; gap: 16px; } + +.kegiatan-card { + background: var(--white); + border-radius: var(--border-radius); + box-shadow: var(--shadow-sm); + padding: 18px; + transition: var(--transition-base); + border-left: 4px solid var(--primary-color); +} + +.kegiatan-card:hover { + box-shadow: var(--shadow-lg); + transform: translateY(-2px); +} + +.kegiatan-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 12px; + gap: 10px; + flex-wrap: wrap; +} + +.kegiatan-info { flex: 1; } + +.kegiatan-title { + font-size: 1.15rem; + font-weight: 600; + color: var(--text-color); + margin: 0 0 8px; + display: flex; + align-items: center; + gap: 8px; +} + +.kegiatan-meta { + display: flex; + gap: 10px; + flex-wrap: wrap; + font-size: 0.88rem; + color: var(--text-light); +} + +.kegiatan-meta i { margin-right: 4px; color: var(--primary-color); } + +.kegiatan-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 12px; +} + +/* -- Status Badges -- */ +.status-badge { + padding: 5px 12px; + border-radius: 20px; + font-size: 0.82rem; + font-weight: 600; + white-space: nowrap; +} + +.status-belum { background: #e9ecef; color: #6c757d; } +.status-berlangsung { background: #d4edda; color: #155724; animation: kegiatan-pulse 2s infinite; } +.status-selesai { background: #d1ecf1; color: #0c5460; } +.status-sudah { background: #d1fae5; color: #065f46; } + +@keyframes kegiatan-pulse { + 0%,100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +/* -- Progress Section -- */ +.kegiatan-progress { margin: 12px 0; } + +.kegiatan-progress-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; + font-size: 0.88rem; +} + +.kegiatan-progress-bar { + height: 22px; + background: #e9ecef; + border-radius: 11px; + overflow: hidden; + position: relative; +} + +.kegiatan-progress-fill { + height: 100%; + border-radius: 11px; + transition: width 0.6s ease; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 600; + color: #fff; +} + +.kegiatan-progress-fill.p-success { background: linear-gradient(90deg, #28a745, #20c997); } +.kegiatan-progress-fill.p-warning { background: linear-gradient(90deg, #ffc107, #fd7e14); } +.kegiatan-progress-fill.p-orange { background: linear-gradient(90deg, #fd7e14, #dc3545); } +.kegiatan-progress-fill.p-danger { background: linear-gradient(90deg, #dc3545, #c82333); } + +/* -- Heatmap Calendar -- */ +.heatmap-calendar { + background: var(--white); + border-radius: var(--border-radius); + box-shadow: var(--shadow-sm); + padding: 18px; + position: sticky; + top: 20px; +} + +.heatmap-header { + font-weight: 600; + margin-bottom: 12px; + color: var(--primary-color); + display: flex; + align-items: center; + gap: 8px; +} + +.heatmap-days, +.heatmap-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 3px; +} + +.heatmap-days { margin-bottom: 6px; } + +.heatmap-day-label { + text-align: center; + font-size: 0.72rem; + font-weight: 600; + color: var(--text-light); +} + +.heatmap-cell { + aspect-ratio: 1; + border-radius: 4px; + cursor: pointer; + transition: var(--transition-fast); + display: flex; + align-items: center; + justify-content: center; + font-size: 0.7rem; + font-weight: 600; + color: var(--text-color); +} + +.heatmap-cell:hover { + transform: scale(1.15); + box-shadow: 0 3px 10px rgba(0,0,0,0.25); + z-index: 10; +} + +.heatmap-level-0 { background: #e5e7eb; color: #9ca3af; } +.heatmap-level-1 { background: #ef4444; color: #fff; } +.heatmap-level-2 { background: #fbbf24; color: #fff; } +.heatmap-level-3 { background: #34d399; color: #fff; } +.heatmap-level-4 { background: #10b981; color: #fff; } + +.heatmap-cell.today { + outline: 2px solid var(--primary-color); + outline-offset: 1px; + box-shadow: 0 0 0 3px rgba(111,186,165,0.3); +} + +.heatmap-legend { + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid #e9ecef; +} + +.heatmap-legend-title { + font-size: 0.72rem; + font-weight: 600; + color: var(--text-light); + margin-bottom: 6px; +} + +.heatmap-legend-items { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.7rem; +} + +.heatmap-legend-item { + display: flex; + align-items: center; + gap: 4px; +} + +.heatmap-legend-box { + width: 12px; + height: 12px; + border-radius: 2px; +} + +/* -- Modal Kegiatan -- */ +.modal-kegiatan { + display: none; + position: fixed; + z-index: 9999; + inset: 0; + background: rgba(0,0,0,0.5); +} + +.modal-kegiatan.active { + display: flex; + justify-content: center; + align-items: center; +} + +.modal-kegiatan-panel { + background: var(--white); + border-radius: var(--border-radius); + max-width: 700px; + width: 90%; + max-height: 85vh; + overflow-y: auto; + animation: keg-slide-up 0.3s ease; +} + +.modal-kegiatan-head { + padding: 18px; + border-bottom: 2px solid #e9ecef; + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + background: var(--white); + z-index: 10; +} + +.modal-kegiatan-head h3 { margin: 0; color: var(--text-color); } + +.modal-kegiatan-close { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--text-light); + transition: color 0.3s; +} + +.modal-kegiatan-close:hover { color: var(--text-color); } + +.modal-kegiatan-body { padding: 18px; } + +@keyframes keg-slide-up { + from { transform: translateY(40px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +/* -- Responsive -- */ +@media (max-width: 1024px) { + .layout-kegiatan { grid-template-columns: 1fr; } + .heatmap-calendar { position: static; } +} + +@media (max-width: 768px) { + .kpi-grid-kegiatan { grid-template-columns: repeat(2, 1fr); } + .day-tabs { gap: 2px; } + .day-tab { padding: 8px 12px; font-size: 0.82rem; } + .kegiatan-card-header { flex-direction: column; } +} + +@media (max-width: 480px) { + .kpi-grid-kegiatan { grid-template-columns: 1fr; } +} + /* =================================== END OF OPTIMIZED CSS + =================================== */ + +/* =================================== + SELECT2 OVERRIDES + =================================== */ +.select2-container { width: 100% !important; } + +.select2-container--default .select2-selection--single { + height: calc(2.0rem + 10px); + border: 1px solid #ced4da; + border-radius: 6px; + padding: 4px 8px; +} + +.select2-container--default .select2-selection--single .select2-selection__arrow { + height: 100%; +} + +.select2-container--default .select2-selection--single .select2-selection__rendered { + line-height: calc(2.0rem + 2px); + color: #495057; +} + +.select2-container--default .select2-results__option--highlighted[aria-selected] { + background-color: var(--primary-color, #6FBA9D); +} + +.select2-dropdown { + border: 1px solid #ced4da; + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0,0,0,.1); +} + +.select2-container--default .select2-search--dropdown .select2-search__field { + border: 1px solid #ced4da; + border-radius: 4px; + padding: 4px 8px; +} +/* =================================== + END SELECT2 OVERRIDES =================================== */ \ No newline at end of file diff --git a/sim-pkpps/resources/views/admin/auth/forgot_password.blade.php b/sim-pkpps/resources/views/admin/auth/forgot_password.blade.php new file mode 100644 index 0000000..e3bf7c0 --- /dev/null +++ b/sim-pkpps/resources/views/admin/auth/forgot_password.blade.php @@ -0,0 +1,311 @@ +{{-- resources/views/admin/auth/forgot_password.blade.php --}} +@extends('auth.auth_layout') + +@section('title', 'Lupa Password') + +@section('auth-content') + + + + + +
+
+
+
+
+
+
+ +
+ + +
+ +
Reset Akses
+

Lupa
Password?

+

Jangan khawatir, kami bantu pulihkan.

+
+

Ikuti langkah berikut untuk mengatur ulang password akun Super Admin Anda.

+ +
+
+
1
+
Masukkan email terdaftar Kami kirim kode OTP 6 digit
+
+
+
2
+
Verifikasi kode OTP Cek email masuk / spam
+
+
+
3
+
Buat password baru Minimal 8 karakter
+
+
+
+ + +
+
+
+ +
+
Langkah 1 dari 3
+
Masukkan Email
+
Masukkan email Super Admin yang terdaftar di sistem. Kami akan mengirim kode OTP ke email tersebut.
+ + @if ($errors->any()) +
+ + {{ $errors->first() }} +
+ @endif + + @if(session('success')) +
+ + {{ session('success') }} +
+ @endif + +
+ @csrf + +
+ +
+ + +
+
+ + +
+ + + Kembali ke Login + +
+
+ +
+
+ + +@endsection diff --git a/sim-pkpps/resources/views/admin/auth/login.blade.php b/sim-pkpps/resources/views/admin/auth/login.blade.php index 40b17c3..a5b973a 100644 --- a/sim-pkpps/resources/views/admin/auth/login.blade.php +++ b/sim-pkpps/resources/views/admin/auth/login.blade.php @@ -4,214 +4,422 @@ @section('title', 'Login Admin') @section('auth-content') -
-

Admin Login

-

Sistem Informasi Monitoring Santri

+ + + + + +
+
+
+
+
+
+
+
+
+
+ +
+ + +
+ +
Selamat Datang
+

Masuk ke
Panel
Admin.

+

PKPPS Riyadlul Jannah

+
+

Kelola data santri, absensi, keuangan, dan seluruh aktivitas pesantren dalam satu sistem terpadu.

+
+
+
+ Dashboard monitoring real-time +
+
+
+ Akses aman berbasis role +
+
+
+ Terintegrasi aplikasi mobile wali +
+
+
+ + +
+
+
Login Admin
+
Masuk Akun
+
Masukkan username dan password untuk mengakses panel admin.
+ + @if ($errors->any()) +
+ + {{ $errors->first() }} +
+ @endif + + @if(session('success')) +
+ + {{ session('success') }} +
+ @endif + +
+ @csrf + +
+ +
+ + +
+
+ +
+ +
+ + + +
+
+ +
+ + + Lupa Password? + +
+ + + +
+ Admin baru? Daftar Sekarang +
+ +
atau
+ + Login sebagai Santri / Wali + +
+
+
+ +
-{{-- Alert Error --}} -@if ($errors->any()) -
- - {{ $errors->first() }} -
-@endif - -{{-- Alert Success (dari logout) --}} -@if(session('success')) -
- - {{ session('success') }} -
-@endif - -
- @csrf - - {{-- Username Field --}} -
- - - @error('username') -
{{ $message }}
- @enderror -
- - {{-- Password Field --}} -
- -
- - -
- @error('password') -
{{ $message }}
- @enderror -
- - {{-- Remember Me Checkbox --}} -
- - -
- - {{-- Submit Button --}} -
- -
- - {{-- Link ke Register --}} -

- Admin baru? Daftar Sekarang -

- - {{-- Link ke Login Santri --}} -
-

- Login sebagai santri/wali? -

- - Login Santri/Wali - -
-
- -{{-- JavaScript --}} -@endsection \ No newline at end of file +@endsection diff --git a/sim-pkpps/resources/views/admin/auth/register.blade.php b/sim-pkpps/resources/views/admin/auth/register.blade.php index 4e339c6..dd18499 100644 --- a/sim-pkpps/resources/views/admin/auth/register.blade.php +++ b/sim-pkpps/resources/views/admin/auth/register.blade.php @@ -1,53 +1,361 @@ +{{-- resources/views/admin/auth/register.blade.php --}} @extends('auth.auth_layout') @section('title', 'Register Admin') @section('auth-content') -
-
- + + + + + +
+
+
+
+
+
+
+
+
+
+ +
+ + +
+
+
Pendaftaran Admin
+
Buat Akun Baru
+
Isi data berikut untuk mendaftarkan akun admin Anda.
+ + @if ($errors->any()) +
+ @foreach ($errors->all() as $error) +

{{ $error }}

+ @endforeach +
+ @endif + +
+ @csrf + +
+ +
+ + +
+ @error('email')
{{ $message }}
@enderror +
+ +
+ +
+ + + +
+
+
+
+
+
+
+ @error('password')
{{ $message }}
@enderror +
+ +
+ +
+ + +
+
+ + + +
+ Sudah punya akun? Login di sini +
+
+
-

Pendaftaran Akun Admin

-

Mohon gunakan email dan password yang kuat untuk keamanan sistem.

+ + +
+ +
Bergabung Sekarang
+

Bergabung
Bersama
Kami.

+

PKPPS Riyadlul Jannah

+
+

Daftarkan akun admin baru dengan aman. Gunakan email dan password kuat untuk menjaga keamanan sistem pesantren.

+
+
+
+ Gunakan email institusi yang valid +
+
+
+ Password minimal 8 karakter campuran +
+
+
+ +
-{{-- Tampilkan error dari validator --}} -@if ($errors->any()) -
- @foreach ($errors->all() as $error) -

{{ $error }}

- @endforeach -
-@endif + -
- @csrf - -
- - - @error('email')
{{ $message }}
@enderror -
- -
- - - @error('password')
{{ $message }}
@enderror -
- -
- - -
- -
- -
- -

- Sudah punya akun? Login di sini -

-
@endsection \ No newline at end of file diff --git a/sim-pkpps/resources/views/admin/auth/reset_password.blade.php b/sim-pkpps/resources/views/admin/auth/reset_password.blade.php new file mode 100644 index 0000000..df9853d --- /dev/null +++ b/sim-pkpps/resources/views/admin/auth/reset_password.blade.php @@ -0,0 +1,521 @@ +{{-- resources/views/admin/auth/reset_password.blade.php --}} +@extends('auth.auth_layout') + +@section('title', 'Reset Password') + +@section('auth-content') + + + + + +
+
+
+
+
+
+ +
+ + +
+ +
Langkah Terakhir
+

Buat
Password
Baru.

+

PKPPS Riyadlul Jannah

+
+ +
+
+
+
Email terkirim
+
+
+
+
OTP terverifikasi
+
+
+
3
+
Buat password baru Ikuti ketentuan di bawah
+
+
+ + {{-- Ketentuan Password --}} +
+
Ketentuan Password
+
+ Minimal 8 karakter +
+
+ Mengandung huruf besar (A-Z) +
+
+ Mengandung huruf kecil (a-z) +
+
+ Mengandung angka (0-9) +
+
+ Mengandung simbol (!@#$%...) +
+
+
+ + +
+
+
+ +
+
Langkah 3 dari 3
+
Password Baru
+
Buat password baru yang kuat. Password harus memenuhi semua ketentuan keamanan berikut.
+ + {{-- Ketentuan Password (visible di mobile saja) --}} + + + @if ($errors->any()) +
+ + {{ $errors->first() }} +
+ @endif + + @if(session('success')) +
+ + {{ session('success') }} +
+ @endif + +
+ @csrf + + +
+ +
+ + + +
+
+
+
+
+
+
+
+
+ +
+ +
+ + + +
+
+
+ + +
+ + + Kembali ke Login + +
+
+ +
+
+ + + + +@endsection diff --git a/sim-pkpps/resources/views/admin/auth/verify_otp.blade.php b/sim-pkpps/resources/views/admin/auth/verify_otp.blade.php new file mode 100644 index 0000000..bff0302 --- /dev/null +++ b/sim-pkpps/resources/views/admin/auth/verify_otp.blade.php @@ -0,0 +1,419 @@ +{{-- resources/views/admin/auth/verify_otp.blade.php --}} +@extends('auth.auth_layout') + +@section('title', 'Verifikasi OTP') + +@section('auth-content') + + + + + +
+
+
+
+
+
+ +
+ + +
+ +
Verifikasi
+

Cek
Email
Anda.

+

PKPPS Riyadlul Jannah

+
+

Masukkan kode 6 digit yang telah kami kirim. Periksa juga folder spam jika belum menerima email.

+ +
+
+
+
Email terkirim
+
+
+
2
+
Verifikasi kode OTP Masukkan 6 digit kode
+
+
+
3
+
Buat password baru
+
+
+
+ + +
+
+
+ +
+
Langkah 2 dari 3
+
Masukkan Kode OTP
+
Kode verifikasi 6 digit telah dikirim ke:
+
+ {{ $email }} +
+ + @if ($errors->any()) +
+ + {{ $errors->first() }} +
+ @endif + + @if(session('success')) +
+ + {{ session('success') }} +
+ @endif + +
+ @csrf + + + +
+ + + + + + +
+ + +
+ +
+

Tidak menerima kode?

+
+ @csrf + + + Kirim Ulang OTP + + (60s) +
+
+ + + Ganti Email + +
+
+ +
+
+ + +@endsection diff --git a/sim-pkpps/resources/views/admin/berita/create.blade.php b/sim-pkpps/resources/views/admin/berita/create.blade.php index a63487a..5948cd5 100644 --- a/sim-pkpps/resources/views/admin/berita/create.blade.php +++ b/sim-pkpps/resources/views/admin/berita/create.blade.php @@ -149,8 +149,8 @@ class="form-control @error('status') is-invalid @enderror" Pilih Kelas yang Akan Menerima Berita * -
-
+
+
@foreach($kelasOptions as $kelas)
-
+
@@ -264,7 +264,7 @@ function updateKelasCount() { + + + +{{-- Alert --}} +@if(session('success')) +
{{ session('success') }}
+@endif +@if(session('error')) +
{{ session('error') }}
+@endif + +{{-- ===== STATUS HERO ===== --}} +
+
{{ $isOpen ? 'πŸ”“' : 'πŸ”’' }}
+
+
+ + {{ $isOpen ? 'AKSES DIBUKA' : 'AKSES DITUTUP' }} +
+

+ @if($isOpen) + Santri sedang bisa menginputkan capaian mereka + @else + Santri belum bisa menginputkan capaian + @endif +

+

+ @if($isOpen) + Dibuka oleh {{ $config['opened_by'] ?? '-' }} + pada {{ $config['opened_at'] ? \Carbon\Carbon::parse($config['opened_at'])->isoFormat('D MMM YYYY, HH:mm') : '-' }} + @if($config['id_semester']) + • Semester: {{ \App\Models\Semester::where('id_semester', $config['id_semester'])->value('nama_semester') ?? '-' }} + @else + • Semua semester diizinkan + @endif + @if($sisaWaktu) + • Sisa waktu: {{ $sisaWaktu }} + @endif + @else + @if($config['closed_at']) + Ditutup pada {{ \Carbon\Carbon::parse($config['closed_at'])->isoFormat('D MMM YYYY, HH:mm') }} + @else + Belum pernah dibuka + @endif + @endif +

+ @if(!empty($config['catatan'])) +

"{{ $config['catatan'] }}"

+ @endif +
+
+ +{{-- ===== INFO STATS ===== --}} +
+
+
Status
+
+ + {{ $isOpen ? 'Terbuka' : 'Tertutup' }} +
+
+
+
Dibuka Oleh
+
{{ $config['opened_by'] ?? '-' }}
+
+
+
Semester
+
+ @if($config['id_semester']) + {{ \App\Models\Semester::where('id_semester', $config['id_semester'])->value('nama_semester') ?? '-' }} + @else + Semua Semester + @endif +
+
+
+
Auto-Close
+
+ @if($config['auto_close_at']) + {{ \Carbon\Carbon::parse($config['auto_close_at'])->isoFormat('D MMM HH:mm') }} + @else + Manual + @endif +
+
+
+ +
+ +{{-- ===== FORM BUKA AKSES ===== --}} +
+

Buka Akses Input Capaian

+ +
+ @csrf + +
+ + + Kosongkan = santri bisa input di semua semester +
+ +
+ +
+ + jam +
+ Kosongkan = harus ditutup manual oleh admin +
+ +
+ + +
+ + +
+
+ +{{-- ===== TUTUP AKSES ===== --}} +
+

Tutup Akses Input Capaian

+ + @if($isOpen) +
+

+ + Saat ini akses sedang dibuka. Klik tombol di bawah untuk menutup akses input capaian santri segera. +

+ @if($sisaWaktu) +

+ Sisa waktu auto-close: {{ $sisaWaktu }} +

+ @endif +
+ +
+ @csrf + +
+ @else +
+ + Akses saat ini sudah tertutup.
+ Gunakan form di sebelah kiri untuk membuka akses. +
+ @endif +
+ +
+ +{{-- ===== PANDUAN ===== --}} +
+

Alur Penggunaan

+
+
+
1
+
Admin membuka akses
Pilih semester & opsional durasi waktu lalu klik "Buka Akses".
+
+
+
2
+
Santri input capaian
Santri login ke web-nya dan bisa input capaian sesuai materi kelasnya.
+
+
+
3
+
Data langsung masuk
Data capaian santri langsung terlihat di dashboard admin & riwayat santri.
+
+
+
4
+
Admin menutup akses
Setelah selesai, tutup akses manual atau biarkan auto-close berjalan.
+
+
+
+@endsection \ No newline at end of file diff --git a/sim-pkpps/resources/views/admin/capaian/create.blade.php b/sim-pkpps/resources/views/admin/capaian/create.blade.php index 3e076d9..41b7126 100644 --- a/sim-pkpps/resources/views/admin/capaian/create.blade.php +++ b/sim-pkpps/resources/views/admin/capaian/create.blade.php @@ -71,7 +71,7 @@
{{-- Materi Info Display --}} -