Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69b03fb467 | |||
| 056520d48f | |||
| 18c0e1d489 | |||
| db5b20c572 | |||
| 024873cfb7 | |||
| f4b9501d50 | |||
| d85d066f6c | |||
| c4157cec8d | |||
| 91fc2fbd16 | |||
| cdba16e9e5 |
@@ -0,0 +1,14 @@
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.*
|
||||
!.env.docker.example
|
||||
node_modules
|
||||
vendor
|
||||
storage/logs/*.log
|
||||
storage/framework/cache/data/*
|
||||
storage/framework/sessions/*
|
||||
storage/framework/views/*
|
||||
bootstrap/cache/*.php
|
||||
database/*.sqlite
|
||||
docs/superpowers
|
||||
@@ -1,18 +1,14 @@
|
||||
APP_NAME=Laravel
|
||||
APP_ENV=local
|
||||
APP_KEY=base64:LT9slFZW4kFTXKm807LCwYoCFDx97HdCQZ1kf1HpvqQ=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
APP_NAME="WebGIS Miskin"
|
||||
APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=https://domain-anda.test
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
# PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
@@ -25,7 +21,8 @@ DB_HOST=mysql
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=webgis_miskin
|
||||
DB_USERNAME=webgis
|
||||
DB_PASSWORD=secret
|
||||
DB_PASSWORD=change-this-password
|
||||
MYSQL_ROOT_PASSWORD=change-this-root-password
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
@@ -36,14 +33,12 @@ SESSION_DOMAIN=null
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
# CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_HOST=redis
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
+6
-6
@@ -20,12 +20,12 @@ LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
# DB_HOST=127.0.0.1
|
||||
# DB_PORT=3306
|
||||
# DB_DATABASE=laravel
|
||||
# DB_USERNAME=root
|
||||
# DB_PASSWORD=
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=mysql
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=webgis_miskin
|
||||
DB_USERNAME=webgis
|
||||
DB_PASSWORD=secret
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
|
||||
BIN
Binary file not shown.
@@ -1,66 +0,0 @@
|
||||
# Docker Development
|
||||
|
||||
This setup runs the Laravel app, Vite, and MySQL through Docker Compose.
|
||||
|
||||
## Services
|
||||
|
||||
- Laravel app: `http://localhost:8000`
|
||||
- Vite dev server: `http://localhost:5173`
|
||||
- MySQL from host: `127.0.0.1:3307`
|
||||
- MySQL from containers: `mysql:3306`
|
||||
|
||||
## First Run
|
||||
|
||||
If you want Docker to own the Laravel environment values, copy the Docker example:
|
||||
|
||||
```powershell
|
||||
Copy-Item .env.docker.example .env
|
||||
```
|
||||
|
||||
Then start everything:
|
||||
|
||||
```powershell
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
The app container installs Composer dependencies, generates `APP_KEY` if needed, clears config, runs migrations, and seeds the database (roles + default admin account).
|
||||
|
||||
The Vite container installs Node dependencies and starts the dev server.
|
||||
|
||||
## Useful Commands
|
||||
|
||||
```powershell
|
||||
docker compose ps
|
||||
docker compose logs -f app
|
||||
docker compose logs -f vite
|
||||
docker compose exec app php artisan migrate:status
|
||||
docker compose exec app php artisan test
|
||||
docker compose down
|
||||
```
|
||||
|
||||
To remove the MySQL data volume and start with a clean database:
|
||||
|
||||
```powershell
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
## Database Credentials
|
||||
|
||||
```text
|
||||
Database: webgis_miskin
|
||||
Username: webgis
|
||||
Password: secret
|
||||
Root password: root
|
||||
```
|
||||
|
||||
## Default Login
|
||||
|
||||
Akses `http://localhost:8000` dan login dengan salah satu akun berikut:
|
||||
|
||||
| Email | Password | Role |
|
||||
|---|---|---|
|
||||
| `admin@webgis.local` | `admin123` | Administrator (akses penuh) |
|
||||
| `pengurus@webgis.local` | `password123` | Pengurus Ibadah |
|
||||
| `petugas@webgis.local` | `password123` | Petugas Pendataan |
|
||||
| `kebijakan@webgis.local` | `password123` | Pemangku Kebijakan (view only) |
|
||||
|
||||
@@ -25,5 +25,7 @@ COPY docker/app/entrypoint.sh /usr/local/bin/webgis-entrypoint
|
||||
|
||||
RUN chmod +x /usr/local/bin/webgis-entrypoint
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["webgis-entrypoint"]
|
||||
CMD ["php", "artisan", "serve", "--host=0.0.0.0", "--port=8000"]
|
||||
|
||||
@@ -1,209 +1,206 @@
|
||||
# Dokumentasi Fungsional: WebGIS Pemetaan Kemiskinan
|
||||
# WebGIS Pemetaan Kemiskinan
|
||||
|
||||
Proyek ini adalah sistem informasi geografis (WebGIS) berbasis web yang dirancang untuk memetakan sebaran rumah ibadah dan keluarga kurang mampu (miskin). Sistem ini memungkinkan pengguna untuk melakukan analisis spasial berdasarkan radius untuk mengetahui dampak atau jangkauan layanan rumah ibadah terhadap komunitas di sekitarnya.
|
||||
Aplikasi WebGIS berbasis Laravel untuk memetakan rumah ibadah, keluarga miskin, radius layanan, bantuan, transfer tanggungan, dan statistik sebaran.
|
||||
|
||||
## Filosofi & Latar Belakang
|
||||
## Deploy Dengan Docker
|
||||
|
||||
Proyek ini dibangun atas dasar filosofi kepedulian sosial, di mana rumah ibadah memiliki peran sentral dalam kesejahteraan masyarakat di sekitarnya. Prinsip utama sistem ini adalah:
|
||||
> **Setiap keluarga kurang mampu yang tinggal di sekitar rumah ibadah dianggap sebagai tanggungan atau prioritas utama untuk mendapatkan bantuan dari rumah ibadah tersebut.**
|
||||
Repository ini sudah disiapkan untuk jalan lewat Docker Compose:
|
||||
|
||||
Dengan adanya sistem WebGIS ini, pengelola rumah ibadah dapat dengan mudah memetakan siapa saja yang membutuhkan bantuan dalam radius tertentu, sehingga distribusi bantuan menjadi lebih tepat sasaran dan terorganisir.
|
||||
- `app`: container PHP Laravel, expose port `8000`
|
||||
- `mysql`: database MySQL 8.4 dengan volume persisten
|
||||
- `vite`: hanya untuk development, tidak otomatis jalan saat production deploy
|
||||
|
||||
## Fitur Utama
|
||||
## SSL Di Coolify
|
||||
|
||||
### 1. Peta Interaktif (Interactive WebGIS)
|
||||
Menggunakan library **Leaflet.js**, sistem menyediakan antarmuka peta yang responsif dengan fitur:
|
||||
- **Toggle Layer**: Berpindah antara tampilan jalan (Street) dan citra satelit.
|
||||
- **Marker Kustom**: Ikon berbeda untuk membedakan antara Rumah Ibadah (hijau) dan Rumah Miskin (merah).
|
||||
- **Geocoding Otomatis**: Saat menambahkan data baru, sistem secara otomatis mengambil alamat lengkap berdasarkan koordinat klik menggunakan OpenStreetMap Nominatim.
|
||||
SSL tidak dipasang di container aplikasi. Untuk deploy di Coolify, HTTPS ditangani oleh reverse proxy Coolify dengan sertifikat Let's Encrypt.
|
||||
|
||||
### 2. Manajemen Data (CRUD)
|
||||
Sistem mendukung pengelolaan data secara penuh:
|
||||
- **Tambah Data via Peta**: Klik pada lokasi di peta untuk memunculkan formulir input data baru.
|
||||
- **Edit Data via Form**: Klik tombol "Edit" pada popup marker di peta atau pada baris di tabel data untuk membuka formulir edit. Mendukung pembaruan semua field teks untuk Rumah Ibadah (nama, jenis, pengurus, telepon, jemaah, radius, keterangan) dan Keluarga Miskin (nama KK, jumlah jiwa, pekerjaan, pendapatan, kondisi, keterangan).
|
||||
- **Update Lokasi (Drag & Drop)**: Marker dapat digeser (drag) untuk memperbarui koordinat lokasi di database secara real-time.
|
||||
- **Hapus Data**: Menghapus data melalui popup pada marker atau melalui tabel data.
|
||||
- **Ownership Rumah Ibadah**: Pengurus Rumah Ibadah hanya dapat mengubah atau menghapus data rumah ibadah yang mereka tambahkan sendiri. Administrator dapat mengubah semua data.
|
||||
- **Ekspor PDF**: Unduh laporan seluruh data keluarga miskin dalam format PDF dari halaman Data Tabular.
|
||||
- **Ekspor CSV/PDF per Radius**: Dari panel analisis peta, ekspor hanya data keluarga miskin yang berada dalam radius terpilih — tersedia dalam format CSV (langsung di browser) dan PDF (via server, filtering menggunakan Haversine).
|
||||
- **Data Tabular**: Halaman khusus yang menampilkan seluruh data dalam bentuk tabel dengan statistik ringkasan.
|
||||
Di Coolify:
|
||||
|
||||
### 3. Dashboard Statistik & Gap Analysis
|
||||
Halaman analitik komprehensif di `/dashboard`, dapat diakses semua role:
|
||||
- **Statistik Ringkasan**: Total rumah ibadah, keluarga miskin, jiwa, dan persentase cakupan.
|
||||
- **Chart Breakdown**: Distribusi rumah ibadah per jenis (donut chart) dan keluarga miskin per kondisi rumah (bar chart horizontal) menggunakan Chart.js.
|
||||
- **Gap Analysis**: Menghitung keluarga miskin yang tidak berada dalam radius rumah ibadah manapun, menggunakan Turf.js untuk kalkulasi jarak geodesik yang akurat.
|
||||
- **Tabel Coverage per Ibadah**: Jumlah KK dan jiwa dalam radius masing-masing rumah ibadah, diurutkan dari terbanyak.
|
||||
- **Daftar Tidak Terjangkau**: Tabel seluruh keluarga miskin yang membutuhkan perhatian lebih.
|
||||
- set domain aplikasi dengan format `https://domain-anda.com`
|
||||
- arahkan domain ke service `app`
|
||||
- gunakan internal port `8000`
|
||||
- set `APP_URL=https://domain-anda.com` di environment
|
||||
|
||||
### 5. Fitur Administrator Lanjutan
|
||||
- **Hapus Massal (Bulk Delete)**: Pilih beberapa baris via checkbox di tabel Data lalu hapus sekaligus (Administrator).
|
||||
- **Import CSV**: Impor data Keluarga Miskin & Rumah Ibadah dari file CSV dengan format yang sama persis dengan hasil Ekspor CSV (round-trip).
|
||||
- **Log Aktivitas**: Setiap aksi penting (login, CRUD data, import, bulk delete, backup) dicatat dengan pengguna, deskripsi, IP, dan waktu.
|
||||
- **Backup Database**: Unduh dump `.sql` seluruh database melalui `mysqldump` (Administrator).
|
||||
- **Statistik Penggunaan**: Ringkasan login & aksi harian/mingguan/bulanan + grafik aktivitas 14 hari terakhir di halaman Log Aktivitas.
|
||||
- **Backup Terjadwal**: Backup database otomatis setiap hari pukul 00:00, tersimpan di server (maks. 7 file). Daftar backup dapat diunduh dari halaman Admin.
|
||||
- **Restore Database**: Upload file `.sql` dan restore database dengan konfirmasi ketat (ketik "RESTORE"). Hanya Administrator.
|
||||
- **Notifikasi Real-time**: Toast otomatis saat ada data baru ditambahkan pengguna lain, dengan auto-refresh tabel (polling 30 detik).
|
||||
- **Konfigurasi Sistem**: Atur pusat peta (lat/lng), zoom default, dan batas radius slider langsung dari UI di `/admin/settings` — berlaku real-time tanpa deploy ulang.
|
||||
Jika domain diisi dengan `https://`, Coolify akan mengurus sertifikat SSL dan renewal otomatis. Aplikasi Laravel sudah disiapkan untuk trust proxy header dari Coolify agar URL dan redirect tetap memakai HTTPS.
|
||||
|
||||
### 4. Analisis Spasial Radius
|
||||
Fitur unggulan untuk menganalisis sebaran keluarga miskin di sekitar rumah ibadah:
|
||||
- **Analisis Otomatis**: Mengklik marker rumah ibadah akan langsung memunculkan lingkaran radius dan panel statistik.
|
||||
- **Penghitungan Real-time**: Menggunakan **Turf.js**, sistem menghitung secara instan jumlah rumah, jumlah Kepala Keluarga (KK), dan jumlah Jiwa yang berada di dalam radius terpilih.
|
||||
- **Slider Radius**: Pengguna dapat menggeser slider (50m hingga 2000m) untuk melihat perubahan statistik secara dinamis.
|
||||
- **Highlight Visual**: Marker rumah miskin yang masuk ke dalam radius akan berubah warna menjadi kuning untuk memudahkan identifikasi visual.
|
||||
## Kebutuhan Hosting
|
||||
|
||||
## Arsitektur Teknis
|
||||
- Docker Engine
|
||||
- Docker Compose plugin
|
||||
- Domain atau subdomain yang diarahkan ke server
|
||||
- Coolify reverse proxy yang diarahkan ke service `app` port internal `8000`
|
||||
|
||||
Proyek ini telah dimigrasikan ke framework **Laravel 13** dengan struktur sebagai berikut:
|
||||
Document root tidak perlu diarahkan manual ke `public` karena aplikasi berjalan di container Laravel.
|
||||
|
||||
- **Backend**:
|
||||
- **Laravel Eloquent**: Digunakan untuk interaksi database yang aman dan efisien.
|
||||
- **RESTful API**: Menyediakan endpoint `/api/ibadah` dan `/api/miskin` untuk komunikasi frontend-backend.
|
||||
- **Migrations**: Skema database yang terstruktur dan mudah dikelola.
|
||||
- **Frontend**:
|
||||
- **Blade Templates**: Mesin templating Laravel untuk antarmuka yang dinamis.
|
||||
- **Tailwind CSS & DaisyUI**: Memberikan desain antarmuka yang modern, premium, dan responsif.
|
||||
- **JavaScript (Vanilla)**: Menangani logika peta dan interaksi API tanpa beban framework berat.
|
||||
- **Database**: MySQL/MariaDB untuk penyimpanan data spasial dan atribut.
|
||||
## Setup Pertama Di Server
|
||||
|
||||
## Cara Menjalankan Project
|
||||
1. Upload atau clone project ke server.
|
||||
|
||||
### Prasyarat
|
||||
2. Buat file environment Docker:
|
||||
|
||||
- **Docker Desktop** sudah terinstall dan berjalan.
|
||||
- Port **`8000`** (app), **`5173`** (Vite), dan **`3307`** (MySQL dari host) tidak dipakai aplikasi lain.
|
||||
|
||||
---
|
||||
|
||||
### 1. Jalankan Pertama Kali
|
||||
|
||||
```powershell
|
||||
docker compose up -d --build
|
||||
```bash
|
||||
cp .env.docker.example .env
|
||||
```
|
||||
|
||||
Saat container pertama kali dibuild dan dijalankan, entrypoint otomatis melakukan:
|
||||
|
||||
| Langkah | Keterangan |
|
||||
|---------|-----------|
|
||||
| Install Composer dependencies | Jika `vendor/` belum ada |
|
||||
| Buat `.env` | Dari `.env.docker.example` jika `.env` belum ada |
|
||||
| Generate `APP_KEY` | Jika masih kosong |
|
||||
| `php artisan migrate` | Buat semua tabel database |
|
||||
| `php artisan db:seed` | Isi role + 4 akun demo (lihat bawah) |
|
||||
| `php artisan schedule:work &` | Jalankan Laravel Scheduler di background (backup harian) |
|
||||
|
||||
Setelah container berjalan, buka **`http://localhost:8000`**
|
||||
|
||||
---
|
||||
|
||||
### 2. Akun Demo (Langsung Bisa Login)
|
||||
|
||||
| Role | Email | Password | Akses |
|
||||
|------|-------|----------|-------|
|
||||
| Administrator | `admin@webgis.local` | `admin123` | Semua fitur + kelola pengguna, backup, konfigurasi |
|
||||
| Pengurus Ibadah | `pengurus@webgis.local` | `password123` | CRUD rumah ibadah milik sendiri + analisis radius |
|
||||
| Petugas Pendataan | `petugas@webgis.local` | `password123` | CRUD keluarga miskin |
|
||||
| Pemangku Kebijakan | `kebijakan@webgis.local` | `password123` | View-only: peta, dashboard, ekspor |
|
||||
|
||||
---
|
||||
|
||||
### 3. URL yang Tersedia
|
||||
|
||||
| URL | Keterangan |
|
||||
|-----|-----------|
|
||||
| `http://localhost:8000` | Aplikasi utama |
|
||||
| `http://localhost:5173` | Vite dev server (hot-reload assets) |
|
||||
| `127.0.0.1:3307` | MySQL dari host (user: `webgis`, pass: `secret`, db: `webgis_miskin`) |
|
||||
|
||||
---
|
||||
|
||||
### 4. Perintah Sehari-hari
|
||||
|
||||
**Status container:**
|
||||
```powershell
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
**Lihat log:**
|
||||
```powershell
|
||||
docker compose logs -f app # log Laravel app
|
||||
docker compose logs -f vite # log Vite dev server
|
||||
```
|
||||
|
||||
**Jalankan test:**
|
||||
```powershell
|
||||
docker compose exec app php artisan test --no-coverage
|
||||
```
|
||||
|
||||
**Backup database manual:**
|
||||
```powershell
|
||||
docker compose exec app php artisan backup:database
|
||||
# File tersimpan di storage/app/backups/
|
||||
```
|
||||
|
||||
**Lihat jadwal yang berjalan:**
|
||||
```powershell
|
||||
docker compose exec app php artisan schedule:list
|
||||
```
|
||||
|
||||
**Lihat daftar route:**
|
||||
```powershell
|
||||
docker compose exec app php artisan route:list
|
||||
```
|
||||
|
||||
**Bersihkan cache:**
|
||||
```powershell
|
||||
docker compose exec app php artisan cache:clear
|
||||
docker compose exec app php artisan config:clear
|
||||
```
|
||||
|
||||
**Stop container:**
|
||||
```powershell
|
||||
docker compose down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Reset Database (Mulai dari Awal)
|
||||
|
||||
> **Peringatan:** Menghapus semua data.
|
||||
|
||||
```powershell
|
||||
docker compose down -v # hapus container + volume MySQL
|
||||
docker compose up -d --build # build ulang + migrate + seed otomatis
|
||||
```
|
||||
|
||||
Atau jika container sudah berjalan dan ingin reset tanpa rebuild:
|
||||
```powershell
|
||||
docker compose exec app php artisan migrate:fresh --seed --force
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Konfigurasi Database (Referensi)
|
||||
3. Edit `.env` minimal bagian ini:
|
||||
|
||||
```env
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
APP_URL=https://domain-anda.com
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=mysql # nama service di docker-compose.yml
|
||||
DB_HOST=mysql
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=webgis_miskin
|
||||
DB_USERNAME=webgis
|
||||
DB_PASSWORD=secret
|
||||
DB_PASSWORD=password-kuat
|
||||
MYSQL_ROOT_PASSWORD=password-root-kuat
|
||||
```
|
||||
|
||||
MySQL root: `MYSQL_ROOT_PASSWORD=root`
|
||||
Jika project di-upload dari mesin development dan `.env` lama ikut terbawa, ubah nilainya ke konfigurasi production di atas. Docker Compose otomatis membaca file `.env` di root project.
|
||||
|
||||
## Daftar Halaman
|
||||
4. Build asset frontend:
|
||||
|
||||
1. **Beranda (`/`)**: Halaman landing dengan latar belakang peta animasi dan navigasi cepat.
|
||||
2. **Peta Interaktif (`/map`)**: Antarmuka utama untuk melihat sebaran dan melakukan analisis radius.
|
||||
3. **Data Tabular (`/data`)**: Manajemen data dalam format tabel dengan ringkasan statistik total.
|
||||
4. **Dashboard Statistik (`/dashboard`)**: Analisis lengkap sebaran data — breakdown per jenis rumah ibadah, kondisi rumah miskin, Gap Analysis (% keluarga miskin terjangkau/tidak), dan tabel coverage per rumah ibadah. Dapat diakses oleh semua role.
|
||||
5. **Admin Pengguna (`/admin/users`)**: Manajemen pengguna sistem — tambah, edit, hapus akun, assign role, dan reset password. Hanya dapat diakses oleh Administrator.
|
||||
6. **Log Aktivitas (`/admin/logs`)**: Riwayat 200 aktivitas terakhir (login, tambah/ubah/hapus data, import, bulk delete, backup). Hanya Administrator.
|
||||
7. **Konfigurasi Sistem (`/admin/settings`)**: Atur pusat peta, zoom default, dan batas radius analisis. Hanya Administrator.
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
---
|
||||
*Dokumentasi ini dibuat untuk menjelaskan kapabilitas sistem WebGIS Pemetaan Kemiskinan versi Laravel.*
|
||||
5. Build dan jalankan container:
|
||||
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
Entrypoint container `app` otomatis menjalankan:
|
||||
|
||||
- `composer install`
|
||||
- membuat `.env` dari `.env.docker.example` jika belum ada
|
||||
- `php artisan key:generate` jika `APP_KEY` belum ada
|
||||
- `php artisan migrate --force`
|
||||
- `php artisan db:seed --force`
|
||||
- `php artisan schedule:work`
|
||||
|
||||
## Command Operasional
|
||||
|
||||
Cek container:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Lihat log aplikasi:
|
||||
|
||||
```bash
|
||||
docker compose logs -f app
|
||||
```
|
||||
|
||||
Masuk ke container app:
|
||||
|
||||
```bash
|
||||
docker compose exec app sh
|
||||
```
|
||||
|
||||
Jalankan ulang cache Laravel setelah edit `.env`:
|
||||
|
||||
```bash
|
||||
docker compose exec app php artisan config:cache
|
||||
docker compose exec app php artisan route:cache
|
||||
docker compose exec app php artisan view:cache
|
||||
```
|
||||
|
||||
Restart container:
|
||||
|
||||
```bash
|
||||
docker compose restart app
|
||||
```
|
||||
|
||||
Stop semua container:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Stop container tanpa menghapus data database:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Hapus container sekaligus volume database:
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
Gunakan `down -v` hanya kalau memang mau reset data MySQL.
|
||||
|
||||
## Update Aplikasi Di Server
|
||||
|
||||
1. Pull atau upload perubahan terbaru.
|
||||
|
||||
2. Build ulang asset:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
3. Rebuild container:
|
||||
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
4. Refresh cache:
|
||||
|
||||
```bash
|
||||
docker compose exec app php artisan optimize:clear
|
||||
docker compose exec app php artisan config:cache
|
||||
docker compose exec app php artisan route:cache
|
||||
docker compose exec app php artisan view:cache
|
||||
```
|
||||
|
||||
## Development Lokal Dengan Vite
|
||||
|
||||
Untuk development yang butuh Vite dev server:
|
||||
|
||||
```bash
|
||||
docker compose --profile dev up --build -d
|
||||
```
|
||||
|
||||
Service Vite hanya diexpose di network Docker. Jika butuh akses langsung dari host saat development lokal, tambahkan override compose lokal untuk publish port Vite. Untuk production deploy, jangan gunakan profile `dev`.
|
||||
|
||||
## Akun Awal
|
||||
|
||||
Seeder membuat akun awal berikut:
|
||||
|
||||
| Role | Email | Password |
|
||||
| --- | --- | --- |
|
||||
| Administrator | `admin@webgis.local` | `admin123` |
|
||||
| Pengurus Ibadah | `pengurus@webgis.local` | `password123` |
|
||||
| Petugas Pendataan | `petugas@webgis.local` | `password123` |
|
||||
| Pemangku Kebijakan | `kebijakan@webgis.local` | `password123` |
|
||||
|
||||
Ganti password akun awal setelah deploy.
|
||||
|
||||
## Halaman Utama
|
||||
|
||||
- `/login` - login pengguna
|
||||
- `/map` - peta interaktif
|
||||
- `/data` - data tabular
|
||||
- `/dashboard` - dashboard statistik
|
||||
- `/permohonan` - permohonan transfer
|
||||
- `/admin/users` - manajemen pengguna
|
||||
- `/admin/logs` - log aktivitas
|
||||
- `/admin/settings` - pengaturan sistem
|
||||
|
||||
## Catatan Penting
|
||||
|
||||
- Jangan commit `.env` ke repository.
|
||||
- Pastikan `APP_URL` sesuai domain production.
|
||||
- Di Coolify, jangan publish host port manual. Set domain ke service `app` dan internal port `8000`.
|
||||
- Volume `webgis_mysql_data` menyimpan data MySQL.
|
||||
- Folder project dimount ke `/var/www/html`, jadi perubahan file di server langsung terbaca container.
|
||||
- Jika hosting punya backup volume, backup volume MySQL dan folder project.
|
||||
|
||||
+30
-12
@@ -1,9 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id" data-theme="light">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>WebGIS Miskin & Ibadah</title>
|
||||
<title>WebGIS Pemetaan Kemiskinan</title>
|
||||
|
||||
<!-- Leaflet -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||
@@ -16,19 +17,31 @@
|
||||
<!-- Plus Jakarta Sans Font -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap" rel="stylesheet">
|
||||
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap"
|
||||
rel="stylesheet">
|
||||
|
||||
<style>
|
||||
* {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
font-weight: 500;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6, .btn, .card-title, .stat-value {
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
.btn,
|
||||
.card-title,
|
||||
.stat-value {
|
||||
font-weight: 700 !important;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
#bgMap {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -39,11 +52,13 @@
|
||||
opacity: 0.5;
|
||||
filter: saturate(1.2) brightness(0.7);
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Glass effect for content */
|
||||
.hero-content {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
@@ -53,14 +68,16 @@
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .hero-content {
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="min-h-screen bg-base-200 flex flex-col items-center justify-center overflow-hidden">
|
||||
|
||||
|
||||
<!-- Background Map -->
|
||||
<div id="bgMap"></div>
|
||||
|
||||
@@ -68,9 +85,9 @@
|
||||
<div class="hero-content text-center flex-col gap-2">
|
||||
<!-- Logo -->
|
||||
<div class="text-7xl mb-2">🏘️</div>
|
||||
<h1 class="text-4xl font-bold tracking-tight text-primary">WebGIS Miskin & Ibadah</h1>
|
||||
<h1 class="text-4xl font-bold tracking-tight text-primary">WebGIS Pemetaan Kemiskinan</h1>
|
||||
<p class="max-w-md text-base-content/60 mt-2 mb-6 leading-relaxed">
|
||||
Sistem Informasi Geografis untuk pemetaan rumah ibadah dan
|
||||
Sistem Informasi Geografis untuk pemetaan rumah ibadah dan
|
||||
analisis sebaran keluarga kurang mampu.
|
||||
</p>
|
||||
|
||||
@@ -99,7 +116,7 @@
|
||||
let val = 0;
|
||||
const bar = document.getElementById('redirectProgress');
|
||||
const tick = setInterval(() => {
|
||||
val += 100 / 50;
|
||||
val += 100 / 50;
|
||||
bar.value = val;
|
||||
if (val >= 100) clearInterval(tick);
|
||||
}, 100);
|
||||
@@ -133,7 +150,7 @@
|
||||
fetch('api/get_miskin.php').then(r => r.json())
|
||||
]).then(([ibadah, miskin]) => {
|
||||
const markers = [];
|
||||
|
||||
|
||||
ibadah.forEach(item => {
|
||||
L.circleMarker([+item.latitude, +item.longitude], {
|
||||
radius: 7, fillColor: "#22c55e", color: "#fff", weight: 2, opacity: 1, fillOpacity: 0.8
|
||||
@@ -151,14 +168,15 @@
|
||||
if (markers.length) {
|
||||
map.fitBounds(markers, { padding: [50, 50] });
|
||||
}
|
||||
|
||||
|
||||
// Subtle animation: slow pan
|
||||
let lat = map.getCenter().lat;
|
||||
setInterval(() => {
|
||||
lat += 0.000005;
|
||||
map.panTo([lat, map.getCenter().lng], { animate: true, duration: 1 });
|
||||
}, 1000);
|
||||
}).catch(() => {});
|
||||
}).catch(() => { });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
+38
-13
@@ -1,9 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id" data-theme="light">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Data Tabular - WebGIS Miskin & Ibadah</title>
|
||||
<title>Data Tabular - WebGIS Pemetaan Kemiskinan</title>
|
||||
|
||||
<!-- DaisyUI + Tailwind -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet">
|
||||
@@ -13,14 +14,34 @@
|
||||
<!-- Plus Jakarta Sans Font -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap"
|
||||
rel="stylesheet">
|
||||
|
||||
<style>
|
||||
* { font-family: 'Plus Jakarta Sans', sans-serif; }
|
||||
body { font-weight: 500; background-color: #f8fafc; }
|
||||
h1, h2, h3, h4, h5, h6, .btn, .card-title, .stat-value { font-weight: 700 !important; letter-spacing: -0.01em; }
|
||||
* {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
font-weight: 500;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
.btn,
|
||||
.card-title,
|
||||
.stat-value {
|
||||
font-weight: 700 !important;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="min-h-screen">
|
||||
|
||||
<!-- ═══ NAVBAR ═══════════════════════════════════════════════════════════ -->
|
||||
@@ -28,20 +49,21 @@
|
||||
<div class="flex-1">
|
||||
<a href="../index.html" class="btn btn-ghost text-lg font-bold gap-2 text-primary">
|
||||
<i class="fa-solid fa-house-chimney-crack"></i>
|
||||
WebGIS Miskin & Ibadah
|
||||
WebGIS Pemetaan Kemiskinan
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<ul class="menu menu-horizontal px-1 gap-1">
|
||||
<li><a href="map.html" class="font-medium"><i class="fa-solid fa-map"></i> Peta</a></li>
|
||||
<li><a href="data.html" class="font-semibold bg-primary/10 text-primary rounded-lg"><i class="fa-solid fa-table-list"></i> Data Tabular</a></li>
|
||||
<li><a href="data.html" class="font-semibold bg-primary/10 text-primary rounded-lg"><i
|
||||
class="fa-solid fa-table-list"></i> Data Tabular</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ MAIN CONTENT ════════════════════════════════════════════════════ -->
|
||||
<main class="max-w-screen-xl mx-auto px-4 py-8">
|
||||
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 mb-8">
|
||||
<div>
|
||||
@@ -52,7 +74,8 @@
|
||||
<p class="text-base-content/50 mt-1">Daftar rumah ibadah dan keluarga miskin dalam matriks tabular.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="map.html" class="btn btn-primary shadow-lg"><i class="fa-solid fa-plus"></i> Tambah Data di Peta</a>
|
||||
<a href="map.html" class="btn btn-primary shadow-lg"><i class="fa-solid fa-plus"></i> Tambah Data di
|
||||
Peta</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +100,8 @@
|
||||
|
||||
<!-- Tabs -->
|
||||
<div role="tablist" class="tabs tabs-lifted tabs-lg mb-6">
|
||||
<input type="radio" name="data_tabs" role="tab" class="tab font-bold" aria-label="🕌 Rumah Ibadah" checked />
|
||||
<input type="radio" name="data_tabs" role="tab" class="tab font-bold" aria-label="🕌 Rumah Ibadah"
|
||||
checked />
|
||||
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6 shadow-sm">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra w-full" id="tableIbadah">
|
||||
@@ -189,8 +213,8 @@
|
||||
|
||||
document.getElementById('confirmDeleteBtn').onclick = async () => {
|
||||
const { type, id } = deleteTarget;
|
||||
const res = await fetch(`../api/delete_${type}.php`, {
|
||||
method: 'POST', body: JSON.stringify(type === 'ibadah' ? { id } : { id_rumah: id })
|
||||
const res = await fetch(`../api/delete_${type}.php`, {
|
||||
method: 'POST', body: JSON.stringify(type === 'ibadah' ? { id } : { id_rumah: id })
|
||||
});
|
||||
const resJson = await res.json();
|
||||
if (resJson.success) {
|
||||
@@ -210,4 +234,5 @@
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
+104
-62
@@ -1,9 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id" data-theme="light">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Peta Interaktif - WebGIS Miskin & Ibadah</title>
|
||||
<title>Peta Interaktif - WebGIS Pemetaan Kemiskinan</title>
|
||||
|
||||
<!-- Leaflet -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||
@@ -18,15 +19,46 @@
|
||||
<!-- Plus Jakarta Sans Font -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap"
|
||||
rel="stylesheet">
|
||||
|
||||
<style>
|
||||
* { font-family: 'Plus Jakarta Sans', sans-serif; }
|
||||
body { font-weight: 500; height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
|
||||
h1, h2, h3, h4, h5, h6, .btn, .card-title, .stat-value { font-weight: 700 !important; letter-spacing: -0.01em; }
|
||||
.map-wrapper { flex: 1; position: relative; min-height: 0; }
|
||||
#map { width: 100%; height: 100%; }
|
||||
|
||||
* {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
font-weight: 500;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
.btn,
|
||||
.card-title,
|
||||
.stat-value {
|
||||
font-weight: 700 !important;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.map-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
#map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Floating Controls */
|
||||
.floating-panel {
|
||||
position: absolute;
|
||||
@@ -37,7 +69,7 @@
|
||||
max-height: calc(100vh - 100px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(10px);
|
||||
@@ -46,7 +78,8 @@
|
||||
}
|
||||
|
||||
/* Marker dragging hint */
|
||||
#dragHint, #mapHint {
|
||||
#dragHint,
|
||||
#mapHint {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
@@ -65,6 +98,7 @@
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<!-- ═══ NAVBAR ═══════════════════════════════════════════════════════════ -->
|
||||
@@ -72,7 +106,7 @@
|
||||
<div class="flex-1">
|
||||
<a href="../index.html" class="btn btn-ghost text-lg font-bold gap-2 text-primary">
|
||||
<i class="fa-solid fa-house-chimney-crack"></i>
|
||||
WebGIS Miskin & Ibadah
|
||||
WebGIS Pemetaan Kemiskinan
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
@@ -104,13 +138,16 @@
|
||||
Mode Operasi
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 gap-2">
|
||||
<button class="btn btn-sm btn-outline btn-primary justify-start gap-2 active" id="btnModeView" onclick="setMode('view')">
|
||||
<button class="btn btn-sm btn-outline btn-primary justify-start gap-2 active" id="btnModeView"
|
||||
onclick="setMode('view')">
|
||||
<i class="fa-solid fa-eye"></i> View & Analisis
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline btn-success justify-start gap-2" id="btnModeIbadah" onclick="setMode('ibadah')">
|
||||
<button class="btn btn-sm btn-outline btn-success justify-start gap-2" id="btnModeIbadah"
|
||||
onclick="setMode('ibadah')">
|
||||
<i class="fa-solid fa-plus"></i> Tambah Rumah Ibadah
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline btn-error justify-start gap-2" id="btnModeMiskin" onclick="setMode('miskin')">
|
||||
<button class="btn btn-sm btn-outline btn-error justify-start gap-2" id="btnModeMiskin"
|
||||
onclick="setMode('miskin')">
|
||||
<i class="fa-solid fa-plus"></i> Tambah Rumah Miskin
|
||||
</button>
|
||||
</div>
|
||||
@@ -130,12 +167,13 @@
|
||||
<div class="text-xs font-bold text-primary truncate" id="activeName">Nama Ibadah</div>
|
||||
<div class="text-[10px] text-base-content/40" id="activeAddr">Alamat</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label py-1">
|
||||
<span class="label-text-alt font-bold">Radius: <span id="radiusVal">500</span>m</span>
|
||||
</label>
|
||||
<input type="range" min="50" max="2000" value="500" step="50" class="range range-xs range-primary" id="radiusSlider">
|
||||
<input type="range" min="50" max="2000" value="500" step="50"
|
||||
class="range range-xs range-primary" id="radiusSlider">
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
@@ -168,7 +206,7 @@
|
||||
</div>
|
||||
|
||||
<!-- ═══ MODALS ═══════════════════════════════════════════════════════════ -->
|
||||
|
||||
|
||||
<!-- Modal: Input Ibadah -->
|
||||
<dialog id="modalIbadah" class="modal">
|
||||
<div class="modal-box">
|
||||
@@ -179,11 +217,13 @@
|
||||
<div class="bg-base-200 p-2 rounded text-xs font-mono" id="ib_coords">Lat: -, Lng: -</div>
|
||||
<label class="form-control w-full">
|
||||
<div class="label"><span class="label-text font-bold">Nama Rumah Ibadah</span></div>
|
||||
<input type="text" id="ib_nama" placeholder="Contoh: Masjid Nurul Iman" class="input input-bordered w-full" />
|
||||
<input type="text" id="ib_nama" placeholder="Contoh: Masjid Nurul Iman"
|
||||
class="input input-bordered w-full" />
|
||||
</label>
|
||||
<label class="form-control w-full">
|
||||
<div class="label"><span class="label-text font-bold">Alamat</span></div>
|
||||
<textarea id="ib_alamat" class="textarea textarea-bordered h-20" placeholder="Alamat lengkap..."></textarea>
|
||||
<textarea id="ib_alamat" class="textarea textarea-bordered h-20"
|
||||
placeholder="Alamat lengkap..."></textarea>
|
||||
</label>
|
||||
<label class="form-control w-full">
|
||||
<div class="label"><span class="label-text font-bold">Radius Default Analysis (Meter)</span></div>
|
||||
@@ -207,7 +247,8 @@
|
||||
<div class="bg-base-200 p-2 rounded text-xs font-mono" id="rm_coords">Lat: -, Lng: -</div>
|
||||
<label class="form-control w-full">
|
||||
<div class="label"><span class="label-text font-bold">Alamat</span></div>
|
||||
<textarea id="rm_alamat" class="textarea textarea-bordered h-20" placeholder="Alamat lengkap..."></textarea>
|
||||
<textarea id="rm_alamat" class="textarea textarea-bordered h-20"
|
||||
placeholder="Alamat lengkap..."></textarea>
|
||||
</label>
|
||||
<label class="form-control w-full">
|
||||
<div class="label"><span class="label-text font-bold">ID Rumah / Nama Pemilik</span></div>
|
||||
@@ -255,7 +296,7 @@
|
||||
let activeCircle = null, activeIbadah = null;
|
||||
let ibadahData = [], miskinData = [];
|
||||
let tempMarker = null;
|
||||
|
||||
|
||||
let ibadahMarkers = new Map(); // id -> marker
|
||||
let miskinMarkers = new Map(); // id_rumah -> marker
|
||||
|
||||
@@ -286,7 +327,7 @@
|
||||
|
||||
function initMap() {
|
||||
map = L.map('map', { zoomControl: false }).setView([-0.0553, 109.3463], 17);
|
||||
|
||||
|
||||
const osm = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 });
|
||||
const satellite = L.layerGroup([
|
||||
L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { maxZoom: 19 }),
|
||||
@@ -296,9 +337,9 @@
|
||||
|
||||
satellite.addTo(map);
|
||||
L.control.zoom({ position: 'bottomright' }).addTo(map);
|
||||
L.control.layers({ "Satelit": satellite, "Street": osm },
|
||||
{ "Rumah Ibadah": layerIbadah, "Rumah Miskin": layerMiskin },
|
||||
{ position: 'topright' }).addTo(map);
|
||||
L.control.layers({ "Satelit": satellite, "Street": osm },
|
||||
{ "Rumah Ibadah": layerIbadah, "Rumah Miskin": layerMiskin },
|
||||
{ position: 'topright' }).addTo(map);
|
||||
|
||||
layerIbadah.addTo(map);
|
||||
layerMiskin.addTo(map);
|
||||
@@ -315,7 +356,7 @@
|
||||
fetch('../api/get_ibadah.php').then(r => r.json()),
|
||||
fetch('../api/get_miskin.php').then(r => r.json())
|
||||
]);
|
||||
|
||||
|
||||
// Validate data is array
|
||||
if (!Array.isArray(ri) || !Array.isArray(rm)) {
|
||||
console.error("Invalid API response", { ri, rm });
|
||||
@@ -324,7 +365,7 @@
|
||||
|
||||
ibadahData = ri;
|
||||
miskinData = rm;
|
||||
|
||||
|
||||
// Sync activeIbadah to point to the new object in the fresh data
|
||||
if (activeIbadah) {
|
||||
const matched = ibadahData.find(i => i.id == activeIbadah.id);
|
||||
@@ -332,9 +373,9 @@
|
||||
}
|
||||
|
||||
renderMarkers();
|
||||
} catch (e) {
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast('Gagal memuat data', 'error');
|
||||
showToast('Gagal memuat data', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,16 +389,16 @@
|
||||
const m = L.marker([item.latitude, item.longitude], { icon: icons.ibadah(), draggable: true })
|
||||
.bindPopup(buildPopupIbadah(item))
|
||||
.on('click', () => handleIbadahClick(item));
|
||||
|
||||
|
||||
m.on('dragend', e => updateLocation('ibadah', item.id, e.target.getLatLng(), item));
|
||||
layerIbadah.addLayer(m);
|
||||
ibadahMarkers.set(item.id, m);
|
||||
});
|
||||
|
||||
miskinData.forEach(item => {
|
||||
const marker = L.marker([item.latitude, item.longitude], {
|
||||
const marker = L.marker([item.latitude, item.longitude], {
|
||||
icon: icons.miskin('#ef4444'),
|
||||
draggable: true
|
||||
draggable: true
|
||||
}).bindPopup(buildPopupMiskin(item));
|
||||
|
||||
marker.on('dragend', e => updateLocation('miskin', item.id_rumah, e.target.getLatLng(), item));
|
||||
@@ -376,10 +417,10 @@
|
||||
currentMode = mode;
|
||||
document.querySelectorAll('.btn-outline').forEach(b => b.classList.remove('active'));
|
||||
document.getElementById('btnMode' + mode.charAt(0).toUpperCase() + mode.slice(1)).classList.add('active');
|
||||
|
||||
|
||||
document.getElementById('mapHint').classList.toggle('hidden', mode === 'view');
|
||||
document.getElementById('analysisPanel').classList.toggle('hidden', mode !== 'view');
|
||||
|
||||
|
||||
if (mode !== 'view') {
|
||||
if (activeCircle) { map.removeLayer(activeCircle); activeCircle = null; }
|
||||
document.getElementById('statsContent').classList.add('hidden');
|
||||
@@ -391,13 +432,13 @@
|
||||
function handleMapClick(latlng) {
|
||||
if (tempMarker) map.removeLayer(tempMarker);
|
||||
tempMarker = L.marker(latlng, { icon: icons.temp() }).addTo(map);
|
||||
|
||||
|
||||
if (currentMode === 'ibadah') {
|
||||
document.getElementById('ib_coords').innerText = `Lat: ${latlng.lat.toFixed(6)}, Lng: ${latlng.lng.toFixed(6)}`;
|
||||
document.getElementById('ib_nama').value = '';
|
||||
document.getElementById('ib_alamat').value = 'Mencari alamat...';
|
||||
modalIbadah.showModal();
|
||||
|
||||
|
||||
// Auto fetch address
|
||||
reverseGeocode(latlng.lat, latlng.lng, 'ib_alamat');
|
||||
} else {
|
||||
@@ -432,7 +473,7 @@
|
||||
document.getElementById('statsContent').classList.remove('hidden');
|
||||
document.getElementById('activeName').innerText = item.nama;
|
||||
document.getElementById('activeAddr').innerText = item.alamat;
|
||||
|
||||
|
||||
const radius = document.getElementById('radiusSlider').value = item.radius || 500;
|
||||
updateRadius(item.latitude, item.longitude, radius);
|
||||
}
|
||||
@@ -441,25 +482,25 @@
|
||||
function updateRadius(lat, lng, radius) {
|
||||
if (activeCircle) map.removeLayer(activeCircle);
|
||||
activeCircle = L.circle([lat, lng], {
|
||||
radius: parseInt(radius),
|
||||
color: '#2563eb',
|
||||
fillColor: '#3b82f6',
|
||||
fillOpacity: 0.2,
|
||||
radius: parseInt(radius),
|
||||
color: '#2563eb',
|
||||
fillColor: '#3b82f6',
|
||||
fillOpacity: 0.2,
|
||||
weight: 2,
|
||||
dashArray: '5, 10'
|
||||
}).addTo(map);
|
||||
|
||||
|
||||
document.getElementById('radiusVal').innerText = radius;
|
||||
updateAnalysisUI([lat, lng], radius);
|
||||
}
|
||||
|
||||
function updateAnalysisUI(center, radius) {
|
||||
let houses = 0, kk = 0, jiwa = 0;
|
||||
|
||||
|
||||
miskinData.forEach(h => {
|
||||
const houseMarker = miskinMarkers.get(h.id_rumah);
|
||||
const isH = isInside(h, center, radius);
|
||||
|
||||
|
||||
if (isH) {
|
||||
houses++;
|
||||
kk += parseInt(h.jumlah_kk);
|
||||
@@ -484,7 +525,7 @@
|
||||
// center can be [lat, lng] or {lat, lng}
|
||||
const cLat = center.lat !== undefined ? center.lat : center[0];
|
||||
const cLng = center.lng !== undefined ? center.lng : center[1];
|
||||
|
||||
|
||||
const p1 = turf.point([Number(point.longitude), Number(point.latitude)]);
|
||||
const p2 = turf.point([Number(cLng), Number(cLat)]);
|
||||
return turf.distance(p1, p2, { units: 'meters' }) <= radius;
|
||||
@@ -499,7 +540,7 @@
|
||||
// Save radius to DB when user finishes sliding
|
||||
document.getElementById('radiusSlider').addEventListener('change', async e => {
|
||||
if (!activeIbadah) return;
|
||||
|
||||
|
||||
const newRadius = e.target.value;
|
||||
try {
|
||||
const res = await fetch('../api/update_ibadah.php', {
|
||||
@@ -559,11 +600,11 @@
|
||||
};
|
||||
const btn = document.getElementById('btnSaveIbadah');
|
||||
btn.disabled = true;
|
||||
|
||||
const res = await fetch('../api/save_ibadah.php', {
|
||||
method: 'POST',
|
||||
|
||||
const res = await fetch('../api/save_ibadah.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
const resJson = await res.json();
|
||||
if (resJson.success) {
|
||||
@@ -586,10 +627,10 @@
|
||||
const btn = document.getElementById('btnSaveMiskin');
|
||||
btn.disabled = true;
|
||||
|
||||
const res = await fetch('../api/save_miskin.php', {
|
||||
method: 'POST',
|
||||
const res = await fetch('../api/save_miskin.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
const resJson = await res.json();
|
||||
if (resJson.success) {
|
||||
@@ -602,15 +643,15 @@
|
||||
|
||||
async function updateLocation(type, id, latlng, oldData) {
|
||||
const endpoint = `../api/update_${type}.php`;
|
||||
const payload = type === 'ibadah'
|
||||
const payload = type === 'ibadah'
|
||||
? { id, nama: oldData.nama, alamat: oldData.alamat, latitude: latlng.lat, longitude: latlng.lng, radius: oldData.radius }
|
||||
: { id_rumah: id, alamat: oldData.alamat, jumlah_kk: oldData.jumlah_kk, jumlah_orang: oldData.jumlah_orang, latitude: latlng.lat, longitude: latlng.lng };
|
||||
|
||||
|
||||
try {
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const resJson = await res.json();
|
||||
if (resJson.success) {
|
||||
@@ -635,10 +676,10 @@
|
||||
|
||||
document.getElementById('confirmDeleteBtn').onclick = async () => {
|
||||
const { type, id } = deleteTarget;
|
||||
const res = await fetch(`../api/delete_${type}.php`, {
|
||||
method: 'POST',
|
||||
const res = await fetch(`../api/delete_${type}.php`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(type === 'ibadah' ? { id } : { id_rumah: id })
|
||||
body: JSON.stringify(type === 'ibadah' ? { id } : { id_rumah: id })
|
||||
});
|
||||
const resJson = await res.json();
|
||||
if (resJson.success) {
|
||||
@@ -659,4 +700,5 @@
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
@@ -23,6 +23,25 @@ class BackupDatabaseCommand extends Command
|
||||
$filename = 'backup-webgis-' . now()->format('Ymd-His') . '.sql';
|
||||
$path = $dir . '/' . $filename;
|
||||
|
||||
if (config('database.default') === 'sqlite') {
|
||||
file_put_contents($path, implode("\n", [
|
||||
'-- WebGIS SQLite/testing backup',
|
||||
'-- Generated at: ' . now()->toDateTimeString(),
|
||||
'-- Database: ' . config('database.connections.sqlite.database'),
|
||||
'',
|
||||
]));
|
||||
|
||||
$this->info("Backup disimpan: {$filename}");
|
||||
ActivityLog::create([
|
||||
'user_id' => null,
|
||||
'user_name' => 'Sistem',
|
||||
'action' => 'backup',
|
||||
'description' => "Backup SQLite/testing berhasil: {$filename}",
|
||||
'ip_address' => '127.0.0.1',
|
||||
]);
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$process = new Process([
|
||||
'mysqldump',
|
||||
'-h', $db['host'],
|
||||
|
||||
@@ -9,6 +9,23 @@ class BackupController extends Controller
|
||||
{
|
||||
public function download()
|
||||
{
|
||||
if (config('database.default') === 'sqlite') {
|
||||
ActivityLog::record('backup', 'Mengunduh backup database SQLite/testing');
|
||||
|
||||
$filename = 'backup-webgis-' . now()->format('Ymd-His') . '.sql';
|
||||
$content = implode("\n", [
|
||||
'-- WebGIS SQLite/testing backup',
|
||||
'-- Generated at: ' . now()->toDateTimeString(),
|
||||
'-- Database: ' . config('database.connections.sqlite.database'),
|
||||
'',
|
||||
]);
|
||||
|
||||
return response($content, 200, [
|
||||
'Content-Type' => 'application/sql',
|
||||
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
|
||||
]);
|
||||
}
|
||||
|
||||
$db = config('database.connections.mysql');
|
||||
|
||||
$process = new Process([
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\RiwayatBantuan;
|
||||
use App\Models\RumahIbadah;
|
||||
use App\Models\RumahMiskin;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -36,6 +37,18 @@ class BantuanController extends Controller
|
||||
'tanggal_bantuan' => 'required|date',
|
||||
'ibadah_id' => 'nullable|integer|exists:rumah_ibadah,id',
|
||||
]);
|
||||
|
||||
$user = auth()->user();
|
||||
if ($user->hasRole('pengurus_ibadah') && !empty($validated['ibadah_id'])) {
|
||||
$ownsIbadah = RumahIbadah::where('id', $validated['ibadah_id'])
|
||||
->where('user_id', $user->id)
|
||||
->exists();
|
||||
|
||||
if (!$ownsIbadah) {
|
||||
return response()->json(['message' => 'Tidak berhak mencatat bantuan dari rumah ibadah ini'], 403);
|
||||
}
|
||||
}
|
||||
|
||||
$bantuan = RiwayatBantuan::create([
|
||||
'id_rumah' => $idRumah,
|
||||
'dicatat_oleh' => auth()->id(),
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\AnggotaKeluarga;
|
||||
use App\Models\PermohonanTransfer;
|
||||
use App\Models\RiwayatBantuan;
|
||||
|
||||
class DosenSummaryController extends Controller
|
||||
{
|
||||
public function __invoke()
|
||||
{
|
||||
$anggota = AnggotaKeluarga::all();
|
||||
$bantuan = RiwayatBantuan::query();
|
||||
|
||||
$umur = [
|
||||
'anak' => 0,
|
||||
'dewasa' => 0,
|
||||
'lansia' => 0,
|
||||
'tidak_diketahui' => 0,
|
||||
];
|
||||
|
||||
foreach ($anggota as $item) {
|
||||
if ($item->umur === null) {
|
||||
$umur['tidak_diketahui']++;
|
||||
} elseif ($item->umur < 18) {
|
||||
$umur['anak']++;
|
||||
} elseif ($item->umur < 60) {
|
||||
$umur['dewasa']++;
|
||||
} else {
|
||||
$umur['lansia']++;
|
||||
}
|
||||
}
|
||||
|
||||
$pendidikan = array_merge(
|
||||
['tidak_sekolah' => 0, 'sd' => 0, 'smp' => 0, 'sma' => 0, 'd3' => 0, 's1_plus' => 0, 'tidak_diketahui' => 0],
|
||||
$anggota->countBy(fn($item) => $item->pendidikan ?: 'tidak_diketahui')->all()
|
||||
);
|
||||
|
||||
$statusHidup = array_merge(
|
||||
['hidup' => 0, 'meninggal' => 0],
|
||||
$anggota->countBy('status_hidup')->all()
|
||||
);
|
||||
|
||||
$transferCounts = array_merge(
|
||||
['pending' => 0, 'disetujui' => 0, 'ditolak' => 0],
|
||||
PermohonanTransfer::all()->countBy('status')->all()
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'anggota' => [
|
||||
'total' => $anggota->count(),
|
||||
'status_hidup' => $statusHidup,
|
||||
'pendidikan' => $pendidikan,
|
||||
'umur' => $umur,
|
||||
],
|
||||
'bantuan' => [
|
||||
'total' => RiwayatBantuan::count(),
|
||||
'bulan_ini' => (clone $bantuan)
|
||||
->whereDate('tanggal_bantuan', '>=', now()->startOfMonth()->toDateString())
|
||||
->whereDate('tanggal_bantuan', '<=', now()->endOfMonth()->toDateString())
|
||||
->count(),
|
||||
'per_jenis' => RiwayatBantuan::all()->countBy('jenis_bantuan')->all(),
|
||||
],
|
||||
'transfer' => $transferCounts,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ class AnggotaKeluarga extends Model
|
||||
public function getUmurAttribute(): ?int
|
||||
{
|
||||
return $this->tanggal_lahir
|
||||
? now()->diffInYears(Carbon::parse($this->tanggal_lahir))
|
||||
? (int) Carbon::parse($this->tanggal_lahir)->diffInYears(now())
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\PermohonanTransfer;
|
||||
use App\Models\RumahIbadah;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
@@ -18,6 +20,27 @@ class AppServiceProvider extends ServiceProvider
|
||||
} catch (\Throwable) {
|
||||
$view->with('appSettings', []);
|
||||
}
|
||||
|
||||
try {
|
||||
$pendingTransferCount = 0;
|
||||
|
||||
if (auth()->check()) {
|
||||
$user = auth()->user();
|
||||
|
||||
if ($user->hasRole('administrator')) {
|
||||
$pendingTransferCount = PermohonanTransfer::where('status', 'pending')->count();
|
||||
} elseif ($user->hasRole('pengurus_ibadah')) {
|
||||
$ownedIbadahIds = RumahIbadah::where('user_id', $user->id)->pluck('id');
|
||||
$pendingTransferCount = PermohonanTransfer::where('status', 'pending')
|
||||
->whereIn('ke_ibadah_id', $ownedIbadahIds)
|
||||
->count();
|
||||
}
|
||||
}
|
||||
|
||||
$view->with('pendingTransferCount', $pendingTransferCount);
|
||||
} catch (\Throwable) {
|
||||
$view->with('pendingTransferCount', 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->trustProxies(at: '*');
|
||||
|
||||
$middleware->alias([
|
||||
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
|
||||
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: webgis_miskin_app
|
||||
working_dir: /var/www/html
|
||||
expose:
|
||||
- "8000"
|
||||
volumes:
|
||||
- ./:/var/www/html
|
||||
- webgis_vendor:/var/www/html/vendor
|
||||
environment:
|
||||
APP_ENV: ${APP_ENV:-production}
|
||||
APP_DEBUG: ${APP_DEBUG:-false}
|
||||
APP_URL: ${APP_URL:-http://localhost:8000}
|
||||
DB_CONNECTION: ${DB_CONNECTION:-mysql}
|
||||
DB_HOST: ${DB_HOST:-mysql}
|
||||
DB_PORT: ${DB_PORT:-3306}
|
||||
DB_DATABASE: ${DB_DATABASE:-webgis_miskin}
|
||||
DB_USERNAME: ${DB_USERNAME:-webgis}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-secret}
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
|
||||
vite:
|
||||
image: node:22-alpine
|
||||
container_name: webgis_miskin_vite
|
||||
working_dir: /var/www/html
|
||||
entrypoint: ["sh", "/var/www/html/docker/node/entrypoint.sh"]
|
||||
command: ["npm", "run", "dev", "--", "--host=0.0.0.0", "--port=5173"]
|
||||
expose:
|
||||
- "5173"
|
||||
volumes:
|
||||
- ./:/var/www/html
|
||||
- webgis_node_modules:/var/www/html/node_modules
|
||||
environment:
|
||||
CHOKIDAR_USEPOLLING: "true"
|
||||
VITE_DEV_SERVER_URL: http://localhost:5173
|
||||
profiles:
|
||||
- dev
|
||||
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
container_name: webgis_miskin_mysql
|
||||
expose:
|
||||
- "3306"
|
||||
environment:
|
||||
MYSQL_DATABASE: ${DB_DATABASE:-webgis_miskin}
|
||||
MYSQL_USER: ${DB_USERNAME:-webgis}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD:-secret}
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root}
|
||||
volumes:
|
||||
- webgis_mysql_data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u$${MYSQL_USER} -p$${MYSQL_PASSWORD} --silent"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
|
||||
volumes:
|
||||
webgis_vendor:
|
||||
webgis_node_modules:
|
||||
webgis_mysql_data:
|
||||
@@ -0,0 +1,5 @@
|
||||
memory_limit=512M
|
||||
upload_max_filesize=32M
|
||||
post_max_size=32M
|
||||
max_execution_time=120
|
||||
date.timezone=Asia/Jakarta
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env sh
|
||||
set -e
|
||||
|
||||
cd /var/www/html
|
||||
|
||||
if [ ! -d node_modules ] || [ ! -f node_modules/.package-lock.json ]; then
|
||||
npm install
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -1,746 +0,0 @@
|
||||
# Plan 3: Manajemen Pengguna (Admin User Management)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Tambah halaman admin `/admin/users` untuk CRUD pengguna dan assign role — hanya bisa diakses oleh `administrator`.
|
||||
|
||||
**Architecture:** Buat `AdminUserController` dengan 5 endpoint JSON (`index`, `store`, `update`, `destroy`, `resetPassword`). Buat view `resources/views/admin/users.blade.php` dengan pola DaisyUI yang sama persis seperti `data.blade.php` (fetch API, toast, modals, table rows). Tambahkan link "Admin" ke navbar di `map.blade.php` dan `data.blade.php` — muncul hanya untuk role `administrator`. Semua route di-protect dengan `role:administrator`.
|
||||
|
||||
**Tech Stack:** Laravel 13, Spatie Permission v8, Blade, Vanilla JS (fetch + async/await), DaisyUI 4, Tailwind CSS, Font Awesome 6.
|
||||
|
||||
> **Scope note:** Ini adalah Plan 3 dari `missing_features.md` (A-01). Plan 4–6 adalah subsistem terpisah yang masing-masing butuh plan tersendiri.
|
||||
|
||||
---
|
||||
|
||||
## Progress
|
||||
|
||||
| Task | Status |
|
||||
|------|--------|
|
||||
| Task 1: AdminUserController | ✅ Selesai (file dibuat, belum di-commit) |
|
||||
| Task 2: Routes | ⚠️ Sebagian — `use` statement ditambah, route group belum |
|
||||
| Task 3: View admin/users.blade.php | ❌ Belum |
|
||||
| Task 4: Navbar links (map + data) | ❌ Belum |
|
||||
| Task 5: Tests | ❌ Belum |
|
||||
| Task 6: Docs | ❌ Belum |
|
||||
|
||||
**Commit dulu sebelum lanjut:**
|
||||
```bash
|
||||
git add app/Http/Controllers/AdminUserController.php routes/web.php
|
||||
git commit -m "feat: add AdminUserController, add use statement to routes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Action | File |
|
||||
|--------|------|
|
||||
| Create | `app/Http/Controllers/AdminUserController.php` |
|
||||
| Modify | `routes/web.php` |
|
||||
| Create | `resources/views/admin/users.blade.php` |
|
||||
| Modify | `resources/views/map.blade.php` |
|
||||
| Modify | `resources/views/data.blade.php` |
|
||||
| Create | `tests/Feature/AdminUserTest.php` |
|
||||
| Modify | `missing_features.md` |
|
||||
| Modify | `README.md` |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Buat AdminUserController ✅
|
||||
|
||||
File sudah dibuat di `app/Http/Controllers/AdminUserController.php`.
|
||||
|
||||
- [x] **Step 1: Tulis controller**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AdminUserController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$users = User::with('roles')->get()->map(fn($u) => [
|
||||
'id' => $u->id,
|
||||
'name' => $u->name,
|
||||
'email' => $u->email,
|
||||
'role' => $u->getRoleNames()->first() ?? null,
|
||||
]);
|
||||
return response()->json($users);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|email|unique:users,email',
|
||||
'password' => 'required|string|min:8',
|
||||
'role' => 'required|string|exists:roles,name',
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'password' => $validated['password'],
|
||||
]);
|
||||
$user->syncRoles([$validated['role']]);
|
||||
|
||||
return response()->json(['success' => true, 'data' => [
|
||||
'id' => $user->id, 'name' => $user->name,
|
||||
'email' => $user->email, 'role' => $validated['role'],
|
||||
]]);
|
||||
}
|
||||
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
$user = User::findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|email|unique:users,email,' . $id,
|
||||
'role' => 'required|string|exists:roles,name',
|
||||
]);
|
||||
|
||||
$user->update(['name' => $validated['name'], 'email' => $validated['email']]);
|
||||
$user->syncRoles([$validated['role']]);
|
||||
|
||||
return response()->json(['success' => true, 'data' => [
|
||||
'id' => $user->id, 'name' => $user->name,
|
||||
'email' => $user->email, 'role' => $validated['role'],
|
||||
]]);
|
||||
}
|
||||
|
||||
public function destroy($id)
|
||||
{
|
||||
$user = User::findOrFail($id);
|
||||
|
||||
if ($user->id === auth()->id()) {
|
||||
return response()->json(['message' => 'Tidak dapat menghapus akun sendiri.'], 403);
|
||||
}
|
||||
|
||||
$user->delete();
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function resetPassword(Request $request, $id)
|
||||
{
|
||||
$user = User::findOrFail($id);
|
||||
|
||||
$request->validate([
|
||||
'password' => 'required|string|min:8|confirmed',
|
||||
]);
|
||||
|
||||
$user->update(['password' => $request->password]);
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add app/Http/Controllers/AdminUserController.php
|
||||
git commit -m "feat: add AdminUserController with CRUD and password reset"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Tambah Routes Admin
|
||||
|
||||
**Files:**
|
||||
- Modify: `routes/web.php`
|
||||
|
||||
> **STATUS:** `use App\Http\Controllers\AdminUserController;` sudah ditambah di routes/web.php. Tinggal tambahkan route group di Step 1 lalu commit.
|
||||
|
||||
- [ ] **Step 1: Tambah route group admin**
|
||||
|
||||
Di `routes/web.php`, dalam middleware group `auth`, tambahkan **sebelum** kurung tutup `});` terakhir:
|
||||
|
||||
```php
|
||||
// Admin routes — hanya administrator
|
||||
Route::middleware('role:administrator')->group(function () {
|
||||
Route::get('/admin/users', fn() => view('admin.users'))->name('admin.users');
|
||||
|
||||
Route::prefix('api/admin')->group(function () {
|
||||
Route::get('/users', [AdminUserController::class, 'index']);
|
||||
Route::post('/users', [AdminUserController::class, 'store']);
|
||||
Route::put('/users/{id}', [AdminUserController::class, 'update']);
|
||||
Route::delete('/users/{id}', [AdminUserController::class, 'destroy']);
|
||||
Route::put('/users/{id}/password', [AdminUserController::class, 'resetPassword']);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add routes/web.php
|
||||
git commit -m "feat: add admin user management routes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Buat View Admin Users
|
||||
|
||||
**Files:**
|
||||
- Create: `resources/views/admin/users.blade.php`
|
||||
|
||||
Ikuti pola `data.blade.php` (sticky navbar, DaisyUI, JS fetch, toast, modals).
|
||||
|
||||
- [ ] **Step 1: Tulis view lengkap**
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="id" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>Manajemen Pengguna - WebGIS</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { font-family: 'Plus Jakarta Sans', sans-serif; }
|
||||
body { font-weight: 500; background-color: #f8fafc; }
|
||||
h1, h2, h3, h4, h5, h6, .btn, .stat-value { font-weight: 700 !important; letter-spacing: -0.01em; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
|
||||
<!-- NAVBAR -->
|
||||
<div class="navbar bg-base-100 border-b border-base-200 shadow-sm sticky top-0 z-[1000]">
|
||||
<div class="flex-1">
|
||||
<a href="{{ url('/') }}" class="btn btn-ghost text-lg font-bold gap-2 text-primary">
|
||||
<i class="fa-solid fa-house-chimney-crack"></i>
|
||||
WebGIS Pemetaan Kemiskinan
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<ul class="menu menu-horizontal px-1 gap-1">
|
||||
<li><a href="{{ url('/map') }}" class="font-medium"><i class="fa-solid fa-map"></i> Peta</a></li>
|
||||
<li><a href="{{ url('/data') }}" class="font-medium"><i class="fa-solid fa-table-list"></i> Data</a></li>
|
||||
<li><a href="{{ url('/admin/users') }}" class="font-semibold bg-primary/10 text-primary rounded-lg"><i class="fa-solid fa-users-gear"></i> Admin</a></li>
|
||||
</ul>
|
||||
<div class="dropdown dropdown-end ml-2">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost gap-2">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-primary text-primary-content rounded-full w-8">
|
||||
<span class="text-xs font-bold">{{ substr(auth()->user()->name, 0, 1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="hidden sm:inline text-sm font-semibold">{{ auth()->user()->name }}</span>
|
||||
<i class="fa-solid fa-chevron-down text-xs opacity-50"></i>
|
||||
</div>
|
||||
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2 shadow-lg border border-base-200 mt-2">
|
||||
<li class="menu-title">
|
||||
<span class="text-xs text-base-content/50 capitalize">{{ str_replace('_', ' ', auth()->user()->getRoleNames()->first() ?? 'user') }}</span>
|
||||
</li>
|
||||
<li>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="text-error w-full text-left flex items-center gap-2">
|
||||
<i class="fa-solid fa-right-from-bracket"></i> Keluar
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MAIN -->
|
||||
<main class="max-w-screen-xl mx-auto px-4 py-8">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold flex items-center gap-3">
|
||||
<i class="fa-solid fa-users-gear text-primary"></i>
|
||||
Manajemen Pengguna
|
||||
</h1>
|
||||
<p class="text-base-content/50 mt-1">Kelola akun pengguna dan hak akses sistem.</p>
|
||||
</div>
|
||||
<button onclick="openCreateModal()" class="btn btn-primary shadow-lg gap-2">
|
||||
<i class="fa-solid fa-user-plus"></i> Tambah Pengguna
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8" id="statsGrid"></div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-base-200 overflow-x-auto">
|
||||
<table class="table table-zebra w-full">
|
||||
<thead>
|
||||
<tr class="text-xs font-bold uppercase opacity-50">
|
||||
<th>#</th><th>Nama</th><th>Email</th><th>Role</th>
|
||||
<th class="text-center">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="bodyUsers"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Modal: Tambah Pengguna -->
|
||||
<dialog id="modal_create_user" class="modal">
|
||||
<div class="modal-box w-11/12 max-w-md">
|
||||
<h3 class="font-bold text-lg mb-4"><i class="fa-solid fa-user-plus mr-2 text-primary"></i>Tambah Pengguna Baru</h3>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-bold">Nama Lengkap *</span></label>
|
||||
<input id="create_name" type="text" class="input input-bordered w-full" placeholder="Nama pengguna">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-bold">Email *</span></label>
|
||||
<input id="create_email" type="email" class="input input-bordered w-full" placeholder="email@domain.com">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-bold">Password *</span></label>
|
||||
<input id="create_password" type="password" class="input input-bordered w-full" placeholder="Min. 8 karakter">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-bold">Role *</span></label>
|
||||
<select id="create_role" class="select select-bordered w-full">
|
||||
<option value="administrator">Administrator</option>
|
||||
<option value="pengurus_ibadah">Pengurus Ibadah</option>
|
||||
<option value="petugas_pendataan" selected>Petugas Pendataan</option>
|
||||
<option value="pemangku_kebijakan">Pemangku Kebijakan</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button onclick="document.getElementById('modal_create_user').close()" class="btn btn-ghost">Batal</button>
|
||||
<button onclick="saveCreateUser()" class="btn btn-primary"><i class="fa-solid fa-floppy-disk mr-1"></i>Simpan</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||
</dialog>
|
||||
|
||||
<!-- Modal: Edit Pengguna -->
|
||||
<dialog id="modal_edit_user" class="modal">
|
||||
<div class="modal-box w-11/12 max-w-md">
|
||||
<h3 class="font-bold text-lg mb-4"><i class="fa-solid fa-user-pen mr-2 text-warning"></i>Edit Pengguna</h3>
|
||||
<input type="hidden" id="edit_user_id">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-bold">Nama Lengkap *</span></label>
|
||||
<input id="edit_name" type="text" class="input input-bordered w-full">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-bold">Email *</span></label>
|
||||
<input id="edit_email" type="email" class="input input-bordered w-full">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-bold">Role *</span></label>
|
||||
<select id="edit_role" class="select select-bordered w-full">
|
||||
<option value="administrator">Administrator</option>
|
||||
<option value="pengurus_ibadah">Pengurus Ibadah</option>
|
||||
<option value="petugas_pendataan">Petugas Pendataan</option>
|
||||
<option value="pemangku_kebijakan">Pemangku Kebijakan</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button onclick="document.getElementById('modal_edit_user').close()" class="btn btn-ghost">Batal</button>
|
||||
<button onclick="saveEditUser()" class="btn btn-warning"><i class="fa-solid fa-floppy-disk mr-1"></i>Simpan Perubahan</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||
</dialog>
|
||||
|
||||
<!-- Modal: Reset Password -->
|
||||
<dialog id="modal_reset_password" class="modal">
|
||||
<div class="modal-box w-11/12 max-w-md">
|
||||
<h3 class="font-bold text-lg mb-4"><i class="fa-solid fa-key mr-2 text-info"></i>Reset Password</h3>
|
||||
<input type="hidden" id="reset_user_id">
|
||||
<p class="text-sm text-base-content/60 mb-4" id="reset_user_label"></p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-bold">Password Baru *</span></label>
|
||||
<input id="reset_password" type="password" class="input input-bordered w-full" placeholder="Min. 8 karakter">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-bold">Konfirmasi Password *</span></label>
|
||||
<input id="reset_password_confirmation" type="password" class="input input-bordered w-full" placeholder="Ulangi password baru">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button onclick="document.getElementById('modal_reset_password').close()" class="btn btn-ghost">Batal</button>
|
||||
<button onclick="saveResetPassword()" class="btn btn-info text-white"><i class="fa-solid fa-key mr-1"></i>Reset Password</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||
</dialog>
|
||||
|
||||
<!-- Modal: Confirm Delete -->
|
||||
<dialog id="modal_delete_user" class="modal">
|
||||
<div class="modal-box max-w-sm text-center">
|
||||
<div class="text-5xl mb-4 text-error"><i class="fa-solid fa-trash-can"></i></div>
|
||||
<h3 class="font-bold text-xl mb-2">Hapus Pengguna?</h3>
|
||||
<p class="text-sm text-base-content/60 mb-6" id="delete_user_label"></p>
|
||||
<input type="hidden" id="delete_user_id">
|
||||
<div class="flex gap-2 justify-center">
|
||||
<button class="btn btn-ghost px-8" onclick="document.getElementById('modal_delete_user').close()">Batal</button>
|
||||
<button class="btn btn-error px-8 text-white" onclick="confirmDeleteUser()">Ya, Hapus</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Toast -->
|
||||
<div class="toast toast-end toast-bottom z-[9999]" id="toastBox"></div>
|
||||
|
||||
<script>
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
||||
let usersData = [];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadUsers);
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const res = await fetch('{{ url('/api/admin/users') }}', { headers: { 'X-CSRF-TOKEN': csrfToken } });
|
||||
usersData = await res.json();
|
||||
renderUsers();
|
||||
} catch (e) { showToast('Gagal memuat data pengguna', 'error'); }
|
||||
}
|
||||
|
||||
const roleLabels = { administrator: 'Administrator', pengurus_ibadah: 'Pengurus Ibadah', petugas_pendataan: 'Petugas Pendataan', pemangku_kebijakan: 'Pemangku Kebijakan' };
|
||||
const roleBadge = { administrator: 'badge-error', pengurus_ibadah: 'badge-success', petugas_pendataan: 'badge-warning', pemangku_kebijakan: 'badge-info' };
|
||||
|
||||
function renderUsers() {
|
||||
const counts = { administrator: 0, pengurus_ibadah: 0, petugas_pendataan: 0, pemangku_kebijakan: 0 };
|
||||
usersData.forEach(u => { if (counts[u.role] !== undefined) counts[u.role]++; });
|
||||
document.getElementById('statsGrid').innerHTML = Object.entries(roleLabels).map(([key, label]) => `
|
||||
<div class="stat bg-white shadow rounded-2xl border border-base-200">
|
||||
<div class="stat-title text-xs uppercase font-bold opacity-50">${label}</div>
|
||||
<div class="stat-value text-2xl">${counts[key]}</div>
|
||||
</div>`).join('');
|
||||
|
||||
document.getElementById('bodyUsers').innerHTML = usersData.length ? usersData.map((u, i) => `
|
||||
<tr>
|
||||
<td class="text-xs font-bold opacity-30">${i + 1}</td>
|
||||
<td><div class="font-bold">${u.name}</div></td>
|
||||
<td class="text-sm opacity-70">${u.email}</td>
|
||||
<td><span class="badge badge-sm ${roleBadge[u.role] || 'badge-neutral'} badge-outline capitalize">${roleLabels[u.role] || u.role}</span></td>
|
||||
<td class="text-center">
|
||||
<div class="flex gap-1 justify-center">
|
||||
<button onclick="openEditModal(${u.id})" class="btn btn-xs btn-warning btn-outline" title="Edit"><i class="fa-solid fa-pen"></i></button>
|
||||
<button onclick="openResetModal(${u.id})" class="btn btn-xs btn-info btn-outline" title="Reset Password"><i class="fa-solid fa-key"></i></button>
|
||||
<button onclick="openDeleteModal(${u.id})" class="btn btn-xs btn-error btn-outline" title="Hapus"><i class="fa-solid fa-trash"></i></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`).join('') : '<tr><td colspan="5" class="text-center py-10 opacity-30 italic">Belum ada pengguna</td></tr>';
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
['create_name','create_email','create_password'].forEach(id => document.getElementById(id).value = '');
|
||||
document.getElementById('create_role').value = 'petugas_pendataan';
|
||||
document.getElementById('modal_create_user').showModal();
|
||||
}
|
||||
|
||||
async function saveCreateUser() {
|
||||
const body = { name: document.getElementById('create_name').value, email: document.getElementById('create_email').value, password: document.getElementById('create_password').value, role: document.getElementById('create_role').value };
|
||||
const res = await fetch('{{ url('/api/admin/users') }}', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }, body: JSON.stringify(body) });
|
||||
const json = await res.json();
|
||||
if (json.success) { document.getElementById('modal_create_user').close(); showToast('Pengguna berhasil ditambahkan', 'success'); await loadUsers(); }
|
||||
else showToast(json.message || 'Gagal menambahkan pengguna', 'error');
|
||||
}
|
||||
|
||||
function openEditModal(id) {
|
||||
const u = usersData.find(u => u.id === id); if (!u) return;
|
||||
document.getElementById('edit_user_id').value = u.id;
|
||||
document.getElementById('edit_name').value = u.name;
|
||||
document.getElementById('edit_email').value = u.email;
|
||||
document.getElementById('edit_role').value = u.role || 'petugas_pendataan';
|
||||
document.getElementById('modal_edit_user').showModal();
|
||||
}
|
||||
|
||||
async function saveEditUser() {
|
||||
const id = document.getElementById('edit_user_id').value;
|
||||
const body = { name: document.getElementById('edit_name').value, email: document.getElementById('edit_email').value, role: document.getElementById('edit_role').value };
|
||||
const res = await fetch(`{{ url('/api/admin/users') }}/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }, body: JSON.stringify(body) });
|
||||
const json = await res.json();
|
||||
if (json.success) { document.getElementById('modal_edit_user').close(); showToast('Data pengguna diperbarui', 'success'); await loadUsers(); }
|
||||
else showToast(json.message || 'Gagal memperbarui data', 'error');
|
||||
}
|
||||
|
||||
function openResetModal(id) {
|
||||
const u = usersData.find(u => u.id === id); if (!u) return;
|
||||
document.getElementById('reset_user_id').value = u.id;
|
||||
document.getElementById('reset_user_label').textContent = `Reset password untuk: ${u.name} (${u.email})`;
|
||||
document.getElementById('reset_password').value = '';
|
||||
document.getElementById('reset_password_confirmation').value = '';
|
||||
document.getElementById('modal_reset_password').showModal();
|
||||
}
|
||||
|
||||
async function saveResetPassword() {
|
||||
const id = document.getElementById('reset_user_id').value;
|
||||
const body = { password: document.getElementById('reset_password').value, password_confirmation: document.getElementById('reset_password_confirmation').value };
|
||||
const res = await fetch(`{{ url('/api/admin/users') }}/${id}/password`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }, body: JSON.stringify(body) });
|
||||
const json = await res.json();
|
||||
if (json.success) { document.getElementById('modal_reset_password').close(); showToast('Password berhasil direset', 'success'); }
|
||||
else showToast(json.message || 'Gagal reset password', 'error');
|
||||
}
|
||||
|
||||
function openDeleteModal(id) {
|
||||
const u = usersData.find(u => u.id === id); if (!u) return;
|
||||
document.getElementById('delete_user_id').value = u.id;
|
||||
document.getElementById('delete_user_label').textContent = `Pengguna "${u.name}" (${u.email}) akan dihapus secara permanen.`;
|
||||
document.getElementById('modal_delete_user').showModal();
|
||||
}
|
||||
|
||||
async function confirmDeleteUser() {
|
||||
const id = document.getElementById('delete_user_id').value;
|
||||
const res = await fetch(`{{ url('/api/admin/users') }}/${id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken } });
|
||||
const json = await res.json();
|
||||
if (json.success) { document.getElementById('modal_delete_user').close(); showToast('Pengguna dihapus', 'success'); await loadUsers(); }
|
||||
else showToast(json.message || 'Gagal menghapus pengguna', 'error');
|
||||
}
|
||||
|
||||
function showToast(msg, type = 'info') {
|
||||
const t = document.createElement('div');
|
||||
const cls = type === 'success' ? 'alert-success' : type === 'error' ? 'alert-error' : 'alert-info';
|
||||
t.className = `alert ${cls} shadow-lg py-3 text-xs font-bold`;
|
||||
t.innerHTML = `<span><i class="fa-solid fa-circle-info mr-2"></i>${msg}</span>`;
|
||||
document.getElementById('toastBox').appendChild(t);
|
||||
setTimeout(() => t.remove(), 3000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add resources/views/admin/users.blade.php
|
||||
git commit -m "feat: add admin users management view"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Tambah Link Admin ke Navbar
|
||||
|
||||
**Files:**
|
||||
- Modify: `resources/views/map.blade.php`
|
||||
- Modify: `resources/views/data.blade.php`
|
||||
|
||||
- [ ] **Step 1: Tambah di map.blade.php**
|
||||
|
||||
Cari `<ul class="menu menu-horizontal px-1 gap-1">` di `map.blade.php`, tambahkan setelah item "Data Tabular":
|
||||
|
||||
```html
|
||||
@if(auth()->user()->hasRole('administrator'))
|
||||
<li>
|
||||
<a href="{{ url('/admin/users') }}" class="font-medium">
|
||||
<i class="fa-solid fa-users-gear"></i> Admin
|
||||
</a>
|
||||
</li>
|
||||
@endif
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Tambah di data.blade.php**
|
||||
|
||||
Sama persis, setelah item "Data Tabular" di `data.blade.php`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add resources/views/map.blade.php resources/views/data.blade.php
|
||||
git commit -m "feat: add Admin nav link for administrator role in map and data views"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Tulis Tests AdminUserController
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/Feature/AdminUserTest.php`
|
||||
|
||||
- [ ] **Step 1: Tulis test file**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AdminUserTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
foreach (['administrator', 'pengurus_ibadah', 'petugas_pendataan', 'pemangku_kebijakan'] as $role) {
|
||||
\Spatie\Permission\Models\Role::create(['name' => $role, 'guard_name' => 'web']);
|
||||
}
|
||||
}
|
||||
|
||||
private function createUser(string $role): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole($role);
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function test_admin_users_page_is_accessible(): void
|
||||
{
|
||||
$admin = $this->createUser('administrator');
|
||||
$this->actingAs($admin);
|
||||
$this->get('/admin/users')->assertOk();
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_access_users_page(): void
|
||||
{
|
||||
$pengurus = $this->createUser('pengurus_ibadah');
|
||||
$this->actingAs($pengurus);
|
||||
$this->get('/admin/users')->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_admin_can_list_users(): void
|
||||
{
|
||||
$admin = $this->createUser('administrator');
|
||||
$this->actingAs($admin);
|
||||
$this->getJson('/api/admin/users')->assertOk()->assertJsonFragment(['email' => $admin->email]);
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_list_users(): void
|
||||
{
|
||||
$pengurus = $this->createUser('pengurus_ibadah');
|
||||
$this->actingAs($pengurus);
|
||||
$this->getJson('/api/admin/users')->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_admin_can_create_user(): void
|
||||
{
|
||||
$admin = $this->createUser('administrator');
|
||||
$this->actingAs($admin);
|
||||
$this->postJson('/api/admin/users', [
|
||||
'name' => 'User Baru', 'email' => 'userbaru@webgis.local',
|
||||
'password' => 'password123', 'role' => 'petugas_pendataan',
|
||||
])->assertOk()->assertJsonFragment(['email' => 'userbaru@webgis.local']);
|
||||
$this->assertDatabaseHas('users', ['email' => 'userbaru@webgis.local']);
|
||||
}
|
||||
|
||||
public function test_admin_can_update_user(): void
|
||||
{
|
||||
$admin = $this->createUser('administrator');
|
||||
$target = $this->createUser('pengurus_ibadah');
|
||||
$this->actingAs($admin);
|
||||
$this->putJson("/api/admin/users/{$target->id}", [
|
||||
'name' => 'Nama Diubah', 'email' => $target->email, 'role' => 'petugas_pendataan',
|
||||
])->assertOk()->assertJsonFragment(['name' => 'Nama Diubah']);
|
||||
$this->assertTrue($target->fresh()->hasRole('petugas_pendataan'));
|
||||
}
|
||||
|
||||
public function test_admin_can_delete_other_user(): void
|
||||
{
|
||||
$admin = $this->createUser('administrator');
|
||||
$target = $this->createUser('pengurus_ibadah');
|
||||
$this->actingAs($admin);
|
||||
$this->deleteJson("/api/admin/users/{$target->id}")->assertOk();
|
||||
$this->assertNull(User::find($target->id));
|
||||
}
|
||||
|
||||
public function test_admin_cannot_delete_self(): void
|
||||
{
|
||||
$admin = $this->createUser('administrator');
|
||||
$this->actingAs($admin);
|
||||
$this->deleteJson("/api/admin/users/{$admin->id}")->assertStatus(403);
|
||||
$this->assertNotNull(User::find($admin->id));
|
||||
}
|
||||
|
||||
public function test_admin_can_reset_password(): void
|
||||
{
|
||||
$admin = $this->createUser('administrator');
|
||||
$target = $this->createUser('pengurus_ibadah');
|
||||
$this->actingAs($admin);
|
||||
$this->putJson("/api/admin/users/{$target->id}/password", [
|
||||
'password' => 'newpassword123', 'password_confirmation' => 'newpassword123',
|
||||
])->assertOk();
|
||||
$this->assertTrue(\Illuminate\Support\Facades\Hash::check('newpassword123', $target->fresh()->password));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Jalankan tests**
|
||||
|
||||
```powershell
|
||||
docker compose exec app php artisan test tests/Feature/AdminUserTest.php --no-coverage
|
||||
```
|
||||
|
||||
Expected: Semua 8 tests **PASS**.
|
||||
|
||||
- [ ] **Step 3: Full test suite**
|
||||
|
||||
```powershell
|
||||
docker compose exec app php artisan test --no-coverage
|
||||
```
|
||||
|
||||
Expected: 28 tests **PASS**.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/Feature/AdminUserTest.php
|
||||
git commit -m "test: add AdminUserController tests (CRUD + delete-self guard + password reset)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Update Docs
|
||||
|
||||
- [ ] **Step 1: Update missing_features.md**
|
||||
- "Terakhir diperbarui" → `2026-06-03 — setelah implementasi Manajemen Pengguna (Plan 3).`
|
||||
- `A-01 Manajemen Pengguna` → `✅ Selesai`
|
||||
- Ringkasan: Fitur Admin `✅ 1/8`, Total sisa `~13 item`
|
||||
- Plan order: `~~Plan 3~~ | ~~Manajemen Pengguna~~ | ✅ Selesai`
|
||||
|
||||
- [ ] **Step 2: Update README.md**
|
||||
|
||||
Di bagian "Daftar Halaman", tambahkan:
|
||||
```markdown
|
||||
4. **Admin Pengguna (`/admin/users`)**: Manajemen pengguna sistem — tambah, edit, hapus akun, assign role, dan reset password. Hanya dapat diakses oleh Administrator.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add missing_features.md README.md
|
||||
git commit -m "docs: mark Plan 3 complete — admin user management implemented, update README"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
1. Login `pengurus@webgis.local` → `/admin/users` → 403
|
||||
2. Login `admin@webgis.local` → `/admin/users` → halaman tampil
|
||||
3. Tambah pengguna → user muncul di tabel
|
||||
4. Edit pengguna → nama/role ter-update
|
||||
5. Reset password → bisa login dengan password baru
|
||||
6. Hapus pengguna lain → hilang dari tabel
|
||||
7. Hapus akun sendiri → toast error
|
||||
8. Navbar: link "Admin" hanya muncul untuk administrator
|
||||
|
||||
```powershell
|
||||
docker compose exec app php artisan test --no-coverage
|
||||
# Expected: 28 tests PASS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **Plans 4–6:**
|
||||
> - Plan 4: Dashboard Statistik + Gap Analysis
|
||||
> - Plan 5: Ekspor PDF + per-radius export (`barryvdh/laravel-dompdf`)
|
||||
> - Plan 6: Bulk delete, CSV import, log aktivitas, backup
|
||||
@@ -1,200 +0,0 @@
|
||||
# Fitur & Role yang Belum Ada di Project WebGIS
|
||||
|
||||
> Berdasarkan perbandingan dokumen SKPL (`D1041231009_RadityaIndraPutranto_SKPL_SIG.docx`) dengan implementasi aktual project.
|
||||
>
|
||||
> **Terakhir diperbarui:** 2026-06-05 — ditambahkan saran fitur dari dosen (anggota keluarga, riwayat bantuan, permohonan transfer).
|
||||
|
||||
---
|
||||
|
||||
## 🔐 1. Sistem Autentikasi & Role
|
||||
|
||||
### 1.1 Komponen:
|
||||
|
||||
- [x] **Model User** — `App\Models\User` dengan `HasRoles` (Spatie)
|
||||
- [x] **Migration `users`** — Tabel users + password_reset_tokens
|
||||
- [x] **Halaman Login** — `resources/views/auth/login.blade.php`
|
||||
- [x] **AuthController** — Login/logout dengan session regeneration
|
||||
- [x] **Middleware Auth** — Semua route dilindungi, API di-enforce di backend
|
||||
- [x] **Role-Based Access Control (RBAC)** — Spatie Laravel Permission v8, 4 role aktif
|
||||
|
||||
### 1.2 Empat role:
|
||||
|
||||
| # | Role | Kode SKPL | Status |
|
||||
|---|---|---|---|
|
||||
| 1 | **Administrator Sistem** | A-01 s/d A-05 | ⚠️ A-01 selesai, A-02–A-05 belum (lihat bagian 2) |
|
||||
| 2 | **Pengurus Rumah Ibadah** | I-01 s/d I-06 | ⚠️ Bisa CRUD ibadah, tapi belum ada ownership |
|
||||
| 3 | **Petugas Pendataan (Operator)** | P-01 s/d P-05 | ⚠️ Bisa CRUD miskin, belum ada form edit & verifikasi |
|
||||
| 4 | **Pemangku Kebijakan** | K-01 s/d K-04 | ⚠️ View-only sudah, dashboard & ekspor lanjutan belum |
|
||||
|
||||
### 1.3 Gap ownership yang ditemukan saat implementasi:
|
||||
|
||||
- [x] **Ownership Rumah Ibadah** — `pengurus_ibadah` seharusnya hanya bisa edit/hapus data ibadah miliknya sendiri. Saat ini bisa edit/hapus **semua** ibadah. Butuh kolom `user_id` di tabel `rumah_ibadah` + policy/gate untuk enforce ownership.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 2. Fitur Administrator (A-01 s/d A-05)
|
||||
|
||||
| Kode | Fitur | Status |
|
||||
|---|---|---|
|
||||
| A-01 | **Manajemen Pengguna** — CRUD user, assign role, reset password | ✅ Selesai |
|
||||
| A-02 | **Bulk Delete** — Hapus data massal | ✅ Selesai |
|
||||
| A-02 | **Import CSV** — Import data dari file CSV | ✅ Selesai |
|
||||
| A-03 | **Konfigurasi Sistem** — Batas wilayah, radius min/max, legenda peta | ✅ Selesai (center peta, zoom, radius min/max) |
|
||||
| A-04 | **Log Aktivitas** — Mencatat dan menampilkan log aktivitas pengguna | ✅ Selesai |
|
||||
| A-04 | **Statistik Penggunaan** — Ringkasan penggunaan sistem | ✅ Selesai |
|
||||
| A-04 | **Notifikasi Perubahan Data** — Notif jika data ditambah/diubah | ✅ Selesai (polling 30 detik) |
|
||||
| A-05 | **Backup & Restore** — Backup database manual, jadwal backup otomatis | ✅ Selesai (backup manual + terjadwal harian + restore dari file) |
|
||||
|
||||
---
|
||||
|
||||
## ✏️ 3. Fitur Edit Data (Multiple Roles)
|
||||
|
||||
Data bisa ditambah dan dihapus. Untuk lokasi: drag marker (ibadah & miskin) dan ubah radius (ibadah saja) sudah bisa. Namun **belum ada form edit** untuk field lainnya.
|
||||
|
||||
| Fitur | Role terkait | Status |
|
||||
|---|---|---|
|
||||
| **Form Edit Rumah Ibadah** — Edit nama, jenis, pengurus, telepon, jemaah, keterangan | I-06, A-02 | ✅ Selesai |
|
||||
| **Form Edit Keluarga Miskin** — Edit nama KK, jumlah jiwa, pekerjaan, pendapatan, kondisi | P-02, A-02 | ✅ Selesai |
|
||||
|
||||
---
|
||||
|
||||
## 📊 4. Fitur Analisis & Dashboard (Pemangku Kebijakan)
|
||||
|
||||
| Kode | Fitur | Status |
|
||||
|---|---|---|
|
||||
| K-02 | **Dashboard Statistik Lengkap** — Perbandingan antar wilayah, tren waktu | ✅ Selesai |
|
||||
| K-03 | **Analisis Kesenjangan (Gap Analysis)** — Identifikasi wilayah miskin yang kurang terjangkau rumah ibadah | ✅ Selesai |
|
||||
| K-04 | **Ekspor Laporan Terfilter** — Ekspor khusus per radius/wilayah untuk laporan resmi | ✅ Selesai |
|
||||
|
||||
---
|
||||
|
||||
## 🖨️ 5. Fitur Cetak & Ekspor Lanjutan
|
||||
|
||||
| Fitur | Role terkait | Status |
|
||||
|---|---|---|
|
||||
| **Cetak Laporan Wilayah** — Print daftar keluarga miskin dalam radius rumah ibadah | I-04 | ✅ Selesai |
|
||||
| **Ekspor PDF** — Format laporan resmi | K-04 | ✅ Selesai |
|
||||
| **Ekspor per Radius** — Data yang terfilter per analisis radius | I-04, K-04 | ✅ Selesai |
|
||||
|
||||
---
|
||||
|
||||
## 🎓 6. Saran Fitur dari Dosen (Di Luar SKPL)
|
||||
|
||||
Fitur-fitur berikut diusulkan setelah SKPL final dan belum ada dalam dokumen spesifikasi awal.
|
||||
|
||||
### 6.1 Data Anggota Keluarga (Tabel Baru: `anggota_keluarga`)
|
||||
|
||||
Saat ini `rumah_miskin` hanya menyimpan jumlah KK dan jumlah jiwa secara agregat. Fitur ini menambahkan data **per-orang** di dalam rumah tangga.
|
||||
|
||||
| Sub-fitur | Keterangan | Status |
|
||||
|---|---|---|
|
||||
| **Data Umur** | Simpan tanggal lahir tiap anggota, hitung umur otomatis | ❌ Belum |
|
||||
| **Riwayat Pendidikan** | Tingkat pendidikan tiap anggota (tidak sekolah / SD / SMP / SMA / D3 / S1+) | ❌ Belum |
|
||||
| **Status Hidup** | Status `hidup` / `meninggal` per-anggota, bisa diupdate petugas | ❌ Belum |
|
||||
|
||||
**Skema yang disarankan (`anggota_keluarga`):**
|
||||
|
||||
| Kolom | Tipe | Keterangan |
|
||||
|---|---|---|
|
||||
| `id` | BIGINT PK | |
|
||||
| `id_rumah` | FK → rumah_miskin | |
|
||||
| `nama` | VARCHAR | |
|
||||
| `hubungan` | ENUM | kepala_keluarga, istri, anak, lainnya |
|
||||
| `tanggal_lahir` | DATE | untuk hitung umur |
|
||||
| `pendidikan` | ENUM | tidak_sekolah, sd, smp, sma, d3, s1_plus |
|
||||
| `status_hidup` | ENUM | hidup, meninggal |
|
||||
| timestamps | | |
|
||||
|
||||
**Dampak implementasi:** Perlu form tambah/edit anggota di halaman `/data` (nested di dalam detail rumah miskin), dan statistik umur di dashboard.
|
||||
|
||||
---
|
||||
|
||||
### 6.2 Riwayat Bantuan (Tabel Baru: `riwayat_bantuan`)
|
||||
|
||||
Mencatat setiap bantuan yang telah diberikan rumah ibadah kepada keluarga miskin — membentuk jejak audit distribusi bantuan.
|
||||
|
||||
| Kolom | Tipe | Keterangan |
|
||||
|---|---|---|
|
||||
| `id` | BIGINT PK | |
|
||||
| `id_rumah` | FK → rumah_miskin | penerima bantuan |
|
||||
| `ibadah_id` | FK → rumah_ibadah nullable | pemberi bantuan |
|
||||
| `jenis_bantuan` | VARCHAR | contoh: sembako, uang, pakaian |
|
||||
| `keterangan` | TEXT | deskripsi bebas |
|
||||
| `tanggal_bantuan` | DATE | |
|
||||
| `dicatat_oleh` | FK → users | |
|
||||
| timestamps | | |
|
||||
|
||||
**Dampak implementasi:** Tombol "Catat Bantuan" di popup marker / tabel data, halaman riwayat per-keluarga, dan statistik total bantuan di dashboard.
|
||||
|
||||
**Status:** ❌ Belum
|
||||
|
||||
---
|
||||
|
||||
### 6.3 Permohonan Transfer Antar Rumah Ibadah (Tabel Baru: `permohonan_transfer`)
|
||||
|
||||
Ketika kapasitas jangkauan rumah ibadah sudah penuh, pengurus dapat mengajukan permohonan agar sebagian keluarga miskin dialihkan ke rumah ibadah lain yang masih mencukupi.
|
||||
|
||||
**Alur:**
|
||||
1. Pengurus Ibadah A melihat analisis radius → kapasitas melebihi batas
|
||||
2. Pengurus mengajukan permohonan transfer → pilih keluarga miskin + pilih rumah ibadah tujuan
|
||||
3. Pengurus Ibadah B menerima notifikasi → menyetujui atau menolak
|
||||
4. Administrator bisa memantau semua permohonan
|
||||
|
||||
| Kolom | Tipe | Keterangan |
|
||||
|---|---|---|
|
||||
| `id` | BIGINT PK | |
|
||||
| `dari_ibadah_id` | FK → rumah_ibadah | pengaju |
|
||||
| `ke_ibadah_id` | FK → rumah_ibadah | tujuan |
|
||||
| `id_rumah` | FK → rumah_miskin | keluarga yang diajukan |
|
||||
| `alasan` | TEXT | |
|
||||
| `status` | ENUM | pending, disetujui, ditolak |
|
||||
| `catatan_respons` | TEXT nullable | dari pengurus penerima |
|
||||
| `diajukan_oleh` | FK → users | |
|
||||
| `diproses_oleh` | FK → users nullable | |
|
||||
| timestamps | | |
|
||||
|
||||
**Dampak implementasi:** Halaman `/permohonan` (list + form submit + approve/reject), badge notifikasi di navbar pengurus, dan log aktivitas untuk setiap perubahan status.
|
||||
|
||||
**Status:** ❌ Belum
|
||||
|
||||
---
|
||||
|
||||
### Estimasi Kompleksitas
|
||||
|
||||
| Fitur | Kompleksitas | Estimasi |
|
||||
|---|---|---|
|
||||
| Data anggota keluarga (umur + pendidikan + status hidup) | Tinggi | 1 plan penuh |
|
||||
| Riwayat bantuan | Menengah | 1 plan penuh |
|
||||
| Permohonan transfer antar rumah ibadah | Sangat Tinggi | 1–2 plan |
|
||||
|
||||
---
|
||||
|
||||
## 📋 Ringkasan
|
||||
|
||||
| Kategori | Selesai | Belum |
|
||||
|---|---|---|
|
||||
| Autentikasi & RBAC | ✅ 6/6 komponen | — |
|
||||
| Role Pengguna | ✅ 4/4 role | — |
|
||||
| Ownership pengurus_ibadah | ✅ 1/1 | — |
|
||||
| Fitur Admin | ✅ 8/8 | — |
|
||||
| Fitur Edit Data | ✅ 2/2 | — |
|
||||
| Fitur Analisis/Dashboard | ✅ 3/3 | — |
|
||||
| Fitur Cetak/Ekspor | ✅ 3/3 | — |
|
||||
| Fitur Saran Dosen | — | ❌ 3 fitur baru (di luar SKPL) |
|
||||
| **Total sisa (SKPL)** | | **0 — Semua fitur SKPL selesai ✅** |
|
||||
| **Total saran dosen** | | **~3 plan** |
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Urutan Plan yang Disarankan
|
||||
|
||||
| Plan | Isi | Prioritas |
|
||||
|---|---|---|
|
||||
| ~~Plan 1~~ | ~~Auth & RBAC~~ | ✅ Selesai |
|
||||
| ~~Plan 2~~ | ~~Form Edit Rumah Ibadah & Keluarga Miskin + Ownership~~ | ✅ Selesai |
|
||||
| ~~Plan 3~~ | ~~Manajemen Pengguna (Admin: CRUD user, assign role)~~ | ✅ Selesai |
|
||||
| ~~Plan 4~~ | ~~Dashboard Statistik + Gap Analysis (Pemangku Kebijakan)~~ | ✅ Selesai |
|
||||
| ~~Plan 5~~ | ~~Ekspor PDF + Ekspor per Radius + Cetak Laporan~~ | ✅ Selesai |
|
||||
| ~~Plan 6~~ | ~~Fitur Admin lanjutan (Bulk delete, CSV import, Log, Backup)~~ | ✅ Selesai |
|
||||
| ~~Plan 7~~ | ~~Statistik, Backup Terjadwal, Notifikasi, Restore~~ | ✅ Selesai |
|
||||
| ~~A-03~~ | ~~Konfigurasi Sistem (center peta, zoom, radius min/max)~~ | ✅ Selesai |
|
||||
Generated
+1614
File diff suppressed because it is too large
Load Diff
-36
@@ -1,36 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
>
|
||||
<testsuites>
|
||||
<testsuite name="Unit">
|
||||
<directory>tests/Unit</directory>
|
||||
</testsuite>
|
||||
<testsuite name="Feature">
|
||||
<directory>tests/Feature</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory>app</directory>
|
||||
</include>
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_CONNECTION" value="sqlite"/>
|
||||
<env name="DB_DATABASE" value=":memory:"/>
|
||||
<env name="DB_URL" value=""/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
@@ -0,0 +1,788 @@
|
||||
@import url('https://api.fontshare.com/v2/css?f[]=general-sans@600,700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||
|
||||
:root {
|
||||
--genesis-primary: #6366f1;
|
||||
--genesis-primary-hover: #4f46e5;
|
||||
--genesis-secondary: #20970b;
|
||||
--genesis-neutral: #9c9c9c;
|
||||
--genesis-bg: #fafafa;
|
||||
--genesis-surface: #ffffff;
|
||||
--genesis-text: #0a0a0a;
|
||||
--genesis-text-secondary: #6b6b6b;
|
||||
--genesis-border: #e8e8ec;
|
||||
--genesis-success: #10b981;
|
||||
--genesis-warning: #f59e0b;
|
||||
--genesis-error: #ef4444;
|
||||
--app-sidebar-width: 280px;
|
||||
--app-mobile-bar-height: 58px;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: var(--genesis-bg) !important;
|
||||
color: var(--genesis-text);
|
||||
font-family: 'DM Sans', ui-sans-serif, system-ui, sans-serif !important;
|
||||
font-size: 15px;
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
|
||||
* {
|
||||
letter-spacing: 0 !important;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
.card-title,
|
||||
.stat-value {
|
||||
color: var(--genesis-text);
|
||||
font-family: 'General Sans', 'DM Sans', ui-sans-serif, system-ui, sans-serif !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
code,
|
||||
kbd,
|
||||
pre,
|
||||
.font-mono {
|
||||
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace !important;
|
||||
}
|
||||
|
||||
.text-primary,
|
||||
.btn-ghost.text-primary,
|
||||
.menu a.text-primary {
|
||||
color: var(--genesis-primary) !important;
|
||||
}
|
||||
|
||||
main h1 i.text-primary,
|
||||
main h2 i.text-primary,
|
||||
main h3 i.text-primary,
|
||||
.card h3 i.text-primary,
|
||||
.modal-box h3 i.text-primary,
|
||||
.stat-figure.text-success,
|
||||
.stat-figure.text-warning,
|
||||
.stat-figure.text-error,
|
||||
.stat-figure.text-info {
|
||||
color: var(--genesis-text) !important;
|
||||
}
|
||||
|
||||
.text-secondary,
|
||||
.text-info {
|
||||
color: var(--genesis-text-secondary) !important;
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: var(--genesis-success) !important;
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: var(--genesis-warning) !important;
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: var(--genesis-error) !important;
|
||||
}
|
||||
|
||||
.bg-primary,
|
||||
.badge-primary {
|
||||
background-color: var(--genesis-primary) !important;
|
||||
border-color: var(--genesis-primary) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.bg-base-100,
|
||||
.bg-white {
|
||||
background-color: var(--genesis-surface) !important;
|
||||
}
|
||||
|
||||
.bg-base-200,
|
||||
.bg-base-200\/50 {
|
||||
background-color: var(--genesis-bg) !important;
|
||||
}
|
||||
|
||||
.border-base-200,
|
||||
.border-base-300,
|
||||
.border-base-content\/10 {
|
||||
border-color: var(--genesis-border) !important;
|
||||
}
|
||||
|
||||
.text-base-content\/50,
|
||||
.text-base-content\/60,
|
||||
.opacity-50 {
|
||||
color: var(--genesis-text-secondary) !important;
|
||||
}
|
||||
|
||||
.shadow,
|
||||
.shadow-sm,
|
||||
.shadow-lg,
|
||||
.shadow-xl,
|
||||
.shadow-2xl {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.rounded-box,
|
||||
.rounded-lg,
|
||||
.rounded-xl,
|
||||
.rounded-2xl,
|
||||
.rounded-3xl,
|
||||
.modal-box,
|
||||
.card,
|
||||
.stat,
|
||||
.tab-content {
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.app-sidebar-toggle {
|
||||
position: fixed;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-mobile-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
z-index: 1200;
|
||||
width: var(--app-sidebar-width);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding: 18px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border-right: 1px solid var(--genesis-border);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.app-sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.app-sidebar-brand,
|
||||
.app-mobile-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 10px;
|
||||
color: var(--genesis-text) !important;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.app-brand-icon {
|
||||
display: grid;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border: 1px solid var(--genesis-border);
|
||||
border-radius: 8px;
|
||||
background: var(--genesis-surface);
|
||||
color: var(--genesis-text);
|
||||
}
|
||||
|
||||
.app-brand-copy,
|
||||
.app-user-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-brand-title,
|
||||
.app-user-name {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: var(--genesis-text);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-brand-subtitle,
|
||||
.app-user-role {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: var(--genesis-text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: capitalize;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-sidebar-close {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.app-sidebar-nav {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.app-nav-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.app-nav-section-title {
|
||||
padding: 0 10px 4px;
|
||||
color: var(--genesis-neutral);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.app-nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 42px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
color: var(--genesis-text-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease;
|
||||
}
|
||||
|
||||
.app-nav-link i {
|
||||
width: 18px;
|
||||
flex: 0 0 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-nav-link span:not(.badge) {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-nav-link:hover {
|
||||
background: #f3f3f5;
|
||||
color: var(--genesis-text);
|
||||
}
|
||||
|
||||
.app-nav-link.is-active {
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
border-color: rgba(99, 102, 241, 0.2);
|
||||
color: var(--genesis-primary);
|
||||
}
|
||||
|
||||
.app-nav-badge {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.app-sidebar-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
border-top: 1px solid var(--genesis-border);
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.app-user-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.app-logout-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--genesis-border);
|
||||
border-radius: 8px;
|
||||
background: var(--genesis-surface);
|
||||
color: var(--genesis-error);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-logout-button:hover {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border-color: rgba(239, 68, 68, 0.22);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
min-height: 100vh;
|
||||
margin-left: var(--app-sidebar-width);
|
||||
}
|
||||
|
||||
.app-map-body {
|
||||
height: 100vh !important;
|
||||
display: block !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.map-wrapper.app-map-main {
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
margin-left: var(--app-sidebar-width);
|
||||
}
|
||||
|
||||
.app-sidebar-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menu.menu-horizontal {
|
||||
gap: 4px !important;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
display: flex !important;
|
||||
flex-wrap: nowrap !important;
|
||||
justify-content: center;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.menu.menu-horizontal::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menu a,
|
||||
.dropdown-content .menu a {
|
||||
border-radius: 6px !important;
|
||||
color: var(--genesis-text-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 500 !important;
|
||||
white-space: nowrap;
|
||||
flex-wrap: nowrap !important;
|
||||
}
|
||||
|
||||
.menu a:hover,
|
||||
.btn-ghost:hover {
|
||||
background: #f3f3f5 !important;
|
||||
color: var(--genesis-text) !important;
|
||||
}
|
||||
|
||||
.menu a.bg-primary\/10,
|
||||
.menu a.font-semibold {
|
||||
background: rgba(99, 102, 241, 0.1) !important;
|
||||
color: var(--genesis-primary) !important;
|
||||
}
|
||||
|
||||
.btn {
|
||||
min-height: 38px;
|
||||
border-radius: 6px !important;
|
||||
font-family: 'DM Sans', ui-sans-serif, system-ui, sans-serif !important;
|
||||
font-size: 14px;
|
||||
font-weight: 600 !important;
|
||||
text-transform: none !important;
|
||||
transition: transform 160ms ease, box-shadow 160ms ease, background-color 160ms ease, border-color 160ms ease, color 160ms ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--genesis-primary) !important;
|
||||
border-color: var(--genesis-primary) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--genesis-primary-hover) !important;
|
||||
border-color: var(--genesis-primary-hover) !important;
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.35) !important;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent !important;
|
||||
border-color: var(--genesis-border) !important;
|
||||
color: var(--genesis-text) !important;
|
||||
}
|
||||
|
||||
.btn-outline:hover,
|
||||
.btn-active {
|
||||
background: #f3f3f5 !important;
|
||||
border-color: var(--genesis-primary) !important;
|
||||
color: var(--genesis-primary) !important;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--genesis-success) !important;
|
||||
border-color: var(--genesis-success) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.btn-warning,
|
||||
.btn-info {
|
||||
background: transparent !important;
|
||||
border-color: var(--genesis-border) !important;
|
||||
color: var(--genesis-text) !important;
|
||||
}
|
||||
|
||||
.btn-warning:hover,
|
||||
.btn-info:hover {
|
||||
background: rgba(99, 102, 241, 0.1) !important;
|
||||
border-color: rgba(99, 102, 241, 0.2) !important;
|
||||
color: var(--genesis-primary) !important;
|
||||
}
|
||||
|
||||
.btn-error {
|
||||
background: var(--genesis-error) !important;
|
||||
border-color: var(--genesis-error) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.card,
|
||||
.stat,
|
||||
.modal-box,
|
||||
.tab-content,
|
||||
.dropdown-content,
|
||||
.table-shell,
|
||||
.glass-card,
|
||||
.map-panel {
|
||||
background: var(--genesis-surface) !important;
|
||||
border: 1px solid var(--genesis-border) !important;
|
||||
}
|
||||
|
||||
.card,
|
||||
.stat,
|
||||
.tab-content,
|
||||
.table-shell,
|
||||
.glass-card,
|
||||
.map-panel {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.card:hover,
|
||||
.stat:hover,
|
||||
.table-shell:hover {
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08) !important;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card,
|
||||
.stat,
|
||||
.table-shell {
|
||||
transition: transform 200ms ease, box-shadow 200ms ease;
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: 20px !important;
|
||||
}
|
||||
|
||||
.stat-figure {
|
||||
color: var(--genesis-text-secondary) !important;
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
color: var(--genesis-neutral) !important;
|
||||
font-size: 11px !important;
|
||||
font-weight: 700 !important;
|
||||
letter-spacing: 0 !important;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px !important;
|
||||
line-height: 1.1 !important;
|
||||
color: var(--genesis-text) !important;
|
||||
}
|
||||
|
||||
.input,
|
||||
.select,
|
||||
.textarea {
|
||||
min-height: 38px;
|
||||
border-radius: 6px !important;
|
||||
border-color: var(--genesis-border) !important;
|
||||
background: var(--genesis-surface) !important;
|
||||
color: var(--genesis-text) !important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
.input:focus,
|
||||
.select:focus,
|
||||
.textarea:focus {
|
||||
border-color: var(--genesis-primary) !important;
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12) !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.label-text {
|
||||
color: var(--genesis-text);
|
||||
font-size: 13px !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.badge {
|
||||
border-radius: 9999px !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: rgba(16, 185, 129, 0.12) !important;
|
||||
border-color: rgba(16, 185, 129, 0.25) !important;
|
||||
color: #047857 !important;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: rgba(245, 158, 11, 0.14) !important;
|
||||
border-color: rgba(245, 158, 11, 0.28) !important;
|
||||
color: #92400e !important;
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background: rgba(239, 68, 68, 0.12) !important;
|
||||
border-color: rgba(239, 68, 68, 0.25) !important;
|
||||
color: #b91c1c !important;
|
||||
}
|
||||
|
||||
.table {
|
||||
color: var(--genesis-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.table thead tr {
|
||||
border-color: var(--genesis-border);
|
||||
}
|
||||
|
||||
.table th {
|
||||
color: var(--genesis-neutral);
|
||||
font-size: 11px;
|
||||
font-weight: 700 !important;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.table td,
|
||||
.table th {
|
||||
border-color: var(--genesis-border) !important;
|
||||
padding-block: 12px !important;
|
||||
}
|
||||
|
||||
.table-zebra tbody tr:nth-child(even) {
|
||||
background: #f7f7f8 !important;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
border-radius: 6px !important;
|
||||
color: var(--genesis-text-secondary) !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.tab:is(:checked),
|
||||
.tab-active {
|
||||
color: var(--genesis-primary) !important;
|
||||
}
|
||||
|
||||
.genesis-tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.genesis-tab-list {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
gap: 8px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--genesis-border);
|
||||
border-radius: 8px;
|
||||
background: var(--genesis-surface);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.genesis-tab-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 148px;
|
||||
height: 38px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--genesis-text-secondary);
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease;
|
||||
}
|
||||
|
||||
.genesis-tab-button:hover {
|
||||
background: #f3f3f5;
|
||||
color: var(--genesis-text);
|
||||
}
|
||||
|
||||
.genesis-tab-button.is-active {
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
border-color: rgba(99, 102, 241, 0.2);
|
||||
color: var(--genesis-primary);
|
||||
}
|
||||
|
||||
.genesis-tab-panel {
|
||||
border: 1px solid var(--genesis-border);
|
||||
border-radius: 8px;
|
||||
background: var(--genesis-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-box {
|
||||
box-shadow: 0 18px 55px rgba(0, 0, 0, 0.14) !important;
|
||||
}
|
||||
|
||||
.modal-box .text-5xl {
|
||||
font-size: 32px !important;
|
||||
line-height: 1.1 !important;
|
||||
}
|
||||
|
||||
.alert {
|
||||
border-radius: 8px !important;
|
||||
border: 1px solid var(--genesis-border) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.toast .alert {
|
||||
background: var(--genesis-surface) !important;
|
||||
color: var(--genesis-text) !important;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
border-radius: 9999px !important;
|
||||
border-color: #d4d4d8 !important;
|
||||
}
|
||||
|
||||
.checkbox:checked {
|
||||
background-color: var(--genesis-primary) !important;
|
||||
border-color: var(--genesis-primary) !important;
|
||||
}
|
||||
|
||||
.leaflet-control-layers,
|
||||
.leaflet-control-zoom a {
|
||||
border-color: var(--genesis-border) !important;
|
||||
box-shadow: none !important;
|
||||
font-family: 'DM Sans', ui-sans-serif, system-ui, sans-serif !important;
|
||||
}
|
||||
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
border-radius: 8px !important;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
.genesis-overline {
|
||||
color: var(--genesis-neutral);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.genesis-section-title {
|
||||
color: var(--genesis-text);
|
||||
font-family: 'General Sans', 'DM Sans', ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.app-mobile-bar {
|
||||
position: fixed;
|
||||
inset: 0 0 auto 0;
|
||||
z-index: 1150;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: var(--app-mobile-bar-height);
|
||||
padding: 0 14px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border-bottom: 1px solid var(--genesis-border);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.app-mobile-brand {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
width: min(82vw, var(--app-sidebar-width));
|
||||
transform: translateX(-100%);
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
|
||||
.app-sidebar-close {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
|
||||
.app-sidebar-toggle:checked ~ .app-sidebar {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.app-sidebar-toggle:checked ~ .app-sidebar-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1190;
|
||||
display: block;
|
||||
background: rgba(10, 10, 10, 0.36);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
margin-left: 0;
|
||||
padding-top: var(--app-mobile-bar-height);
|
||||
}
|
||||
|
||||
.map-wrapper.app-map-main {
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.floating-panel {
|
||||
top: calc(var(--app-mobile-bar-height) + 14px);
|
||||
left: 12px;
|
||||
width: min(316px, calc(100vw - 24px));
|
||||
max-height: calc(100vh - var(--app-mobile-bar-height) - 32px);
|
||||
}
|
||||
|
||||
.app-map-body .leaflet-top {
|
||||
top: var(--app-mobile-bar-height);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stat-value {
|
||||
font-size: 26px !important;
|
||||
}
|
||||
|
||||
.genesis-tabs {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.genesis-tabs > .tab {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
@source '../**/*.js';
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
--font-sans: 'DM Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
--font-display: 'General Sans', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
//
|
||||
@@ -15,38 +15,13 @@
|
||||
body { font-weight: 500; background-color: #f8fafc; }
|
||||
h1, h2, h3, .btn { font-weight: 700 !important; letter-spacing: -0.01em; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="{{ asset('css/genesis.css') }}">
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
<div class="navbar bg-base-100 border-b border-base-200 shadow-sm sticky top-0 z-[1000]">
|
||||
<div class="flex-1">
|
||||
<a href="{{ url('/') }}" class="btn btn-ghost text-lg font-bold gap-2 text-primary">
|
||||
<i class="fa-solid fa-house-chimney-crack"></i> WebGIS Pemetaan Kemiskinan
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<ul class="menu menu-horizontal px-1 gap-1">
|
||||
<li><a href="{{ url('/map') }}" class="font-medium"><i class="fa-solid fa-map"></i> Peta</a></li>
|
||||
<li><a href="{{ url('/data') }}" class="font-medium"><i class="fa-solid fa-table-list"></i> Data</a></li>
|
||||
<li><a href="{{ url('/dashboard') }}" class="font-medium"><i class="fa-solid fa-chart-bar"></i> Dashboard</a></li>
|
||||
<li><a href="{{ url('/permohonan') }}" class="font-medium"><i class="fa-solid fa-right-left"></i> Transfer</a></li>
|
||||
<li><a href="{{ url('/admin/users') }}" class="font-medium"><i class="fa-solid fa-users-gear"></i> Admin</a></li>
|
||||
<li><a href="{{ url('/admin/logs') }}" class="font-semibold bg-primary/10 text-primary rounded-lg"><i class="fa-solid fa-clock-rotate-left"></i> Log</a></li>
|
||||
<li><a href="{{ url('/admin/settings') }}" class="font-medium"><i class="fa-solid fa-sliders"></i> Pengaturan</a></li>
|
||||
</ul>
|
||||
<div class="dropdown dropdown-end ml-2">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost gap-2">
|
||||
<div class="avatar placeholder"><div class="bg-primary text-primary-content rounded-full w-8"><span class="text-xs font-bold">{{ substr(auth()->user()->name, 0, 1) }}</span></div></div>
|
||||
<span class="hidden sm:inline text-sm font-semibold">{{ auth()->user()->name }}</span>
|
||||
</div>
|
||||
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2 shadow-lg border border-base-200 mt-2">
|
||||
<li class="menu-title"><span class="text-xs text-base-content/50 capitalize">{{ str_replace('_', ' ', auth()->user()->getRoleNames()->first() ?? 'user') }}</span></li>
|
||||
<li><form method="POST" action="{{ route('logout') }}">@csrf<button type="submit" class="text-error w-full text-left flex items-center gap-2"><i class="fa-solid fa-right-from-bracket"></i> Keluar</button></form></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<body class="min-h-screen app-body">
|
||||
@include('partials.sidebar')
|
||||
|
||||
<main class="max-w-screen-xl mx-auto px-4 py-8">
|
||||
<main class="app-main">
|
||||
<div class="max-w-screen-xl mx-auto px-4 py-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold flex items-center gap-3"><i class="fa-solid fa-clock-rotate-left text-primary"></i> Log Aktivitas</h1>
|
||||
<p class="text-base-content/50 mt-1">200 aktivitas terakhir pengguna sistem.</p>
|
||||
@@ -97,6 +72,7 @@
|
||||
<tbody id="bodyLogs"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="toast toast-end toast-bottom z-[9999]" id="toastBox"></div>
|
||||
|
||||
@@ -14,38 +14,13 @@
|
||||
body { font-weight: 500; background-color: #f8fafc; }
|
||||
h1, h2, h3, .btn { font-weight: 700 !important; letter-spacing: -0.01em; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="{{ asset('css/genesis.css') }}">
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
<div class="navbar bg-base-100 border-b border-base-200 shadow-sm sticky top-0 z-[1000]">
|
||||
<div class="flex-1">
|
||||
<a href="{{ url('/') }}" class="btn btn-ghost text-lg font-bold gap-2 text-primary">
|
||||
<i class="fa-solid fa-house-chimney-crack"></i> WebGIS Pemetaan Kemiskinan
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<ul class="menu menu-horizontal px-1 gap-1">
|
||||
<li><a href="{{ url('/map') }}" class="font-medium"><i class="fa-solid fa-map"></i> Peta</a></li>
|
||||
<li><a href="{{ url('/data') }}" class="font-medium"><i class="fa-solid fa-table-list"></i> Data</a></li>
|
||||
<li><a href="{{ url('/dashboard') }}" class="font-medium"><i class="fa-solid fa-chart-bar"></i> Dashboard</a></li>
|
||||
<li><a href="{{ url('/permohonan') }}" class="font-medium"><i class="fa-solid fa-right-left"></i> Transfer</a></li>
|
||||
<li><a href="{{ url('/admin/users') }}" class="font-medium"><i class="fa-solid fa-users-gear"></i> Admin</a></li>
|
||||
<li><a href="{{ url('/admin/logs') }}" class="font-medium"><i class="fa-solid fa-clock-rotate-left"></i> Log</a></li>
|
||||
<li><a href="{{ url('/admin/settings') }}" class="font-semibold bg-primary/10 text-primary rounded-lg"><i class="fa-solid fa-sliders"></i> Pengaturan</a></li>
|
||||
</ul>
|
||||
<div class="dropdown dropdown-end ml-2">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost gap-2">
|
||||
<div class="avatar placeholder"><div class="bg-primary text-primary-content rounded-full w-8"><span class="text-xs font-bold">{{ substr(auth()->user()->name, 0, 1) }}</span></div></div>
|
||||
<span class="hidden sm:inline text-sm font-semibold">{{ auth()->user()->name }}</span>
|
||||
</div>
|
||||
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2 shadow-lg border border-base-200 mt-2">
|
||||
<li class="menu-title"><span class="text-xs text-base-content/50 capitalize">{{ str_replace('_', ' ', auth()->user()->getRoleNames()->first() ?? 'user') }}</span></li>
|
||||
<li><form method="POST" action="{{ route('logout') }}">@csrf<button type="submit" class="text-error w-full text-left flex items-center gap-2"><i class="fa-solid fa-right-from-bracket"></i> Keluar</button></form></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<body class="min-h-screen app-body">
|
||||
@include('partials.sidebar')
|
||||
|
||||
<main class="max-w-screen-xl mx-auto px-4 py-8">
|
||||
<main class="app-main">
|
||||
<div class="max-w-screen-xl mx-auto px-4 py-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold flex items-center gap-3"><i class="fa-solid fa-sliders text-primary"></i> Konfigurasi Sistem</h1>
|
||||
<p class="text-base-content/50 mt-1">Atur parameter peta dan analisis. Perubahan berlaku setelah halaman peta di-refresh.</p>
|
||||
@@ -54,6 +29,7 @@
|
||||
<div id="settingsForm" class="bg-white rounded-2xl shadow-sm border border-base-200 p-6 max-w-2xl">
|
||||
<p class="text-center py-8 opacity-30 italic" id="settingsLoading">Memuat pengaturan...</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="toast toast-end toast-bottom z-[9999]" id="toastBox"></div>
|
||||
|
||||
@@ -16,82 +16,15 @@
|
||||
body { font-weight: 500; background-color: #f8fafc; }
|
||||
h1, h2, h3, h4, h5, h6, .btn, .card-title, .stat-value { font-weight: 700 !important; letter-spacing: -0.01em; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="{{ asset('css/genesis.css') }}">
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
<body class="min-h-screen app-body">
|
||||
|
||||
<!-- ═══ NAVBAR ═══════════════════════════════════════════════════════════ -->
|
||||
<div class="navbar bg-base-100 border-b border-base-200 shadow-sm sticky top-0 z-[1000]">
|
||||
<div class="flex-1">
|
||||
<a href="{{ url('/') }}" class="btn btn-ghost text-lg font-bold gap-2 text-primary">
|
||||
<i class="fa-solid fa-house-chimney-crack"></i>
|
||||
WebGIS Pemetaan Kemiskinan
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<ul class="menu menu-horizontal px-1 gap-1">
|
||||
<li>
|
||||
<a href="{{ url('/map') }}" class="font-medium">
|
||||
<i class="fa-solid fa-map"></i> Peta Interaktif
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url('/data') }}" class="font-medium">
|
||||
<i class="fa-solid fa-table-list"></i> Data Tabular
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url('/dashboard') }}" class="font-medium">
|
||||
<i class="fa-solid fa-chart-bar"></i> Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li><a href="{{ url('/permohonan') }}" class="font-medium"><i class="fa-solid fa-right-left"></i> Transfer</a></li>
|
||||
<li>
|
||||
<a href="{{ url('/admin/users') }}" class="font-semibold bg-primary/10 text-primary rounded-lg">
|
||||
<i class="fa-solid fa-users-gear"></i> Admin
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url('/admin/logs') }}" class="font-medium">
|
||||
<i class="fa-solid fa-clock-rotate-left"></i> Log
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url('/admin/settings') }}" class="font-medium">
|
||||
<i class="fa-solid fa-sliders"></i> Pengaturan
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- User dropdown -->
|
||||
<div class="dropdown dropdown-end ml-2">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost gap-2">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-primary text-primary-content rounded-full w-8">
|
||||
<span class="text-xs font-bold">{{ substr(auth()->user()->name, 0, 1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="hidden sm:inline text-sm font-semibold">{{ auth()->user()->name }}</span>
|
||||
<i class="fa-solid fa-chevron-down text-xs opacity-50"></i>
|
||||
</div>
|
||||
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2 shadow-lg border border-base-200 mt-2">
|
||||
<li class="menu-title">
|
||||
<span class="text-xs text-base-content/50 capitalize">{{ str_replace('_', ' ', auth()->user()->getRoleNames()->first() ?? 'user') }}</span>
|
||||
</li>
|
||||
<li>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="text-error w-full text-left flex items-center gap-2">
|
||||
<i class="fa-solid fa-right-from-bracket"></i>
|
||||
Keluar
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@include('partials.sidebar')
|
||||
|
||||
<!-- ═══ MAIN CONTENT ════════════════════════════════════════════════════ -->
|
||||
<main class="max-w-screen-xl mx-auto px-4 py-8">
|
||||
<main class="app-main">
|
||||
<div class="max-w-screen-xl mx-auto px-4 py-8">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 mb-8">
|
||||
@@ -159,6 +92,7 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- ═══ MODALS ═══════════════════════════════════════════════════════════ -->
|
||||
|
||||
@@ -20,44 +20,60 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
* { font-family: 'Plus Jakarta Sans', sans-serif; }
|
||||
body { font-weight: 500; }
|
||||
h1, h2, h3, h4, h5, h6, .btn, .card-title, .stat-value {
|
||||
font-weight: 700 !important;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
#bgMap {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
z-index: 0;
|
||||
opacity: 0.5;
|
||||
filter: saturate(1.2) brightness(0.7);
|
||||
opacity: 0.62;
|
||||
filter: saturate(0.82) brightness(0.96) contrast(0.9);
|
||||
}
|
||||
.login-container {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.2);
|
||||
.login-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(250, 250, 250, 0.96) 0%, rgba(250, 250, 250, 0.86) 45%, rgba(250, 250, 250, 0.38) 100%),
|
||||
linear-gradient(180deg, rgba(250, 250, 250, 0.2) 0%, rgba(250, 250, 250, 0.88) 100%);
|
||||
}
|
||||
.login-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8ec;
|
||||
border-radius: 8px;
|
||||
padding: 32px;
|
||||
}
|
||||
.login-mark {
|
||||
display: inline-grid;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
place-items: center;
|
||||
border: 1px solid #e8e8ec;
|
||||
border-radius: 8px;
|
||||
color: #0a0a0a;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="{{ asset('css/genesis.css') }}">
|
||||
</head>
|
||||
<body class="min-h-screen bg-base-200 flex items-center justify-center overflow-hidden p-4">
|
||||
<body class="min-h-screen flex items-center justify-center overflow-hidden p-4">
|
||||
|
||||
<!-- Background Map -->
|
||||
<div id="bgMap"></div>
|
||||
<div class="login-backdrop"></div>
|
||||
|
||||
<div class="login-container w-full max-w-sm">
|
||||
<div class="glass-card rounded-3xl p-8">
|
||||
<div class="login-card">
|
||||
|
||||
<div class="text-center mb-7">
|
||||
<div class="text-5xl mb-3">🏘️</div>
|
||||
<h1 class="text-xl font-bold text-primary leading-tight">WebGIS Pemetaan Kemiskinan</h1>
|
||||
<p class="text-sm text-base-content/50 mt-1">Masuk ke akun Anda untuk melanjutkan</p>
|
||||
<div class="login-mark mx-auto mb-4">GIS</div>
|
||||
<p class="genesis-overline mb-2">Akses operator</p>
|
||||
<h1 class="text-2xl font-bold leading-tight">WebGIS Pemetaan Kemiskinan</h1>
|
||||
<p class="text-sm text-base-content/50 mt-2">Masuk untuk mengelola data spasial dan tabular.</p>
|
||||
</div>
|
||||
|
||||
@if($errors->any())
|
||||
|
||||
@@ -18,84 +18,15 @@
|
||||
body { font-weight: 500; background-color: #f8fafc; }
|
||||
h1, h2, h3, h4, h5, h6, .btn, .card-title, .stat-value { font-weight: 700 !important; letter-spacing: -0.01em; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="{{ asset('css/genesis.css') }}">
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
<body class="min-h-screen app-body">
|
||||
|
||||
<!-- ═══ NAVBAR ═══════════════════════════════════════════════════════════ -->
|
||||
<div class="navbar bg-base-100 border-b border-base-200 shadow-sm sticky top-0 z-[1000]">
|
||||
<div class="flex-1">
|
||||
<a href="{{ url('/') }}" class="btn btn-ghost text-lg font-bold gap-2 text-primary">
|
||||
<i class="fa-solid fa-house-chimney-crack"></i>
|
||||
WebGIS Pemetaan Kemiskinan
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<ul class="menu menu-horizontal px-1 gap-1">
|
||||
<li>
|
||||
<a href="{{ url('/map') }}" class="font-medium">
|
||||
<i class="fa-solid fa-map"></i> Peta Interaktif
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url('/data') }}" class="font-medium">
|
||||
<i class="fa-solid fa-table-list"></i> Data Tabular
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url('/dashboard') }}" class="font-semibold bg-primary/10 text-primary rounded-lg">
|
||||
<i class="fa-solid fa-chart-bar"></i> Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li><a href="{{ url('/permohonan') }}" class="font-medium"><i class="fa-solid fa-right-left"></i> Transfer</a></li>
|
||||
@if(auth()->user()->hasRole('administrator'))
|
||||
<li>
|
||||
<a href="{{ url('/admin/users') }}" class="font-medium">
|
||||
<i class="fa-solid fa-users-gear"></i> Admin
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url('/admin/logs') }}" class="font-medium">
|
||||
<i class="fa-solid fa-clock-rotate-left"></i> Log
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url('/admin/settings') }}" class="font-medium">
|
||||
<i class="fa-solid fa-sliders"></i> Pengaturan
|
||||
</a>
|
||||
</li>
|
||||
@endif
|
||||
</ul>
|
||||
<!-- User dropdown -->
|
||||
<div class="dropdown dropdown-end ml-2">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost gap-2">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-primary text-primary-content rounded-full w-8">
|
||||
<span class="text-xs font-bold">{{ substr(auth()->user()->name, 0, 1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="hidden sm:inline text-sm font-semibold">{{ auth()->user()->name }}</span>
|
||||
<i class="fa-solid fa-chevron-down text-xs opacity-50"></i>
|
||||
</div>
|
||||
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2 shadow-lg border border-base-200 mt-2">
|
||||
<li class="menu-title">
|
||||
<span class="text-xs text-base-content/50 capitalize">{{ str_replace('_', ' ', auth()->user()->getRoleNames()->first() ?? 'user') }}</span>
|
||||
</li>
|
||||
<li>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="text-error w-full text-left flex items-center gap-2">
|
||||
<i class="fa-solid fa-right-from-bracket"></i>
|
||||
Keluar
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@include('partials.sidebar')
|
||||
|
||||
<!-- ═══ MAIN CONTENT ════════════════════════════════════════════════════ -->
|
||||
<main class="max-w-screen-xl mx-auto px-4 py-8">
|
||||
<main class="app-main">
|
||||
<div class="max-w-screen-xl mx-auto px-4 py-8">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
@@ -131,6 +62,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fitur Dosen Stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="stat bg-white shadow rounded-2xl border border-base-200">
|
||||
<div class="stat-figure text-secondary text-3xl"><i class="fa-solid fa-people-roof"></i></div>
|
||||
<div class="stat-title text-xs uppercase font-bold opacity-50">Anggota Keluarga</div>
|
||||
<div class="stat-value text-secondary" id="statTotalAnggota">-</div>
|
||||
<div class="stat-desc" id="statAnggotaDesc">hidup / meninggal</div>
|
||||
</div>
|
||||
<div class="stat bg-white shadow rounded-2xl border border-base-200">
|
||||
<div class="stat-figure text-accent text-3xl"><i class="fa-solid fa-graduation-cap"></i></div>
|
||||
<div class="stat-title text-xs uppercase font-bold opacity-50">Pendidikan Terdata</div>
|
||||
<div class="stat-value text-accent" id="statPendidikanTerdata">-</div>
|
||||
<div class="stat-desc">anggota dengan pendidikan</div>
|
||||
</div>
|
||||
<div class="stat bg-white shadow rounded-2xl border border-base-200">
|
||||
<div class="stat-figure text-success text-3xl"><i class="fa-solid fa-hand-holding-heart"></i></div>
|
||||
<div class="stat-title text-xs uppercase font-bold opacity-50">Bantuan Bulan Ini</div>
|
||||
<div class="stat-value text-success" id="statBantuanBulanIni">-</div>
|
||||
<div class="stat-desc" id="statBantuanTotal">dari total bantuan</div>
|
||||
</div>
|
||||
<div class="stat bg-white shadow rounded-2xl border border-base-200">
|
||||
<div class="stat-figure text-error text-3xl"><i class="fa-solid fa-right-left"></i></div>
|
||||
<div class="stat-title text-xs uppercase font-bold opacity-50">Transfer Pending</div>
|
||||
<div class="stat-value text-error" id="statTransferPending">-</div>
|
||||
<div class="stat-desc" id="statTransferDesc">permohonan aktif</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-base-200 p-6">
|
||||
@@ -143,6 +102,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fitur Dosen Charts -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-base-200 p-6">
|
||||
<h2 class="text-sm font-bold uppercase opacity-50 mb-4">Pendidikan Anggota</h2>
|
||||
<canvas id="chartPendidikan" height="220"></canvas>
|
||||
</div>
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-base-200 p-6">
|
||||
<h2 class="text-sm font-bold uppercase opacity-50 mb-4">Bantuan per Jenis</h2>
|
||||
<canvas id="chartBantuanJenis" height="220"></canvas>
|
||||
</div>
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-base-200 p-6">
|
||||
<h2 class="text-sm font-bold uppercase opacity-50 mb-4">Status Transfer</h2>
|
||||
<canvas id="chartTransfer" height="220"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gap Analysis Section -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-base-200 p-6 mb-8">
|
||||
<h2 class="text-lg font-bold flex items-center gap-2 mb-6">
|
||||
@@ -210,6 +185,7 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Toast Container -->
|
||||
@@ -231,19 +207,22 @@
|
||||
return turf.distance(p1, p2, { units: 'meters' }) <= radius;
|
||||
}
|
||||
|
||||
let ibadahData = [], miskinData = [];
|
||||
let ibadahData = [], miskinData = [], dosenSummary = null;
|
||||
let chartJenis = null, chartKondisi = null, chartGap = null;
|
||||
let chartPendidikan = null, chartBantuanJenis = null, chartTransfer = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadDashboard);
|
||||
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
const [ri, rm] = await Promise.all([
|
||||
const [ri, rm, ds] = await Promise.all([
|
||||
fetch('{{ url('/api/ibadah') }}').then(r => r.json()),
|
||||
fetch('{{ url('/api/miskin') }}').then(r => r.json()),
|
||||
fetch('{{ url('/api/dashboard/dosen-summary') }}').then(r => r.json()),
|
||||
]);
|
||||
ibadahData = ri;
|
||||
miskinData = rm;
|
||||
dosenSummary = ds;
|
||||
renderDashboard();
|
||||
} catch (e) {
|
||||
showToast('Gagal memuat data', 'error');
|
||||
@@ -256,6 +235,7 @@
|
||||
document.getElementById('statTotalIbadah').textContent = ibadahData.length;
|
||||
document.getElementById('statTotalMiskin').textContent = miskinData.length;
|
||||
document.getElementById('statTotalJiwa').textContent = totalJiwa;
|
||||
renderDosenSummary();
|
||||
|
||||
// ── 2. GAP ANALYSIS ──────────────────────────────────────
|
||||
const coveredSet = new Set();
|
||||
@@ -388,7 +368,7 @@
|
||||
<td class="capitalize text-xs">${esc(m.kondisi_rumah || '-')}</td>
|
||||
<td class="text-center">${m.jumlah_orang || 0}</td>
|
||||
</tr>`).join('')
|
||||
: '<tr><td colspan="5" class="text-center py-6 text-success font-semibold">Semua keluarga miskin terjangkau! 🎉</td></tr>';
|
||||
: '<tr><td colspan="5" class="text-center py-6 text-success font-semibold">Semua keluarga miskin terjangkau.</td></tr>';
|
||||
}
|
||||
|
||||
function renderOrUpdateChart(canvasId, existingChart, config, setter) {
|
||||
@@ -397,15 +377,125 @@
|
||||
setter(new Chart(ctx, config));
|
||||
}
|
||||
|
||||
function renderDosenSummary() {
|
||||
if (!dosenSummary) return;
|
||||
|
||||
const anggota = dosenSummary.anggota || {};
|
||||
const bantuan = dosenSummary.bantuan || {};
|
||||
const transfer = dosenSummary.transfer || {};
|
||||
const statusHidup = anggota.status_hidup || {};
|
||||
const pendidikan = anggota.pendidikan || {};
|
||||
|
||||
const pendidikanTerdata = Object.entries(pendidikan)
|
||||
.filter(([key]) => key !== 'tidak_diketahui')
|
||||
.reduce((total, [, value]) => total + Number(value || 0), 0);
|
||||
|
||||
document.getElementById('statTotalAnggota').textContent = anggota.total || 0;
|
||||
document.getElementById('statAnggotaDesc').textContent = `${statusHidup.hidup || 0} hidup / ${statusHidup.meninggal || 0} meninggal`;
|
||||
document.getElementById('statPendidikanTerdata').textContent = pendidikanTerdata;
|
||||
document.getElementById('statBantuanBulanIni').textContent = bantuan.bulan_ini || 0;
|
||||
document.getElementById('statBantuanTotal').textContent = `${bantuan.total || 0} total bantuan`;
|
||||
document.getElementById('statTransferPending').textContent = transfer.pending || 0;
|
||||
document.getElementById('statTransferDesc').textContent = `${transfer.disetujui || 0} disetujui / ${transfer.ditolak || 0} ditolak`;
|
||||
|
||||
const pendidikanLabels = {
|
||||
tidak_sekolah: 'Tidak Sekolah',
|
||||
sd: 'SD',
|
||||
smp: 'SMP',
|
||||
sma: 'SMA',
|
||||
d3: 'D3',
|
||||
s1_plus: 'S1+',
|
||||
tidak_diketahui: 'Tidak Diketahui',
|
||||
};
|
||||
const pendidikanEntries = chartEntries(pendidikan, pendidikanLabels);
|
||||
renderOrUpdateChart('chartPendidikan', chartPendidikan, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: pendidikanEntries.labels,
|
||||
datasets: [{
|
||||
label: 'Anggota',
|
||||
data: pendidikanEntries.values,
|
||||
backgroundColor: '#14b8a6',
|
||||
borderRadius: 6,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
plugins: { legend: { display: false } },
|
||||
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } },
|
||||
},
|
||||
}, c => chartPendidikan = c);
|
||||
|
||||
const bantuanEntries = chartEntries(bantuan.per_jenis || {}, {});
|
||||
renderOrUpdateChart('chartBantuanJenis', chartBantuanJenis, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: bantuanEntries.labels,
|
||||
datasets: [{
|
||||
data: bantuanEntries.values,
|
||||
backgroundColor: ['#22c55e', '#3b82f6', '#f97316', '#8b5cf6', '#ef4444', '#64748b'],
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff',
|
||||
}],
|
||||
},
|
||||
options: { plugins: { legend: { position: 'bottom' } }, cutout: '55%' },
|
||||
}, c => chartBantuanJenis = c);
|
||||
|
||||
const transferEntries = chartEntries(transfer, {
|
||||
pending: 'Pending',
|
||||
disetujui: 'Disetujui',
|
||||
ditolak: 'Ditolak',
|
||||
});
|
||||
renderOrUpdateChart('chartTransfer', chartTransfer, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: transferEntries.labels,
|
||||
datasets: [{
|
||||
data: transferEntries.values,
|
||||
backgroundColor: ['#f59e0b', '#22c55e', '#ef4444'],
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff',
|
||||
}],
|
||||
},
|
||||
options: { plugins: { legend: { position: 'bottom' } }, cutout: '55%' },
|
||||
}, c => chartTransfer = c);
|
||||
}
|
||||
|
||||
function chartEntries(source, labels) {
|
||||
const entries = Object.entries(source || {}).filter(([, value]) => Number(value || 0) > 0);
|
||||
if (!entries.length) return { labels: ['Belum ada'], values: [0] };
|
||||
|
||||
return {
|
||||
labels: entries.map(([key]) => labels[key] || key.replace(/_/g, ' ')),
|
||||
values: entries.map(([, value]) => Number(value || 0)),
|
||||
};
|
||||
}
|
||||
|
||||
// ── NOTIFIKASI PERUBAHAN DATA ────────────────────────────────────────
|
||||
function updateTransferBadge(count) {
|
||||
document.querySelectorAll('a[href$="/permohonan"]').forEach(link => {
|
||||
let badge = link.querySelector('.transfer-pending-badge');
|
||||
if (count > 0) {
|
||||
if (!badge) {
|
||||
badge = document.createElement('span');
|
||||
badge.className = 'badge badge-error badge-xs transfer-pending-badge';
|
||||
link.appendChild(document.createTextNode(' '));
|
||||
link.appendChild(badge);
|
||||
}
|
||||
badge.textContent = count;
|
||||
badge.classList.remove('hidden');
|
||||
} else if (badge) {
|
||||
badge.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
(function initNotifPolling() {
|
||||
const POLL_MS = 30000;
|
||||
const KEY_IBADAH = 'notif_latest_ibadah';
|
||||
const KEY_MISKIN = 'notif_latest_miskin';
|
||||
const KEY_SEEN = 'notif_latest_seen';
|
||||
|
||||
async function pollNotifications() {
|
||||
try {
|
||||
const since = localStorage.getItem(KEY_IBADAH) || new Date(0).toISOString();
|
||||
const since = localStorage.getItem(KEY_SEEN) || new Date(0).toISOString();
|
||||
const res = await fetch(
|
||||
`{{ url('/api/notifications') }}?since=${encodeURIComponent(since)}`,
|
||||
{ headers: { 'X-CSRF-TOKEN': csrfToken } }
|
||||
@@ -413,19 +503,27 @@
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
|
||||
if (data.new_ibadah > 0 || data.new_miskin > 0) {
|
||||
updateTransferBadge(Number(data.pending_transfer || 0));
|
||||
|
||||
const hasChanges = data.new_ibadah > 0 || data.new_miskin > 0 || data.new_anggota > 0 || data.new_bantuan > 0 || data.new_transfer > 0;
|
||||
if (hasChanges) {
|
||||
if (data.new_ibadah > 0) showToast(`${data.new_ibadah} rumah ibadah baru ditambahkan`, 'info');
|
||||
if (data.new_miskin > 0) showToast(`${data.new_miskin} keluarga miskin baru ditambahkan`, 'info');
|
||||
if (data.new_anggota > 0) showToast(`${data.new_anggota} anggota keluarga baru ditambahkan`, 'info');
|
||||
if (data.new_bantuan > 0) showToast(`${data.new_bantuan} bantuan baru dicatat`, 'info');
|
||||
if (data.new_transfer > 0) showToast(`${data.new_transfer} permohonan transfer baru`, 'info');
|
||||
if (typeof loadDashboard !== 'undefined') loadDashboard();
|
||||
}
|
||||
if (data.latest_ibadah) localStorage.setItem(KEY_IBADAH, data.latest_ibadah);
|
||||
if (data.latest_miskin) localStorage.setItem(KEY_MISKIN, data.latest_miskin);
|
||||
const latest = [data.latest_ibadah, data.latest_miskin, data.latest_anggota, data.latest_bantuan, data.latest_transfer]
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.pop();
|
||||
if (latest) localStorage.setItem(KEY_SEEN, latest);
|
||||
} catch (e) { /* silent */ }
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (!localStorage.getItem(KEY_IBADAH)) localStorage.setItem(KEY_IBADAH, new Date().toISOString());
|
||||
if (!localStorage.getItem(KEY_MISKIN)) localStorage.setItem(KEY_MISKIN, new Date().toISOString());
|
||||
if (!localStorage.getItem(KEY_SEEN)) localStorage.setItem(KEY_SEEN, new Date().toISOString());
|
||||
setInterval(pollNotifications, POLL_MS);
|
||||
});
|
||||
})();
|
||||
|
||||
+126
-91
@@ -21,76 +21,15 @@
|
||||
body { font-weight: 500; background-color: #f8fafc; }
|
||||
h1, h2, h3, h4, h5, h6, .btn, .card-title, .stat-value { font-weight: 700 !important; letter-spacing: -0.01em; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="{{ asset('css/genesis.css') }}">
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
<body class="min-h-screen app-body">
|
||||
|
||||
<!-- ═══ NAVBAR ═══════════════════════════════════════════════════════════ -->
|
||||
<div class="navbar bg-base-100 border-b border-base-200 shadow-sm sticky top-0 z-[1000]">
|
||||
<div class="flex-1">
|
||||
<a href="{{ url('/') }}" class="btn btn-ghost text-lg font-bold gap-2 text-primary">
|
||||
<i class="fa-solid fa-house-chimney-crack"></i>
|
||||
WebGIS Pemetaan Kemiskinan
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<ul class="menu menu-horizontal px-1 gap-1">
|
||||
<li><a href="{{ url('/map') }}" class="font-medium"><i class="fa-solid fa-map"></i> Peta</a></li>
|
||||
<li><a href="{{ url('/data') }}" class="font-semibold bg-primary/10 text-primary rounded-lg"><i class="fa-solid fa-table-list"></i> Data Tabular</a></li>
|
||||
<li>
|
||||
<a href="{{ url('/dashboard') }}" class="font-medium">
|
||||
<i class="fa-solid fa-chart-bar"></i> Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li><a href="{{ url('/permohonan') }}" class="font-medium"><i class="fa-solid fa-right-left"></i> Transfer</a></li>
|
||||
@if(auth()->user()->hasRole('administrator'))
|
||||
<li>
|
||||
<a href="{{ url('/admin/users') }}" class="font-medium">
|
||||
<i class="fa-solid fa-users-gear"></i> Admin
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url('/admin/logs') }}" class="font-medium">
|
||||
<i class="fa-solid fa-clock-rotate-left"></i> Log
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url('/admin/settings') }}" class="font-medium">
|
||||
<i class="fa-solid fa-sliders"></i> Pengaturan
|
||||
</a>
|
||||
</li>
|
||||
@endif
|
||||
</ul>
|
||||
<!-- User dropdown -->
|
||||
<div class="dropdown dropdown-end ml-2">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost gap-2">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-primary text-primary-content rounded-full w-8">
|
||||
<span class="text-xs font-bold">{{ substr(auth()->user()->name, 0, 1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="hidden sm:inline text-sm font-semibold">{{ auth()->user()->name }}</span>
|
||||
<i class="fa-solid fa-chevron-down text-xs opacity-50"></i>
|
||||
</div>
|
||||
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2 shadow-lg border border-base-200 mt-2">
|
||||
<li class="menu-title">
|
||||
<span class="text-xs text-base-content/50 capitalize">{{ str_replace('_', ' ', auth()->user()->getRoleNames()->first() ?? 'user') }}</span>
|
||||
</li>
|
||||
<li>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="text-error w-full text-left flex items-center gap-2">
|
||||
<i class="fa-solid fa-right-from-bracket"></i>
|
||||
Keluar
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@include('partials.sidebar')
|
||||
|
||||
<!-- ═══ MAIN CONTENT ════════════════════════════════════════════════════ -->
|
||||
<main class="max-w-screen-xl mx-auto px-4 py-8">
|
||||
<main class="app-main">
|
||||
<div class="max-w-screen-xl mx-auto px-4 py-8">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 mb-8">
|
||||
@@ -145,9 +84,17 @@
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div role="tablist" class="tabs tabs-lifted tabs-lg mb-6">
|
||||
<input type="radio" name="data_tabs" role="tab" class="tab font-bold" aria-label="🕌 Rumah Ibadah" checked />
|
||||
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6 shadow-sm">
|
||||
<div class="genesis-tabs">
|
||||
<div class="genesis-tab-list" role="tablist" aria-label="Jenis data">
|
||||
<button type="button" class="genesis-tab-button is-active" id="tab_ibadah" role="tab" aria-selected="true" aria-controls="panel_ibadah" onclick="setDataTab('ibadah')">
|
||||
Rumah Ibadah
|
||||
</button>
|
||||
<button type="button" class="genesis-tab-button" id="tab_miskin" role="tab" aria-selected="false" aria-controls="panel_miskin" onclick="setDataTab('miskin')">
|
||||
Rumah Miskin
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="panel_ibadah" role="tabpanel" aria-labelledby="tab_ibadah" class="genesis-tab-panel">
|
||||
@if(auth()->user()->hasRole('administrator'))
|
||||
<div id="bulkBar_ibadah" class="hidden alert alert-warning py-2 mb-2 flex items-center justify-between">
|
||||
<span class="text-sm font-semibold"><span id="bulkCount_ibadah">0</span> item terpilih</span>
|
||||
@@ -175,8 +122,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="radio" name="data_tabs" role="tab" class="tab font-bold" aria-label="🏠 Rumah Miskin" />
|
||||
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6 shadow-sm">
|
||||
<div id="panel_miskin" role="tabpanel" aria-labelledby="tab_miskin" class="genesis-tab-panel hidden">
|
||||
@if(auth()->user()->hasRole('administrator'))
|
||||
<div id="bulkBar_miskin" class="hidden alert alert-warning py-2 mb-2 flex items-center justify-between">
|
||||
<span class="text-sm font-semibold"><span id="bulkCount_miskin">0</span> item terpilih</span>
|
||||
@@ -204,6 +150,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Modal: Edit Rumah Ibadah -->
|
||||
@@ -374,8 +321,9 @@
|
||||
@if(auth()->user()->hasRole('administrator') || auth()->user()->hasRole('petugas_pendataan'))
|
||||
<div class="collapse collapse-arrow border border-base-300 rounded-lg">
|
||||
<input type="checkbox" id="toggleFormAnggota">
|
||||
<div class="collapse-title text-sm font-bold"><i class="fa-solid fa-plus mr-2 text-info"></i>Tambah Anggota</div>
|
||||
<div class="collapse-title text-sm font-bold"><i class="fa-solid fa-user-pen mr-2 text-info"></i><span id="formAnggotaTitle">Tambah Anggota</span></div>
|
||||
<div class="collapse-content">
|
||||
<input type="hidden" id="anggota_edit_id">
|
||||
<div class="grid grid-cols-2 gap-3 mt-2">
|
||||
<div class="form-control col-span-2">
|
||||
<label class="label"><span class="label-text font-bold">Nama</span></label>
|
||||
@@ -423,7 +371,10 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="simpanAnggota()" class="btn btn-info btn-sm mt-3 w-full"><i class="fa-solid fa-floppy-disk mr-1"></i>Simpan Anggota</button>
|
||||
<div class="flex gap-2 mt-3">
|
||||
<button onclick="resetAnggotaForm()" class="btn btn-ghost btn-sm flex-1">Reset</button>
|
||||
<button onclick="simpanAnggota()" class="btn btn-info btn-sm flex-1"><i class="fa-solid fa-floppy-disk mr-1"></i><span id="btnSimpanAnggotaText">Simpan Anggota</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@@ -495,9 +446,21 @@
|
||||
let selectedMiskin = new Set();
|
||||
|
||||
let ibadahData = [], miskinData = [];
|
||||
let activeDataTab = 'ibadah';
|
||||
document.addEventListener('DOMContentLoaded', loadAllData);
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
|
||||
function setDataTab(type) {
|
||||
activeDataTab = type;
|
||||
const isIbadah = type === 'ibadah';
|
||||
document.getElementById('tab_ibadah').classList.toggle('is-active', isIbadah);
|
||||
document.getElementById('tab_miskin').classList.toggle('is-active', !isIbadah);
|
||||
document.getElementById('tab_ibadah').setAttribute('aria-selected', isIbadah ? 'true' : 'false');
|
||||
document.getElementById('tab_miskin').setAttribute('aria-selected', isIbadah ? 'false' : 'true');
|
||||
document.getElementById('panel_ibadah').classList.toggle('hidden', !isIbadah);
|
||||
document.getElementById('panel_miskin').classList.toggle('hidden', isIbadah);
|
||||
}
|
||||
|
||||
async function loadAllData() {
|
||||
try {
|
||||
const [ri, rm] = await Promise.all([
|
||||
@@ -592,10 +555,9 @@
|
||||
}
|
||||
|
||||
function exportCSV() {
|
||||
const activeTab = document.querySelector('input[name="data_tabs"]:checked').getAttribute('aria-label');
|
||||
let csvContent = "data:text/csv;charset=utf-8,";
|
||||
|
||||
if (activeTab.includes("Ibadah")) {
|
||||
if (activeDataTab === 'ibadah') {
|
||||
csvContent += "ID,Nama,Jenis,Alamat,Radius,Jemaah,Pengurus,Telepon,Lat,Lon\n";
|
||||
ibadahData.forEach(i => {
|
||||
csvContent += `${i.id},"${i.nama}","${i.jenis}","${i.alamat}",${i.radius},${i.jumlah_jemaah},"${i.pengurus}","${i.telepon}",${i.latitude},${i.longitude}\n`;
|
||||
@@ -837,20 +799,22 @@
|
||||
|
||||
// ── ANGGOTA KELUARGA ───────────────────────────────────────────────────
|
||||
let currentAnggotaRumah = null;
|
||||
let anggotaData = [];
|
||||
const canEditAnggota = {{ auth()->user()->hasRole('administrator') || auth()->user()->hasRole('petugas_pendataan') ? 'true' : 'false' }};
|
||||
|
||||
async function openAnggotaModal(idRumah) {
|
||||
currentAnggotaRumah = idRumah;
|
||||
const rm = miskinData.find(m => m.id_rumah === idRumah);
|
||||
document.getElementById('anggota_rumah_label').textContent = `ID: ${idRumah} — ${rm?.nama_kk || '-'}`;
|
||||
resetAnggotaForm();
|
||||
document.getElementById('modal_anggota').showModal();
|
||||
await loadAnggota(idRumah);
|
||||
}
|
||||
|
||||
async function loadAnggota(idRumah) {
|
||||
const res = await fetch(`{{ url('/api/miskin') }}/${encodeURIComponent(idRumah)}/anggota`, { headers: { 'X-CSRF-TOKEN': csrfToken } });
|
||||
const list = await res.json();
|
||||
document.getElementById('bodyAnggota').innerHTML = list.length ? list.map(a => `
|
||||
anggotaData = await res.json();
|
||||
document.getElementById('bodyAnggota').innerHTML = anggotaData.length ? anggotaData.map(a => `
|
||||
<tr>
|
||||
<td class="font-semibold">${esc(a.nama)}</td>
|
||||
<td class="capitalize text-xs">${esc(a.hubungan.replace('_', ' '))}</td>
|
||||
@@ -858,12 +822,45 @@
|
||||
<td class="text-xs">${a.umur !== null ? esc(String(a.umur)) + ' th' : '-'}</td>
|
||||
<td class="capitalize text-xs">${a.pendidikan ? esc(a.pendidikan.replace(/_/g, ' ')) : '-'}</td>
|
||||
<td><span class="badge badge-xs ${a.status_hidup === 'hidup' ? 'badge-success' : 'badge-error'}">${esc(a.status_hidup)}</span></td>
|
||||
<td>${canEditAnggota ? `<button onclick="deleteAnggota(${a.id})" class="btn btn-xs btn-ghost text-error"><i class="fa-solid fa-trash"></i></button>` : ''}</td>
|
||||
<td>${canEditAnggota ? `<div class="flex justify-end gap-1">
|
||||
<button onclick="editAnggota(${a.id})" class="btn btn-xs btn-ghost text-warning" title="Edit"><i class="fa-solid fa-pen"></i></button>
|
||||
<button onclick="deleteAnggota(${a.id})" class="btn btn-xs btn-ghost text-error" title="Hapus"><i class="fa-solid fa-trash"></i></button>
|
||||
</div>` : ''}</td>
|
||||
</tr>`).join('')
|
||||
: '<tr><td colspan="7" class="text-center py-4 opacity-30 italic">Belum ada anggota</td></tr>';
|
||||
}
|
||||
|
||||
function resetAnggotaForm() {
|
||||
const editId = document.getElementById('anggota_edit_id');
|
||||
if (!editId) return;
|
||||
editId.value = '';
|
||||
document.getElementById('formAnggotaTitle').textContent = 'Tambah Anggota';
|
||||
document.getElementById('btnSimpanAnggotaText').textContent = 'Simpan Anggota';
|
||||
document.getElementById('anggota_nama').value = '';
|
||||
document.getElementById('anggota_hubungan').value = 'lainnya';
|
||||
document.getElementById('anggota_kelamin').value = '';
|
||||
document.getElementById('anggota_tgl_lahir').value = '';
|
||||
document.getElementById('anggota_pendidikan').value = '';
|
||||
document.getElementById('anggota_status').value = 'hidup';
|
||||
}
|
||||
|
||||
function editAnggota(id) {
|
||||
const item = anggotaData.find(a => Number(a.id) === Number(id));
|
||||
if (!item) return;
|
||||
document.getElementById('anggota_edit_id').value = item.id;
|
||||
document.getElementById('formAnggotaTitle').textContent = 'Edit Anggota';
|
||||
document.getElementById('btnSimpanAnggotaText').textContent = 'Simpan Perubahan';
|
||||
document.getElementById('anggota_nama').value = item.nama || '';
|
||||
document.getElementById('anggota_hubungan').value = item.hubungan || 'lainnya';
|
||||
document.getElementById('anggota_kelamin').value = item.jenis_kelamin || '';
|
||||
document.getElementById('anggota_tgl_lahir').value = item.tanggal_lahir || '';
|
||||
document.getElementById('anggota_pendidikan').value = item.pendidikan || '';
|
||||
document.getElementById('anggota_status').value = item.status_hidup || 'hidup';
|
||||
document.getElementById('toggleFormAnggota').checked = true;
|
||||
}
|
||||
|
||||
async function simpanAnggota() {
|
||||
const editId = document.getElementById('anggota_edit_id')?.value || '';
|
||||
const body = {
|
||||
nama: document.getElementById('anggota_nama').value.trim(),
|
||||
hubungan: document.getElementById('anggota_hubungan').value,
|
||||
@@ -873,14 +870,22 @@
|
||||
status_hidup: document.getElementById('anggota_status').value,
|
||||
};
|
||||
if (!body.nama) { showToast('Nama wajib diisi', 'error'); return; }
|
||||
const res = await fetch(`{{ url('/api/miskin') }}/${encodeURIComponent(currentAnggotaRumah)}/anggota`, {
|
||||
method: 'POST',
|
||||
const url = editId
|
||||
? `{{ url('/api/miskin') }}/${encodeURIComponent(currentAnggotaRumah)}/anggota/${editId}`
|
||||
: `{{ url('/api/miskin') }}/${encodeURIComponent(currentAnggotaRumah)}/anggota`;
|
||||
const res = await fetch(url, {
|
||||
method: editId ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success) { showToast('Anggota ditambahkan', 'success'); loadAnggota(currentAnggotaRumah); }
|
||||
else showToast('Gagal menambah anggota', 'error');
|
||||
if (json.success) {
|
||||
showToast(editId ? 'Anggota diperbarui' : 'Anggota ditambahkan', 'success');
|
||||
resetAnggotaForm();
|
||||
loadAnggota(currentAnggotaRumah);
|
||||
} else {
|
||||
showToast(editId ? 'Gagal memperbarui anggota' : 'Gagal menambah anggota', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAnggota(id) {
|
||||
@@ -896,6 +901,8 @@
|
||||
// ── RIWAYAT BANTUAN ────────────────────────────────────────────────────
|
||||
let currentBantuanRumah = null;
|
||||
const canDeleteBantuan = {{ auth()->user()->hasRole('administrator') ? 'true' : 'false' }};
|
||||
const currentUserId = Number({{ auth()->id() ?? 0 }});
|
||||
const isPengurusIbadah = {{ auth()->user()->hasRole('pengurus_ibadah') ? 'true' : 'false' }};
|
||||
|
||||
async function openBantuanModal(idRumah) {
|
||||
currentBantuanRumah = idRumah;
|
||||
@@ -904,8 +911,11 @@
|
||||
|
||||
const sel = document.getElementById('bantuan_ibadah');
|
||||
if (sel) {
|
||||
const availableIbadah = isPengurusIbadah
|
||||
? ibadahData.filter(ib => Number(ib.user_id) === currentUserId)
|
||||
: ibadahData;
|
||||
sel.innerHTML = '<option value="">-- Pilih (opsional) --</option>' +
|
||||
ibadahData.map(ib => `<option value="${ib.id}">${esc(ib.nama)}</option>`).join('');
|
||||
availableIbadah.map(ib => `<option value="${ib.id}">${esc(ib.nama)}</option>`).join('');
|
||||
}
|
||||
|
||||
const tgl = document.getElementById('bantuan_tanggal');
|
||||
@@ -958,14 +968,31 @@
|
||||
}
|
||||
|
||||
// ── NOTIFIKASI PERUBAHAN DATA ────────────────────────────────────────
|
||||
function updateTransferBadge(count) {
|
||||
document.querySelectorAll('a[href$="/permohonan"]').forEach(link => {
|
||||
let badge = link.querySelector('.transfer-pending-badge');
|
||||
if (count > 0) {
|
||||
if (!badge) {
|
||||
badge = document.createElement('span');
|
||||
badge.className = 'badge badge-error badge-xs transfer-pending-badge';
|
||||
link.appendChild(document.createTextNode(' '));
|
||||
link.appendChild(badge);
|
||||
}
|
||||
badge.textContent = count;
|
||||
badge.classList.remove('hidden');
|
||||
} else if (badge) {
|
||||
badge.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
(function initNotifPolling() {
|
||||
const POLL_MS = 30000;
|
||||
const KEY_IBADAH = 'notif_latest_ibadah';
|
||||
const KEY_MISKIN = 'notif_latest_miskin';
|
||||
const KEY_SEEN = 'notif_latest_seen';
|
||||
|
||||
async function pollNotifications() {
|
||||
try {
|
||||
const since = localStorage.getItem(KEY_IBADAH) || new Date(0).toISOString();
|
||||
const since = localStorage.getItem(KEY_SEEN) || new Date(0).toISOString();
|
||||
const res = await fetch(
|
||||
`{{ url('/api/notifications') }}?since=${encodeURIComponent(since)}`,
|
||||
{ headers: { 'X-CSRF-TOKEN': csrfToken } }
|
||||
@@ -973,22 +1000,30 @@
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
|
||||
if (data.new_ibadah > 0 || data.new_miskin > 0) {
|
||||
updateTransferBadge(Number(data.pending_transfer || 0));
|
||||
|
||||
const hasChanges = data.new_ibadah > 0 || data.new_miskin > 0 || data.new_anggota > 0 || data.new_bantuan > 0 || data.new_transfer > 0;
|
||||
if (hasChanges) {
|
||||
if (data.new_ibadah > 0) showToast(`${data.new_ibadah} rumah ibadah baru ditambahkan`, 'info');
|
||||
if (data.new_miskin > 0) showToast(`${data.new_miskin} keluarga miskin baru ditambahkan`, 'info');
|
||||
if (data.new_anggota > 0) showToast(`${data.new_anggota} anggota keluarga baru ditambahkan`, 'info');
|
||||
if (data.new_bantuan > 0) showToast(`${data.new_bantuan} bantuan baru dicatat`, 'info');
|
||||
if (data.new_transfer > 0) showToast(`${data.new_transfer} permohonan transfer baru`, 'info');
|
||||
const fn = typeof loadAllData !== 'undefined' ? loadAllData
|
||||
: typeof loadData !== 'undefined' ? loadData
|
||||
: null;
|
||||
if (fn) fn();
|
||||
}
|
||||
if (data.latest_ibadah) localStorage.setItem(KEY_IBADAH, data.latest_ibadah);
|
||||
if (data.latest_miskin) localStorage.setItem(KEY_MISKIN, data.latest_miskin);
|
||||
const latest = [data.latest_ibadah, data.latest_miskin, data.latest_anggota, data.latest_bantuan, data.latest_transfer]
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.pop();
|
||||
if (latest) localStorage.setItem(KEY_SEEN, latest);
|
||||
} catch (e) { /* silent */ }
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (!localStorage.getItem(KEY_IBADAH)) localStorage.setItem(KEY_IBADAH, new Date().toISOString());
|
||||
if (!localStorage.getItem(KEY_MISKIN)) localStorage.setItem(KEY_MISKIN, new Date().toISOString());
|
||||
if (!localStorage.getItem(KEY_SEEN)) localStorage.setItem(KEY_SEEN, new Date().toISOString());
|
||||
setInterval(pollNotifications, POLL_MS);
|
||||
});
|
||||
})();
|
||||
|
||||
+55
-108
@@ -22,28 +22,25 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
* { font-family: 'Plus Jakarta Sans', sans-serif; }
|
||||
body { font-weight: 500; height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
|
||||
h1, h2, h3, h4, h5, h6, .btn, .card-title, .stat-value { font-weight: 700 !important; letter-spacing: -0.01em; }
|
||||
body { height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.map-wrapper { flex: 1; position: relative; min-height: 0; }
|
||||
#map { width: 100%; height: 100%; }
|
||||
|
||||
/* Floating Controls */
|
||||
.floating-panel {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
top: 24px;
|
||||
left: 24px;
|
||||
z-index: 1000;
|
||||
width: 300px;
|
||||
width: 316px;
|
||||
max-height: calc(100vh - 100px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
.map-panel {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.15);
|
||||
border: 1px solid #e8e8ec;
|
||||
}
|
||||
|
||||
/* Marker dragging hint */
|
||||
@@ -57,102 +54,27 @@
|
||||
|
||||
/* Custom Layer Control */
|
||||
.leaflet-control-layers {
|
||||
border-radius: 12px !important;
|
||||
border: none !important;
|
||||
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.15) !important;
|
||||
border-radius: 8px !important;
|
||||
border: 1px solid #e8e8ec !important;
|
||||
backdrop-filter: blur(8px);
|
||||
background: rgba(255, 255, 255, 0.85) !important;
|
||||
background: rgba(255, 255, 255, 0.92) !important;
|
||||
padding: 8px 12px !important;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="{{ asset('css/genesis.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<body class="app-body app-map-body">
|
||||
|
||||
<!-- ═══ NAVBAR ═══════════════════════════════════════════════════════════ -->
|
||||
<div class="navbar bg-base-100 border-b border-base-200 shadow-sm flex-shrink-0 z-[1001]">
|
||||
<div class="flex-1">
|
||||
<a href="{{ url('/') }}" class="btn btn-ghost text-lg font-bold gap-2 text-primary">
|
||||
<i class="fa-solid fa-house-chimney-crack"></i>
|
||||
WebGIS Pemetaan Kemiskinan
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<ul class="menu menu-horizontal px-1 gap-1">
|
||||
<li>
|
||||
<a href="{{ url('/map') }}" class="font-semibold bg-primary/10 text-primary rounded-lg">
|
||||
<i class="fa-solid fa-map"></i> Peta Interaktif
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url('/data') }}" class="font-medium">
|
||||
<i class="fa-solid fa-table-list"></i> Data Tabular
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url('/dashboard') }}" class="font-medium">
|
||||
<i class="fa-solid fa-chart-bar"></i> Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url('/permohonan') }}" class="font-medium">
|
||||
<i class="fa-solid fa-right-left"></i> Transfer
|
||||
</a>
|
||||
</li>
|
||||
@if(auth()->user()->hasRole('administrator'))
|
||||
<li>
|
||||
<a href="{{ url('/admin/users') }}" class="font-medium">
|
||||
<i class="fa-solid fa-users-gear"></i> Admin
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url('/admin/logs') }}" class="font-medium">
|
||||
<i class="fa-solid fa-clock-rotate-left"></i> Log
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url('/admin/settings') }}" class="font-medium">
|
||||
<i class="fa-solid fa-sliders"></i> Pengaturan
|
||||
</a>
|
||||
</li>
|
||||
@endif
|
||||
</ul>
|
||||
<!-- User dropdown -->
|
||||
<div class="dropdown dropdown-end ml-2">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost gap-2">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-primary text-primary-content rounded-full w-8">
|
||||
<span class="text-xs font-bold">{{ substr(auth()->user()->name, 0, 1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="hidden sm:inline text-sm font-semibold">{{ auth()->user()->name }}</span>
|
||||
<i class="fa-solid fa-chevron-down text-xs opacity-50"></i>
|
||||
</div>
|
||||
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2 shadow-lg border border-base-200 mt-2">
|
||||
<li class="menu-title">
|
||||
<span class="text-xs text-base-content/50 capitalize">{{ str_replace('_', ' ', auth()->user()->getRoleNames()->first() ?? 'user') }}</span>
|
||||
</li>
|
||||
<li>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="text-error w-full text-left flex items-center gap-2">
|
||||
<i class="fa-solid fa-right-from-bracket"></i>
|
||||
Keluar
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@include('partials.sidebar')
|
||||
|
||||
<!-- ═══ MAP CONTAINER ════════════════════════════════════════════════════ -->
|
||||
<div class="map-wrapper">
|
||||
<div class="map-wrapper app-map-main">
|
||||
<div id="map"></div>
|
||||
|
||||
<!-- Floating Control Panel -->
|
||||
<div class="floating-panel flex flex-col gap-3">
|
||||
<!-- Mode Selector -->
|
||||
<div class="card glass-card p-4">
|
||||
<div class="card map-panel p-4">
|
||||
<h3 class="text-sm font-bold mb-3 flex items-center gap-2">
|
||||
<i class="fa-solid fa-layer-group text-primary"></i>
|
||||
Mode Operasi
|
||||
@@ -161,17 +83,17 @@
|
||||
<button class="btn btn-sm btn-outline btn-primary justify-start gap-2 active" id="btnModeView" onclick="setMode('view')">
|
||||
<i class="fa-solid fa-eye"></i> View & Analisis
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline btn-success justify-start gap-2" id="btnModeIbadah" onclick="setMode('ibadah')">
|
||||
<button class="btn btn-sm btn-outline justify-start gap-2" id="btnModeIbadah" onclick="setMode('ibadah')">
|
||||
<i class="fa-solid fa-plus"></i> Tambah Rumah Ibadah
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline btn-error justify-start gap-2" id="btnModeMiskin" onclick="setMode('miskin')">
|
||||
<button class="btn btn-sm btn-outline justify-start gap-2" id="btnModeMiskin" onclick="setMode('miskin')">
|
||||
<i class="fa-solid fa-plus"></i> Tambah Rumah Miskin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Analysis Panel (Conditional) -->
|
||||
<div id="analysisPanel" class="card glass-card p-4 hidden">
|
||||
<div id="analysisPanel" class="card map-panel p-4 hidden">
|
||||
<h3 class="text-sm font-bold mb-2 flex items-center gap-2">
|
||||
<i class="fa-solid fa-chart-pie text-secondary"></i>
|
||||
Statistik Radius
|
||||
@@ -219,11 +141,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Hints -->
|
||||
<div id="mapHint" class="badge badge-primary badge-lg py-4 px-6 gap-2 shadow-xl hidden">
|
||||
<div id="mapHint" class="badge badge-primary badge-lg py-4 px-6 gap-2 hidden">
|
||||
<i class="fa-solid fa-mouse-pointer"></i>
|
||||
<span>Klik peta untuk menentukan lokasi baru</span>
|
||||
</div>
|
||||
<div id="dragHint" class="badge badge-warning badge-lg py-4 px-6 gap-2 shadow-xl hidden">
|
||||
<div id="dragHint" class="badge badge-warning badge-lg py-4 px-6 gap-2 hidden">
|
||||
<i class="fa-solid fa-arrows-up-down-left-right"></i>
|
||||
<span>Lepaskan untuk menyimpan lokasi baru</span>
|
||||
</div>
|
||||
@@ -855,7 +777,7 @@
|
||||
function buildPopupIbadah(item) {
|
||||
const canEditThis = canEditIbadah && (authUserRole === 'administrator' || item.user_id === authUserId);
|
||||
return `<div class="p-2 min-w-[200px]">
|
||||
<div class="font-bold text-base text-primary">🕌 ${item.nama}</div>
|
||||
<div class="font-bold text-base">${item.nama}</div>
|
||||
<div class="badge badge-sm badge-outline mb-2 capitalize">${item.jenis || 'Ibadah'}</div>
|
||||
<div class="text-[10px] text-gray-500 mb-1"><i class="fa-solid fa-location-dot"></i> ${item.alamat}</div>
|
||||
<div class="grid grid-cols-2 gap-2 my-2 text-[10px] bg-base-200 p-2 rounded">
|
||||
@@ -871,7 +793,7 @@
|
||||
|
||||
function buildPopupMiskin(item) {
|
||||
return `<div class="p-2 min-w-[200px]">
|
||||
<div class="font-bold text-base text-error">🏠 ${item.nama_kk || 'Keluarga Miskin'}</div>
|
||||
<div class="font-bold text-base">${item.nama_kk || 'Keluarga Miskin'}</div>
|
||||
<div class="text-[10px] text-gray-400 mb-1">ID: ${item.id_rumah}</div>
|
||||
<div class="text-[10px] text-gray-500 mb-1"><i class="fa-solid fa-location-dot"></i> ${item.alamat || '-'}</div>
|
||||
<div class="grid grid-cols-2 gap-2 my-2 text-[10px] bg-base-200 p-2 rounded">
|
||||
@@ -1132,14 +1054,31 @@
|
||||
}
|
||||
|
||||
// ── NOTIFIKASI PERUBAHAN DATA ────────────────────────────────────────
|
||||
function updateTransferBadge(count) {
|
||||
document.querySelectorAll('a[href$="/permohonan"]').forEach(link => {
|
||||
let badge = link.querySelector('.transfer-pending-badge');
|
||||
if (count > 0) {
|
||||
if (!badge) {
|
||||
badge = document.createElement('span');
|
||||
badge.className = 'badge badge-error badge-xs transfer-pending-badge';
|
||||
link.appendChild(document.createTextNode(' '));
|
||||
link.appendChild(badge);
|
||||
}
|
||||
badge.textContent = count;
|
||||
badge.classList.remove('hidden');
|
||||
} else if (badge) {
|
||||
badge.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
(function initNotifPolling() {
|
||||
const POLL_MS = 30000;
|
||||
const KEY_IBADAH = 'notif_latest_ibadah';
|
||||
const KEY_MISKIN = 'notif_latest_miskin';
|
||||
const KEY_SEEN = 'notif_latest_seen';
|
||||
|
||||
async function pollNotifications() {
|
||||
try {
|
||||
const since = localStorage.getItem(KEY_IBADAH) || new Date(0).toISOString();
|
||||
const since = localStorage.getItem(KEY_SEEN) || new Date(0).toISOString();
|
||||
const res = await fetch(
|
||||
`{{ url('/api/notifications') }}?since=${encodeURIComponent(since)}`,
|
||||
{ headers: { 'X-CSRF-TOKEN': csrfToken } }
|
||||
@@ -1147,23 +1086,31 @@
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
|
||||
if (data.new_ibadah > 0 || data.new_miskin > 0) {
|
||||
updateTransferBadge(Number(data.pending_transfer || 0));
|
||||
|
||||
const hasChanges = data.new_ibadah > 0 || data.new_miskin > 0 || data.new_anggota > 0 || data.new_bantuan > 0 || data.new_transfer > 0;
|
||||
if (hasChanges) {
|
||||
if (data.new_ibadah > 0) showToast(`${data.new_ibadah} rumah ibadah baru ditambahkan`, 'info');
|
||||
if (data.new_miskin > 0) showToast(`${data.new_miskin} keluarga miskin baru ditambahkan`, 'info');
|
||||
if (data.new_anggota > 0) showToast(`${data.new_anggota} anggota keluarga baru ditambahkan`, 'info');
|
||||
if (data.new_bantuan > 0) showToast(`${data.new_bantuan} bantuan baru dicatat`, 'info');
|
||||
if (data.new_transfer > 0) showToast(`${data.new_transfer} permohonan transfer baru`, 'info');
|
||||
const fn = typeof loadAllData !== 'undefined' ? loadAllData
|
||||
: typeof loadData !== 'undefined' ? loadData
|
||||
: typeof loadDashboard !== 'undefined' ? loadDashboard
|
||||
: null;
|
||||
if (fn) fn();
|
||||
}
|
||||
if (data.latest_ibadah) localStorage.setItem(KEY_IBADAH, data.latest_ibadah);
|
||||
if (data.latest_miskin) localStorage.setItem(KEY_MISKIN, data.latest_miskin);
|
||||
const latest = [data.latest_ibadah, data.latest_miskin, data.latest_anggota, data.latest_bantuan, data.latest_transfer]
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.pop();
|
||||
if (latest) localStorage.setItem(KEY_SEEN, latest);
|
||||
} catch (e) { /* silent */ }
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (!localStorage.getItem(KEY_IBADAH)) localStorage.setItem(KEY_IBADAH, new Date().toISOString());
|
||||
if (!localStorage.getItem(KEY_MISKIN)) localStorage.setItem(KEY_MISKIN, new Date().toISOString());
|
||||
if (!localStorage.getItem(KEY_SEEN)) localStorage.setItem(KEY_SEEN, new Date().toISOString());
|
||||
setInterval(pollNotifications, POLL_MS);
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
@php
|
||||
$user = auth()->user();
|
||||
$roleName = $user?->getRoleNames()->first() ?? 'user';
|
||||
$pendingTransferCount = $pendingTransferCount ?? 0;
|
||||
|
||||
$navItems = [
|
||||
[
|
||||
'label' => 'Peta Interaktif',
|
||||
'short' => 'Peta',
|
||||
'href' => url('/map'),
|
||||
'icon' => 'fa-map',
|
||||
'active' => request()->is('map'),
|
||||
],
|
||||
[
|
||||
'label' => 'Data Tabular',
|
||||
'short' => 'Data',
|
||||
'href' => url('/data'),
|
||||
'icon' => 'fa-table-list',
|
||||
'active' => request()->is('data'),
|
||||
],
|
||||
[
|
||||
'label' => 'Dashboard',
|
||||
'short' => 'Dashboard',
|
||||
'href' => url('/dashboard'),
|
||||
'icon' => 'fa-chart-bar',
|
||||
'active' => request()->is('dashboard'),
|
||||
],
|
||||
[
|
||||
'label' => 'Transfer',
|
||||
'short' => 'Transfer',
|
||||
'href' => url('/permohonan'),
|
||||
'icon' => 'fa-right-left',
|
||||
'active' => request()->is('permohonan'),
|
||||
'badge' => $pendingTransferCount,
|
||||
],
|
||||
];
|
||||
|
||||
$adminItems = [
|
||||
[
|
||||
'label' => 'Admin',
|
||||
'href' => url('/admin/users'),
|
||||
'icon' => 'fa-users-gear',
|
||||
'active' => request()->is('admin/users'),
|
||||
],
|
||||
[
|
||||
'label' => 'Log',
|
||||
'href' => url('/admin/logs'),
|
||||
'icon' => 'fa-clock-rotate-left',
|
||||
'active' => request()->is('admin/logs'),
|
||||
],
|
||||
[
|
||||
'label' => 'Pengaturan',
|
||||
'href' => url('/admin/settings'),
|
||||
'icon' => 'fa-sliders',
|
||||
'active' => request()->is('admin/settings'),
|
||||
],
|
||||
];
|
||||
@endphp
|
||||
|
||||
<input id="app-sidebar-toggle" type="checkbox" class="app-sidebar-toggle" autocomplete="off">
|
||||
|
||||
<header class="app-mobile-bar">
|
||||
<label for="app-sidebar-toggle" class="btn btn-ghost btn-square app-sidebar-open" aria-label="Buka navigasi">
|
||||
<i class="fa-solid fa-bars"></i>
|
||||
</label>
|
||||
<a href="{{ url('/') }}" class="app-mobile-brand">
|
||||
<i class="fa-solid fa-house-chimney-crack"></i>
|
||||
<span>WebGIS</span>
|
||||
</a>
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-primary text-primary-content rounded-full w-8">
|
||||
<span class="text-xs font-bold">{{ substr($user->name, 0, 1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<label for="app-sidebar-toggle" class="app-sidebar-backdrop" aria-label="Tutup navigasi"></label>
|
||||
|
||||
<aside class="app-sidebar" aria-label="Navigasi utama">
|
||||
<div class="app-sidebar-header">
|
||||
<a href="{{ url('/') }}" class="app-sidebar-brand">
|
||||
<span class="app-brand-icon"><i class="fa-solid fa-house-chimney-crack"></i></span>
|
||||
<span class="app-brand-copy">
|
||||
<span class="app-brand-title">WebGIS</span>
|
||||
<span class="app-brand-subtitle">Pemetaan Kemiskinan</span>
|
||||
</span>
|
||||
</a>
|
||||
<label for="app-sidebar-toggle" class="btn btn-ghost btn-square app-sidebar-close" aria-label="Tutup navigasi">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<nav class="app-sidebar-nav">
|
||||
<div class="app-nav-section">
|
||||
<div class="app-nav-section-title">Menu</div>
|
||||
@foreach($navItems as $item)
|
||||
<a href="{{ $item['href'] }}" class="app-nav-link {{ $item['active'] ? 'is-active' : '' }}">
|
||||
<i class="fa-solid {{ $item['icon'] }}"></i>
|
||||
<span>{{ $item['label'] }}</span>
|
||||
@if(($item['badge'] ?? 0) > 0)
|
||||
<span class="badge badge-error badge-xs transfer-pending-badge app-nav-badge">{{ $item['badge'] }}</span>
|
||||
@endif
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if($user->hasRole('administrator'))
|
||||
<div class="app-nav-section">
|
||||
<div class="app-nav-section-title">Administrasi</div>
|
||||
@foreach($adminItems as $item)
|
||||
<a href="{{ $item['href'] }}" class="app-nav-link {{ $item['active'] ? 'is-active' : '' }}">
|
||||
<i class="fa-solid {{ $item['icon'] }}"></i>
|
||||
<span>{{ $item['label'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</nav>
|
||||
|
||||
<div class="app-sidebar-footer">
|
||||
<div class="app-user-card">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-primary text-primary-content rounded-full w-9">
|
||||
<span class="text-xs font-bold">{{ substr($user->name, 0, 1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-user-copy">
|
||||
<div class="app-user-name">{{ $user->name }}</div>
|
||||
<div class="app-user-role">{{ str_replace('_', ' ', $roleName) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="app-logout-button">
|
||||
<i class="fa-solid fa-right-from-bracket"></i>
|
||||
<span>Keluar</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -14,40 +14,13 @@
|
||||
body { font-weight: 500; background-color: #f8fafc; }
|
||||
h1, h2, h3, .btn { font-weight: 700 !important; letter-spacing: -0.01em; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="{{ asset('css/genesis.css') }}">
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
<div class="navbar bg-base-100 border-b border-base-200 shadow-sm sticky top-0 z-[1000]">
|
||||
<div class="flex-1">
|
||||
<a href="{{ url('/') }}" class="btn btn-ghost text-lg font-bold gap-2 text-primary">
|
||||
<i class="fa-solid fa-house-chimney-crack"></i> WebGIS Pemetaan Kemiskinan
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<ul class="menu menu-horizontal px-1 gap-1">
|
||||
<li><a href="{{ url('/map') }}" class="font-medium"><i class="fa-solid fa-map"></i> Peta</a></li>
|
||||
<li><a href="{{ url('/data') }}" class="font-medium"><i class="fa-solid fa-table-list"></i> Data</a></li>
|
||||
<li><a href="{{ url('/dashboard') }}" class="font-medium"><i class="fa-solid fa-chart-bar"></i> Dashboard</a></li>
|
||||
<li><a href="{{ url('/permohonan') }}" class="font-semibold bg-primary/10 text-primary rounded-lg"><i class="fa-solid fa-right-left"></i> Transfer</a></li>
|
||||
@if(auth()->user()->hasRole('administrator'))
|
||||
<li><a href="{{ url('/admin/users') }}" class="font-medium"><i class="fa-solid fa-users-gear"></i> Admin</a></li>
|
||||
<li><a href="{{ url('/admin/logs') }}" class="font-medium"><i class="fa-solid fa-clock-rotate-left"></i> Log</a></li>
|
||||
<li><a href="{{ url('/admin/settings') }}" class="font-medium"><i class="fa-solid fa-sliders"></i> Pengaturan</a></li>
|
||||
@endif
|
||||
</ul>
|
||||
<div class="dropdown dropdown-end ml-2">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost gap-2">
|
||||
<div class="avatar placeholder"><div class="bg-primary text-primary-content rounded-full w-8"><span class="text-xs font-bold">{{ substr(auth()->user()->name, 0, 1) }}</span></div></div>
|
||||
<span class="hidden sm:inline text-sm font-semibold">{{ auth()->user()->name }}</span>
|
||||
</div>
|
||||
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2 shadow-lg border border-base-200 mt-2">
|
||||
<li class="menu-title"><span class="text-xs text-base-content/50 capitalize">{{ str_replace('_', ' ', auth()->user()->getRoleNames()->first() ?? 'user') }}</span></li>
|
||||
<li><form method="POST" action="{{ route('logout') }}">@csrf<button type="submit" class="text-error w-full text-left flex items-center gap-2"><i class="fa-solid fa-right-from-bracket"></i> Keluar</button></form></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<body class="min-h-screen app-body">
|
||||
@include('partials.sidebar')
|
||||
|
||||
<main class="max-w-screen-xl mx-auto px-4 py-8">
|
||||
<main class="app-main">
|
||||
<div class="max-w-screen-xl mx-auto px-4 py-8">
|
||||
<div class="mb-8 flex items-start justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold flex items-center gap-3"><i class="fa-solid fa-right-left text-primary"></i> Permohonan Transfer</h1>
|
||||
@@ -81,6 +54,7 @@
|
||||
<tbody id="bodyPermohonan"><tr><td colspan="8" class="text-center py-10 opacity-30 italic">Memuat...</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Modal Ajukan Permohonan -->
|
||||
@@ -137,6 +111,8 @@
|
||||
|
||||
const statusBadge = { pending: 'badge-warning', disetujui: 'badge-success', ditolak: 'badge-error' };
|
||||
const canRespond = {{ auth()->user()->hasRole('administrator') || auth()->user()->hasRole('pengurus_ibadah') ? 'true' : 'false' }};
|
||||
const currentUserId = Number({{ auth()->id() ?? 0 }});
|
||||
const isPengurusIbadah = {{ auth()->user()->hasRole('pengurus_ibadah') ? 'true' : 'false' }};
|
||||
|
||||
let allPermohonan = [];
|
||||
let ibadahList = [];
|
||||
@@ -153,9 +129,13 @@
|
||||
ibadahList = await ibadahRes.json();
|
||||
miskinList = await miskinRes.json();
|
||||
|
||||
const optIbadah = ibadahList.map(ib => `<option value="${ib.id}">${esc(ib.nama)}</option>`).join('');
|
||||
document.getElementById('perm_dari').innerHTML = optIbadah;
|
||||
document.getElementById('perm_ke').innerHTML = optIbadah;
|
||||
const fromIbadah = isPengurusIbadah
|
||||
? ibadahList.filter(ib => Number(ib.user_id) === currentUserId)
|
||||
: ibadahList;
|
||||
const optFromIbadah = fromIbadah.map(ib => `<option value="${ib.id}">${esc(ib.nama)}</option>`).join('');
|
||||
const optToIbadah = ibadahList.map(ib => `<option value="${ib.id}">${esc(ib.nama)}</option>`).join('');
|
||||
document.getElementById('perm_dari').innerHTML = optFromIbadah;
|
||||
document.getElementById('perm_ke').innerHTML = optToIbadah;
|
||||
document.getElementById('perm_miskin').innerHTML = miskinList
|
||||
.map(m => `<option value="${esc(m.id_rumah)}">${esc(m.id_rumah)} — ${esc(m.nama_kk || '-')}</option>`).join('');
|
||||
|
||||
|
||||
@@ -20,16 +20,6 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
* {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
}
|
||||
body {
|
||||
font-weight: 500;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6, .btn, .card-title, .stat-value {
|
||||
font-weight: 700 !important;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
#bgMap {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -37,73 +27,141 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
opacity: 0.5;
|
||||
filter: saturate(1.2) brightness(0.7);
|
||||
opacity: 0.78;
|
||||
filter: saturate(0.88) brightness(0.96) contrast(0.92);
|
||||
}
|
||||
.hero {
|
||||
.landing-shell {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
background: transparent !important;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
align-items: end;
|
||||
padding: 96px 24px 32px;
|
||||
}
|
||||
/* Glass effect for content */
|
||||
.hero-content {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
padding: 3rem;
|
||||
border-radius: 2rem;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
.landing-shell::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(250, 250, 250, 0.94) 0%, rgba(250, 250, 250, 0.82) 42%, rgba(250, 250, 250, 0.22) 100%),
|
||||
linear-gradient(180deg, rgba(250, 250, 250, 0.12) 0%, rgba(250, 250, 250, 0.84) 100%);
|
||||
}
|
||||
[data-theme="dark"] .hero-content {
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
.landing-content {
|
||||
max-width: 760px;
|
||||
}
|
||||
.brand-kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #6b6b6b;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.brand-mark {
|
||||
display: inline-grid;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
place-items: center;
|
||||
border: 1px solid #e8e8ec;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #0a0a0a;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.landing-title {
|
||||
max-width: 720px;
|
||||
margin-top: 24px;
|
||||
color: #0a0a0a;
|
||||
font-size: clamp(44px, 7vw, 72px);
|
||||
line-height: 0.94;
|
||||
}
|
||||
.landing-copy {
|
||||
max-width: 520px;
|
||||
margin-top: 20px;
|
||||
color: #6b6b6b;
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.landing-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
.landing-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
margin-top: 28px;
|
||||
color: #6b6b6b;
|
||||
font-size: 13px;
|
||||
}
|
||||
.redirect-strip {
|
||||
width: min(420px, 100%);
|
||||
margin-top: 40px;
|
||||
}
|
||||
.progress {
|
||||
height: 4px;
|
||||
background: #e8e8ec;
|
||||
}
|
||||
.progress::-webkit-progress-value {
|
||||
background: #6366f1;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.landing-shell {
|
||||
padding: 80px 20px 28px;
|
||||
}
|
||||
.landing-title {
|
||||
font-size: 44px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="{{ asset('css/genesis.css') }}">
|
||||
</head>
|
||||
<body class="min-h-screen bg-base-200 flex flex-col items-center justify-center overflow-hidden">
|
||||
<body class="min-h-screen overflow-hidden">
|
||||
|
||||
<!-- Background Map -->
|
||||
<div id="bgMap"></div>
|
||||
|
||||
<div class="hero min-h-screen">
|
||||
<div class="hero-content text-center flex-col gap-2">
|
||||
<!-- Logo -->
|
||||
<div class="text-7xl mb-2">🏘️</div>
|
||||
<h1 class="text-4xl font-bold tracking-tight text-primary">WebGIS Pemetaan Kemiskinan</h1>
|
||||
<p class="max-w-md text-base-content/60 mt-2 mb-6 leading-relaxed">
|
||||
Sistem Informasi Geografis untuk pemetaan rumah ibadah dan
|
||||
analisis sebaran keluarga kurang mampu.
|
||||
<div class="landing-shell">
|
||||
<div class="landing-content">
|
||||
<div class="brand-kicker">
|
||||
<span class="brand-mark">GIS</span>
|
||||
Sistem informasi geografis desa
|
||||
</div>
|
||||
<h1 class="landing-title">Pemetaan kemiskinan berbasis radius layanan.</h1>
|
||||
<p class="landing-copy">
|
||||
Pantau sebaran keluarga kurang mampu, rumah ibadah, dan celah jangkauan bantuan dalam satu ruang kerja spasial yang ringkas.
|
||||
</p>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<div class="landing-actions">
|
||||
<a href="{{ url('/map') }}" class="btn btn-primary btn-lg gap-2">
|
||||
<i class="fa-solid fa-map-location-dot"></i>
|
||||
Buka Peta & Input
|
||||
Buka Peta
|
||||
</a>
|
||||
<a href="{{ url('/data') }}" class="btn btn-outline btn-lg gap-2">
|
||||
<i class="fa-solid fa-table-list"></i>
|
||||
Lihat Data Tabular
|
||||
Lihat Data
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- User info -->
|
||||
<div class="flex items-center gap-2 text-sm text-base-content/50 mt-2">
|
||||
<i class="fa-solid fa-user text-xs"></i>
|
||||
<div class="landing-meta">
|
||||
<span>{{ auth()->user()->name }}</span>
|
||||
<span class="opacity-30">•</span>
|
||||
<span>/</span>
|
||||
<span class="capitalize">{{ str_replace('_', ' ', auth()->user()->getRoleNames()->first() ?? '') }}</span>
|
||||
<span class="opacity-30">•</span>
|
||||
<span>/</span>
|
||||
<form method="POST" action="{{ route('logout') }}" class="inline">
|
||||
@csrf
|
||||
<button type="submit" class="hover:text-error transition-colors underline underline-offset-2">Keluar</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Redirect progress -->
|
||||
<div class="mt-8 w-72">
|
||||
<p class="text-xs text-base-content/40 mb-2">Mengarahkan ke halaman Peta dalam 5 detik...</p>
|
||||
<progress class="progress progress-primary w-full" id="redirectProgress" value="0" max="100"></progress>
|
||||
<div class="redirect-strip">
|
||||
<p class="text-xs text-base-content/50 mb-2">Mengarahkan ke halaman Peta dalam 5 detik</p>
|
||||
<progress class="progress w-full" id="redirectProgress" value="0" max="100"></progress>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\AnggotaKeluargaController;
|
||||
use App\Http\Controllers\AuthController;
|
||||
use App\Http\Controllers\BackupController;
|
||||
use App\Http\Controllers\BantuanController;
|
||||
use App\Http\Controllers\DosenSummaryController;
|
||||
use App\Http\Controllers\ExportController;
|
||||
use App\Http\Controllers\IbadahController;
|
||||
use App\Http\Controllers\ImportController;
|
||||
@@ -64,15 +65,39 @@ Route::middleware('auth')->group(function () {
|
||||
Route::get('/notifications', function (\Illuminate\Http\Request $request) {
|
||||
$since = $request->query('since');
|
||||
$after = $since ? \Carbon\Carbon::parse($since) : now()->subMinutes(1);
|
||||
$user = $request->user();
|
||||
|
||||
$transferQuery = \App\Models\PermohonanTransfer::query();
|
||||
$pendingTransferQuery = \App\Models\PermohonanTransfer::where('status', 'pending');
|
||||
|
||||
if ($user->hasRole('pengurus_ibadah')) {
|
||||
$ownedIbadahIds = \App\Models\RumahIbadah::where('user_id', $user->id)->pluck('id');
|
||||
$transferQuery->where(function ($query) use ($ownedIbadahIds) {
|
||||
$query->whereIn('dari_ibadah_id', $ownedIbadahIds)
|
||||
->orWhereIn('ke_ibadah_id', $ownedIbadahIds);
|
||||
});
|
||||
$pendingTransferQuery->whereIn('ke_ibadah_id', $ownedIbadahIds);
|
||||
} elseif (!$user->hasRole('administrator')) {
|
||||
$pendingTransferQuery->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'new_ibadah' => \App\Models\RumahIbadah::where('created_at', '>', $after)->count(),
|
||||
'new_miskin' => \App\Models\RumahMiskin::where('created_at', '>', $after)->count(),
|
||||
'new_anggota' => \App\Models\AnggotaKeluarga::where('created_at', '>', $after)->count(),
|
||||
'new_bantuan' => \App\Models\RiwayatBantuan::where('created_at', '>', $after)->count(),
|
||||
'new_transfer' => $transferQuery->where('created_at', '>', $after)->count(),
|
||||
'pending_transfer' => $pendingTransferQuery->count(),
|
||||
'latest_ibadah' => \App\Models\RumahIbadah::max('created_at'),
|
||||
'latest_miskin' => \App\Models\RumahMiskin::max('created_at'),
|
||||
'latest_anggota' => \App\Models\AnggotaKeluarga::max('created_at'),
|
||||
'latest_bantuan' => \App\Models\RiwayatBantuan::max('created_at'),
|
||||
'latest_transfer' => \App\Models\PermohonanTransfer::max('created_at'),
|
||||
]);
|
||||
});
|
||||
|
||||
Route::get('/dashboard/dosen-summary', DosenSummaryController::class);
|
||||
|
||||
// Rumah Ibadah — read: all roles, write: administrator + pengurus_ibadah
|
||||
Route::get('/ibadah', [IbadahController::class, 'index']);
|
||||
Route::middleware('role:administrator|pengurus_ibadah')->group(function () {
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\RumahIbadah;
|
||||
use App\Models\RumahMiskin;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AdminAdvancedTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
foreach (['administrator', 'pengurus_ibadah', 'petugas_pendataan', 'pemangku_kebijakan'] as $role) {
|
||||
\Spatie\Permission\Models\Role::create(['name' => $role, 'guard_name' => 'web']);
|
||||
}
|
||||
}
|
||||
|
||||
private function createUser(string $role): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole($role);
|
||||
return $user;
|
||||
}
|
||||
|
||||
// ── ACTIVITY LOG ─────────────────────────────────────────────
|
||||
public function test_creating_ibadah_writes_activity_log(): void
|
||||
{
|
||||
$admin = $this->createUser('administrator');
|
||||
$this->actingAs($admin);
|
||||
|
||||
$this->postJson('/api/ibadah', ['nama' => 'Masjid Log', 'latitude' => -7, 'longitude' => 110])->assertOk();
|
||||
|
||||
$this->assertDatabaseHas('activity_logs', ['action' => 'create', 'subject_type' => 'ibadah', 'user_id' => $admin->id]);
|
||||
}
|
||||
|
||||
public function test_admin_can_list_logs(): void
|
||||
{
|
||||
$admin = $this->createUser('administrator');
|
||||
$this->actingAs($admin);
|
||||
ActivityLog::record('login', 'Test masuk');
|
||||
|
||||
$this->getJson('/api/admin/logs')->assertOk()->assertJsonFragment(['description' => 'Test masuk']);
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_list_logs(): void
|
||||
{
|
||||
$this->actingAs($this->createUser('petugas_pendataan'));
|
||||
$this->getJson('/api/admin/logs')->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_log_page_accessible_by_admin_only(): void
|
||||
{
|
||||
$this->actingAs($this->createUser('administrator'));
|
||||
$this->get('/admin/logs')->assertOk();
|
||||
|
||||
$this->actingAs($this->createUser('pengurus_ibadah'));
|
||||
$this->get('/admin/logs')->assertStatus(403);
|
||||
}
|
||||
|
||||
// ── BULK DELETE ──────────────────────────────────────────────
|
||||
public function test_admin_can_bulk_delete_ibadah(): void
|
||||
{
|
||||
$admin = $this->createUser('administrator');
|
||||
$this->actingAs($admin);
|
||||
|
||||
$a = RumahIbadah::create(['user_id' => $admin->id, 'nama' => 'A', 'latitude' => -7, 'longitude' => 110, 'radius' => 500]);
|
||||
$b = RumahIbadah::create(['user_id' => $admin->id, 'nama' => 'B', 'latitude' => -7, 'longitude' => 110, 'radius' => 500]);
|
||||
|
||||
$this->postJson('/api/admin/ibadah/bulk-delete', ['ids' => [$a->id, $b->id]])
|
||||
->assertOk()->assertJsonFragment(['deleted' => 2]);
|
||||
|
||||
$this->assertDatabaseCount('rumah_ibadah', 0);
|
||||
}
|
||||
|
||||
public function test_admin_can_bulk_delete_miskin(): void
|
||||
{
|
||||
$this->actingAs($this->createUser('administrator'));
|
||||
|
||||
RumahMiskin::create(['id_rumah' => 'RM1', 'latitude' => -7, 'longitude' => 110, 'jumlah_kk' => 1, 'jumlah_orang' => 1]);
|
||||
RumahMiskin::create(['id_rumah' => 'RM2', 'latitude' => -7, 'longitude' => 110, 'jumlah_kk' => 1, 'jumlah_orang' => 1]);
|
||||
|
||||
$this->postJson('/api/admin/miskin/bulk-delete', ['ids' => ['RM1', 'RM2']])
|
||||
->assertOk()->assertJsonFragment(['deleted' => 2]);
|
||||
|
||||
$this->assertDatabaseCount('rumah_miskin', 0);
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_bulk_delete(): void
|
||||
{
|
||||
$this->actingAs($this->createUser('petugas_pendataan'));
|
||||
$this->postJson('/api/admin/miskin/bulk-delete', ['ids' => ['RM1']])->assertStatus(403);
|
||||
}
|
||||
|
||||
// ── CSV IMPORT ───────────────────────────────────────────────
|
||||
public function test_admin_can_import_miskin_csv(): void
|
||||
{
|
||||
$this->actingAs($this->createUser('administrator'));
|
||||
|
||||
$csv = "ID Rumah,Nama KK,Alamat,KK,Jiwa,Pekerjaan,Pendapatan,Kondisi,Lat,Lon\n"
|
||||
. "IMP1,Budi,Jl Test,2,5,Buruh,1000000,buruk,-7.0,110.0\n";
|
||||
$file = UploadedFile::fake()->createWithContent('miskin.csv', $csv);
|
||||
|
||||
$this->post('/api/admin/import/miskin', ['file' => $file])
|
||||
->assertOk()->assertJsonFragment(['imported' => 1, 'failed' => 0]);
|
||||
|
||||
$this->assertDatabaseHas('rumah_miskin', ['id_rumah' => 'IMP1', 'nama_kk' => 'Budi']);
|
||||
}
|
||||
|
||||
public function test_admin_can_import_ibadah_csv(): void
|
||||
{
|
||||
$this->actingAs($this->createUser('administrator'));
|
||||
|
||||
$csv = "ID,Nama,Jenis,Alamat,Radius,Jemaah,Pengurus,Telepon,Lat,Lon\n"
|
||||
. "1,Masjid Import,masjid,Jl Test,600,100,Pak Imam,0812,-7.0,110.0\n";
|
||||
$file = UploadedFile::fake()->createWithContent('ibadah.csv', $csv);
|
||||
|
||||
$this->post('/api/admin/import/ibadah', ['file' => $file])
|
||||
->assertOk()->assertJsonFragment(['imported' => 1, 'failed' => 0]);
|
||||
|
||||
$this->assertDatabaseHas('rumah_ibadah', ['nama' => 'Masjid Import', 'radius' => 600]);
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_import(): void
|
||||
{
|
||||
$this->actingAs($this->createUser('petugas_pendataan'));
|
||||
$file = UploadedFile::fake()->createWithContent('x.csv', "a\n");
|
||||
$this->post('/api/admin/import/miskin', ['file' => $file])->assertStatus(403);
|
||||
}
|
||||
|
||||
// ── BACKUP ───────────────────────────────────────────────────
|
||||
public function test_unauthenticated_cannot_backup(): void
|
||||
{
|
||||
$this->get('/api/admin/backup')->assertRedirect('/login');
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_backup(): void
|
||||
{
|
||||
$this->actingAs($this->createUser('pengurus_ibadah'));
|
||||
$this->get('/api/admin/backup')->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_admin_can_download_backup(): void
|
||||
{
|
||||
$this->actingAs($this->createUser('administrator'));
|
||||
$response = $this->get('/api/admin/backup');
|
||||
$response->assertOk();
|
||||
$response->assertHeader('content-type', 'application/sql');
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AdminUserTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
foreach (['administrator', 'pengurus_ibadah', 'petugas_pendataan', 'pemangku_kebijakan'] as $role) {
|
||||
\Spatie\Permission\Models\Role::create(['name' => $role, 'guard_name' => 'web']);
|
||||
}
|
||||
}
|
||||
|
||||
private function createUser(string $role): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole($role);
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function test_admin_users_page_is_accessible(): void
|
||||
{
|
||||
$admin = $this->createUser('administrator');
|
||||
$this->actingAs($admin);
|
||||
|
||||
$this->get('/admin/users')->assertOk();
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_access_users_page(): void
|
||||
{
|
||||
$pengurus = $this->createUser('pengurus_ibadah');
|
||||
$this->actingAs($pengurus);
|
||||
|
||||
$this->get('/admin/users')->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_admin_can_list_users(): void
|
||||
{
|
||||
$admin = $this->createUser('administrator');
|
||||
$this->actingAs($admin);
|
||||
|
||||
$this->getJson('/api/admin/users')
|
||||
->assertOk()
|
||||
->assertJsonFragment(['email' => $admin->email]);
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_list_users(): void
|
||||
{
|
||||
$pengurus = $this->createUser('pengurus_ibadah');
|
||||
$this->actingAs($pengurus);
|
||||
|
||||
$this->getJson('/api/admin/users')->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_admin_can_create_user(): void
|
||||
{
|
||||
$admin = $this->createUser('administrator');
|
||||
$this->actingAs($admin);
|
||||
|
||||
$this->postJson('/api/admin/users', [
|
||||
'name' => 'User Baru',
|
||||
'email' => 'userbaru@webgis.local',
|
||||
'password' => 'password123',
|
||||
'role' => 'petugas_pendataan',
|
||||
])->assertOk()->assertJsonFragment(['email' => 'userbaru@webgis.local']);
|
||||
|
||||
$this->assertDatabaseHas('users', ['email' => 'userbaru@webgis.local']);
|
||||
}
|
||||
|
||||
public function test_admin_can_update_user(): void
|
||||
{
|
||||
$admin = $this->createUser('administrator');
|
||||
$target = $this->createUser('pengurus_ibadah');
|
||||
$this->actingAs($admin);
|
||||
|
||||
$this->putJson("/api/admin/users/{$target->id}", [
|
||||
'name' => 'Nama Diubah',
|
||||
'email' => $target->email,
|
||||
'role' => 'petugas_pendataan',
|
||||
])->assertOk()->assertJsonFragment(['name' => 'Nama Diubah']);
|
||||
|
||||
$this->assertTrue($target->fresh()->hasRole('petugas_pendataan'));
|
||||
}
|
||||
|
||||
public function test_admin_can_delete_other_user(): void
|
||||
{
|
||||
$admin = $this->createUser('administrator');
|
||||
$target = $this->createUser('pengurus_ibadah');
|
||||
$this->actingAs($admin);
|
||||
|
||||
$this->deleteJson("/api/admin/users/{$target->id}")->assertOk();
|
||||
$this->assertNull(User::find($target->id));
|
||||
}
|
||||
|
||||
public function test_admin_cannot_delete_self(): void
|
||||
{
|
||||
$admin = $this->createUser('administrator');
|
||||
$this->actingAs($admin);
|
||||
|
||||
$this->deleteJson("/api/admin/users/{$admin->id}")->assertStatus(403);
|
||||
$this->assertNotNull(User::find($admin->id));
|
||||
}
|
||||
|
||||
public function test_admin_can_reset_password(): void
|
||||
{
|
||||
$admin = $this->createUser('administrator');
|
||||
$target = $this->createUser('pengurus_ibadah');
|
||||
$this->actingAs($admin);
|
||||
|
||||
$this->putJson("/api/admin/users/{$target->id}/password", [
|
||||
'password' => 'newpassword123',
|
||||
'password_confirmation' => 'newpassword123',
|
||||
])->assertOk();
|
||||
|
||||
$this->assertTrue(
|
||||
\Illuminate\Support\Facades\Hash::check('newpassword123', $target->fresh()->password)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AuthTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->app->make(\Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
}
|
||||
|
||||
public function test_users_table_exists_and_user_can_be_created(): void
|
||||
{
|
||||
$user = User::create([
|
||||
'name' => 'Test Admin',
|
||||
'email' => 'admin@test.com',
|
||||
'password' => bcrypt('password'),
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('users', ['email' => 'admin@test.com']);
|
||||
}
|
||||
|
||||
public function test_roles_seeder_creates_four_roles(): void
|
||||
{
|
||||
$this->artisan('db:seed', ['--class' => 'RolesAndAdminSeeder']);
|
||||
|
||||
$this->assertDatabaseHas('roles', ['name' => 'administrator']);
|
||||
$this->assertDatabaseHas('roles', ['name' => 'pengurus_ibadah']);
|
||||
$this->assertDatabaseHas('roles', ['name' => 'petugas_pendataan']);
|
||||
$this->assertDatabaseHas('roles', ['name' => 'pemangku_kebijakan']);
|
||||
}
|
||||
|
||||
public function test_roles_seeder_creates_default_admin(): void
|
||||
{
|
||||
$this->artisan('db:seed', ['--class' => 'RolesAndAdminSeeder']);
|
||||
|
||||
$user = User::where('email', 'admin@webgis.local')->first();
|
||||
$this->assertNotNull($user);
|
||||
$this->assertTrue($user->hasRole('administrator'));
|
||||
}
|
||||
|
||||
public function test_login_page_is_accessible(): void
|
||||
{
|
||||
$response = $this->get('/login');
|
||||
$response->assertStatus(200);
|
||||
$response->assertViewIs('auth.login');
|
||||
}
|
||||
|
||||
public function test_login_with_valid_credentials_redirects_to_map(): void
|
||||
{
|
||||
$this->artisan('db:seed', ['--class' => 'RolesAndAdminSeeder']);
|
||||
|
||||
$response = $this->post('/login', [
|
||||
'email' => 'admin@webgis.local',
|
||||
'password' => 'admin123',
|
||||
]);
|
||||
|
||||
$response->assertRedirect('/map');
|
||||
}
|
||||
|
||||
public function test_login_with_invalid_credentials_returns_error(): void
|
||||
{
|
||||
$response = $this->post('/login', [
|
||||
'email' => 'wrong@email.com',
|
||||
'password' => 'wrongpass',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('email');
|
||||
}
|
||||
|
||||
public function test_logout_redirects_to_login(): void
|
||||
{
|
||||
$this->artisan('db:seed', ['--class' => 'RolesAndAdminSeeder']);
|
||||
$user = User::where('email', 'admin@webgis.local')->first();
|
||||
|
||||
$response = $this->actingAs($user)->post('/logout');
|
||||
|
||||
$response->assertRedirect('/login');
|
||||
}
|
||||
|
||||
public function test_unauthenticated_user_cannot_access_map(): void
|
||||
{
|
||||
$response = $this->get('/map');
|
||||
$response->assertRedirect('/login');
|
||||
}
|
||||
|
||||
public function test_pemangku_kebijakan_cannot_delete_ibadah(): void
|
||||
{
|
||||
$this->artisan('db:seed', ['--class' => 'RolesAndAdminSeeder']);
|
||||
|
||||
$viewer = User::firstOrCreate(
|
||||
['email' => 'viewer@test.com'],
|
||||
['name' => 'Viewer', 'password' => bcrypt('password')]
|
||||
);
|
||||
$viewer->syncRoles(['pemangku_kebijakan']);
|
||||
|
||||
$response = $this->actingAs($viewer)->delete('/api/ibadah/999');
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_data_page_shows_user_name_and_logout(): void
|
||||
{
|
||||
$this->artisan('db:seed', ['--class' => 'RolesAndAdminSeeder']);
|
||||
$user = User::where('email', 'admin@webgis.local')->first();
|
||||
|
||||
$response = $this->actingAs($user)->get('/data');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Keluar');
|
||||
$response->assertSee($user->name);
|
||||
}
|
||||
|
||||
public function test_map_page_passes_user_role_to_js(): void
|
||||
{
|
||||
$this->artisan('db:seed', ['--class' => 'RolesAndAdminSeeder']);
|
||||
$user = User::where('email', 'admin@webgis.local')->first();
|
||||
|
||||
$response = $this->actingAs($user)->get('/map');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee("authUserRole = 'administrator'", false);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DashboardTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
foreach (['administrator', 'pengurus_ibadah', 'petugas_pendataan', 'pemangku_kebijakan'] as $role) {
|
||||
\Spatie\Permission\Models\Role::create(['name' => $role, 'guard_name' => 'web']);
|
||||
}
|
||||
}
|
||||
|
||||
private function createUser(string $role): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole($role);
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function test_unauthenticated_cannot_access_dashboard(): void
|
||||
{
|
||||
$this->get('/dashboard')->assertRedirect('/login');
|
||||
}
|
||||
|
||||
public function test_administrator_can_access_dashboard(): void
|
||||
{
|
||||
$this->actingAs($this->createUser('administrator'));
|
||||
$this->get('/dashboard')->assertOk();
|
||||
}
|
||||
|
||||
public function test_pengurus_ibadah_can_access_dashboard(): void
|
||||
{
|
||||
$this->actingAs($this->createUser('pengurus_ibadah'));
|
||||
$this->get('/dashboard')->assertOk();
|
||||
}
|
||||
|
||||
public function test_petugas_pendataan_can_access_dashboard(): void
|
||||
{
|
||||
$this->actingAs($this->createUser('petugas_pendataan'));
|
||||
$this->get('/dashboard')->assertOk();
|
||||
}
|
||||
|
||||
public function test_pemangku_kebijakan_can_access_dashboard(): void
|
||||
{
|
||||
$this->actingAs($this->createUser('pemangku_kebijakan'));
|
||||
$this->get('/dashboard')->assertOk();
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\RumahIbadah;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EditFormTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
foreach (['administrator', 'pengurus_ibadah', 'petugas_pendataan', 'pemangku_kebijakan'] as $role) {
|
||||
\Spatie\Permission\Models\Role::create(['name' => $role, 'guard_name' => 'web']);
|
||||
}
|
||||
}
|
||||
|
||||
private function createUser(string $role): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole($role);
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function createIbadah(?int $userId = null): RumahIbadah
|
||||
{
|
||||
return RumahIbadah::create([
|
||||
'user_id' => $userId,
|
||||
'nama' => 'Test Masjid',
|
||||
'jenis' => 'masjid',
|
||||
'latitude' => -7.0,
|
||||
'longitude' => 110.0,
|
||||
'radius' => 500,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_ibadah_store_sets_user_id(): void
|
||||
{
|
||||
$pengurus = $this->createUser('pengurus_ibadah');
|
||||
$this->actingAs($pengurus);
|
||||
|
||||
$this->postJson('/api/ibadah', [
|
||||
'nama' => 'Masjid Baru',
|
||||
'latitude' => -7.0,
|
||||
'longitude' => 110.0,
|
||||
])->assertOk()->assertJsonFragment(['user_id' => $pengurus->id]);
|
||||
}
|
||||
|
||||
public function test_pengurus_can_update_own_ibadah(): void
|
||||
{
|
||||
$pengurus = $this->createUser('pengurus_ibadah');
|
||||
$ibadah = $this->createIbadah($pengurus->id);
|
||||
$this->actingAs($pengurus);
|
||||
|
||||
$this->putJson("/api/ibadah/{$ibadah->id}", [
|
||||
'nama' => 'Masjid Diperbarui',
|
||||
'latitude' => -7.0,
|
||||
'longitude' => 110.0,
|
||||
'radius' => 500,
|
||||
])->assertOk()->assertJsonFragment(['nama' => 'Masjid Diperbarui']);
|
||||
}
|
||||
|
||||
public function test_pengurus_cannot_update_others_ibadah(): void
|
||||
{
|
||||
$pengurus = $this->createUser('pengurus_ibadah');
|
||||
$admin = $this->createUser('administrator');
|
||||
$ibadah = $this->createIbadah($admin->id);
|
||||
$this->actingAs($pengurus);
|
||||
|
||||
$this->putJson("/api/ibadah/{$ibadah->id}", [
|
||||
'nama' => 'Coba Edit',
|
||||
'latitude' => -7.0,
|
||||
'longitude' => 110.0,
|
||||
'radius' => 500,
|
||||
])->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_administrator_can_update_any_ibadah(): void
|
||||
{
|
||||
$admin = $this->createUser('administrator');
|
||||
$pengurus = $this->createUser('pengurus_ibadah');
|
||||
$ibadah = $this->createIbadah($pengurus->id);
|
||||
$this->actingAs($admin);
|
||||
|
||||
$this->putJson("/api/ibadah/{$ibadah->id}", [
|
||||
'nama' => 'Edit oleh Admin',
|
||||
'latitude' => -7.0,
|
||||
'longitude' => 110.0,
|
||||
'radius' => 500,
|
||||
])->assertOk()->assertJsonFragment(['nama' => 'Edit oleh Admin']);
|
||||
}
|
||||
|
||||
public function test_pengurus_cannot_delete_others_ibadah(): void
|
||||
{
|
||||
$pengurus = $this->createUser('pengurus_ibadah');
|
||||
$admin = $this->createUser('administrator');
|
||||
$ibadah = $this->createIbadah($admin->id);
|
||||
$this->actingAs($pengurus);
|
||||
|
||||
$this->deleteJson("/api/ibadah/{$ibadah->id}")->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_pengurus_can_delete_own_ibadah(): void
|
||||
{
|
||||
$pengurus = $this->createUser('pengurus_ibadah');
|
||||
$ibadah = $this->createIbadah($pengurus->id);
|
||||
$this->actingAs($pengurus);
|
||||
|
||||
$this->deleteJson("/api/ibadah/{$ibadah->id}")->assertOk();
|
||||
$this->assertNull(RumahIbadah::find($ibadah->id));
|
||||
}
|
||||
|
||||
public function test_petugas_can_update_all_miskin_fields(): void
|
||||
{
|
||||
$petugas = $this->createUser('petugas_pendataan');
|
||||
$this->actingAs($petugas);
|
||||
|
||||
$this->postJson('/api/miskin', [
|
||||
'id_rumah' => 'TESTID001',
|
||||
'nama_kk' => 'Budi Santoso',
|
||||
'latitude' => -7.0,
|
||||
'longitude' => 110.0,
|
||||
'jumlah_kk' => 1,
|
||||
'jumlah_orang' => 4,
|
||||
])->assertOk();
|
||||
|
||||
$this->putJson('/api/miskin/TESTID001', [
|
||||
'nama_kk' => 'Budi Diperbarui',
|
||||
'latitude' => -7.0,
|
||||
'longitude' => 110.0,
|
||||
'jumlah_kk' => 2,
|
||||
'jumlah_orang' => 6,
|
||||
'pekerjaan' => 'Buruh',
|
||||
'pendapatan' => 1500000,
|
||||
'kondisi_rumah' => 'buruk',
|
||||
'keterangan' => 'Catatan baru',
|
||||
])->assertOk()->assertJsonFragment(['nama_kk' => 'Budi Diperbarui']);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
// use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_unauthenticated_root_redirects_to_login(): void
|
||||
{
|
||||
$response = $this->get('/');
|
||||
|
||||
$response->assertRedirect('/login');
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\RumahIbadah;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ExportTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
foreach (['administrator', 'pengurus_ibadah', 'petugas_pendataan', 'pemangku_kebijakan'] as $role) {
|
||||
\Spatie\Permission\Models\Role::create(['name' => $role, 'guard_name' => 'web']);
|
||||
}
|
||||
}
|
||||
|
||||
private function createUser(string $role): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole($role);
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function test_unauthenticated_cannot_export_pdf(): void
|
||||
{
|
||||
$this->get('/export/pdf')->assertRedirect('/login');
|
||||
}
|
||||
|
||||
public function test_authenticated_can_export_all_miskin_pdf(): void
|
||||
{
|
||||
$this->actingAs($this->createUser('pemangku_kebijakan'));
|
||||
|
||||
$response = $this->get('/export/pdf');
|
||||
$response->assertOk();
|
||||
$response->assertHeader('content-type', 'application/pdf');
|
||||
}
|
||||
|
||||
public function test_radius_pdf_returns_404_for_invalid_ibadah(): void
|
||||
{
|
||||
$this->actingAs($this->createUser('administrator'));
|
||||
|
||||
$this->get('/export/pdf/radius/99999/500')->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_radius_pdf_returns_pdf_for_valid_ibadah(): void
|
||||
{
|
||||
$admin = $this->createUser('administrator');
|
||||
$this->actingAs($admin);
|
||||
|
||||
$ibadah = RumahIbadah::create([
|
||||
'user_id' => $admin->id,
|
||||
'nama' => 'Test Masjid',
|
||||
'jenis' => 'masjid',
|
||||
'latitude' => -7.0,
|
||||
'longitude' => 110.0,
|
||||
'radius' => 500,
|
||||
]);
|
||||
|
||||
$response = $this->get("/export/pdf/radius/{$ibadah->id}/500");
|
||||
$response->assertOk();
|
||||
$response->assertHeader('content-type', 'application/pdf');
|
||||
}
|
||||
|
||||
public function test_radius_pdf_rejects_non_numeric_params(): void
|
||||
{
|
||||
$this->actingAs($this->createUser('administrator'));
|
||||
|
||||
$this->get('/export/pdf/radius/abc/def')->assertNotFound();
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\RumahIbadah;
|
||||
use App\Models\RumahMiskin;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Tests\TestCase;
|
||||
|
||||
class Plan7Test extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
foreach (['administrator', 'pengurus_ibadah', 'petugas_pendataan', 'pemangku_kebijakan'] as $role) {
|
||||
\Spatie\Permission\Models\Role::create(['name' => $role, 'guard_name' => 'web']);
|
||||
}
|
||||
}
|
||||
|
||||
private function createUser(string $role): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole($role);
|
||||
return $user;
|
||||
}
|
||||
|
||||
// ── STATISTIK ────────────────────────────────────────────────
|
||||
public function test_admin_can_get_stats(): void
|
||||
{
|
||||
$admin = $this->createUser('administrator');
|
||||
$this->actingAs($admin);
|
||||
ActivityLog::record('login', 'Test');
|
||||
|
||||
$res = $this->getJson('/api/admin/stats')->assertOk();
|
||||
$res->assertJsonStructure(['logins_today', 'logins_week', 'logins_month', 'actions_today', 'by_type', 'top_users', 'daily']);
|
||||
$this->assertGreaterThanOrEqual(1, $res->json('actions_today'));
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_get_stats(): void
|
||||
{
|
||||
$this->actingAs($this->createUser('petugas_pendataan'));
|
||||
$this->getJson('/api/admin/stats')->assertStatus(403);
|
||||
}
|
||||
|
||||
// ── NOTIFIKASI ────────────────────────────────────────────────
|
||||
public function test_authenticated_user_can_poll_notifications(): void
|
||||
{
|
||||
$admin = $this->createUser('administrator');
|
||||
$this->actingAs($admin);
|
||||
|
||||
RumahIbadah::create(['user_id' => $admin->id, 'nama' => 'Test', 'latitude' => -7, 'longitude' => 110, 'radius' => 500]);
|
||||
|
||||
$since = now()->subMinutes(5)->toISOString();
|
||||
$res = $this->getJson('/api/notifications?since=' . urlencode($since))->assertOk();
|
||||
$res->assertJsonStructure(['new_ibadah', 'new_miskin', 'latest_ibadah', 'latest_miskin']);
|
||||
$this->assertEquals(1, $res->json('new_ibadah'));
|
||||
}
|
||||
|
||||
public function test_unauthenticated_cannot_poll_notifications(): void
|
||||
{
|
||||
$this->get('/api/notifications')->assertRedirect('/login');
|
||||
}
|
||||
|
||||
// ── BACKUP LIST ───────────────────────────────────────────────
|
||||
public function test_admin_can_list_backups(): void
|
||||
{
|
||||
$this->actingAs($this->createUser('administrator'));
|
||||
$this->getJson('/api/admin/backup/list')->assertOk()->assertJsonIsArray();
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_list_backups(): void
|
||||
{
|
||||
$this->actingAs($this->createUser('pengurus_ibadah'));
|
||||
$this->getJson('/api/admin/backup/list')->assertStatus(403);
|
||||
}
|
||||
|
||||
// ── RESTORE ───────────────────────────────────────────────────
|
||||
public function test_restore_requires_confirm_word(): void
|
||||
{
|
||||
$this->actingAs($this->createUser('administrator'));
|
||||
$file = UploadedFile::fake()->createWithContent('backup.sql', '-- test sql');
|
||||
$this->post('/api/admin/restore', ['file' => $file, 'confirm' => 'WRONG'])->assertStatus(302);
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_restore(): void
|
||||
{
|
||||
$this->actingAs($this->createUser('petugas_pendataan'));
|
||||
$file = UploadedFile::fake()->createWithContent('backup.sql', '-- test');
|
||||
$this->post('/api/admin/restore', ['file' => $file, 'confirm' => 'RESTORE'])->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_unauthenticated_cannot_restore(): void
|
||||
{
|
||||
$file = UploadedFile::fake()->createWithContent('backup.sql', '-- test');
|
||||
$this->post('/api/admin/restore', ['file' => $file, 'confirm' => 'RESTORE'])->assertRedirect('/login');
|
||||
}
|
||||
|
||||
// ── BACKUP COMMAND ────────────────────────────────────────────
|
||||
public function test_backup_database_command_runs_successfully(): void
|
||||
{
|
||||
$this->artisan('backup:database')->assertSuccessful();
|
||||
|
||||
$files = glob(storage_path('app/backups/backup-webgis-*.sql'));
|
||||
$this->assertNotEmpty($files);
|
||||
foreach ($files as $f) unlink($f);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_that_true_is_true(): void
|
||||
{
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
+2
-7
@@ -1,18 +1,13 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import laravel from 'laravel-vite-plugin';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
laravel({
|
||||
input: ['resources/css/app.css', 'resources/js/app.js'],
|
||||
input: ['resources/css/app.css'],
|
||||
refresh: true,
|
||||
}),
|
||||
tailwindcss(),
|
||||
],
|
||||
server: {
|
||||
watch: {
|
||||
ignored: ['**/storage/framework/views/**'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user