Compare commits
15 Commits
66cfbbd523
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4907ff7ef8 | |||
| 4e19f27522 | |||
| 1b3031a1ab | |||
| 70d6efdec6 | |||
| 2f8b14ed2a | |||
| 69b03fb467 | |||
| 056520d48f | |||
| 18c0e1d489 | |||
| db5b20c572 | |||
| 024873cfb7 | |||
| f4b9501d50 | |||
| d85d066f6c | |||
| c4157cec8d | |||
| 91fc2fbd16 | |||
| cdba16e9e5 |
@@ -0,0 +1,15 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.docker.example
|
||||||
|
node_modules
|
||||||
|
vendor
|
||||||
|
public/build
|
||||||
|
storage/logs/*.log
|
||||||
|
storage/framework/cache/data/*
|
||||||
|
storage/framework/sessions/*
|
||||||
|
storage/framework/views/*
|
||||||
|
bootstrap/cache/*.php
|
||||||
|
database/*.sqlite
|
||||||
|
docs/superpowers
|
||||||
+10
-16
@@ -1,18 +1,14 @@
|
|||||||
APP_NAME=Laravel
|
APP_NAME="WebGIS Miskin"
|
||||||
APP_ENV=local
|
APP_ENV=production
|
||||||
APP_KEY=base64:LT9slFZW4kFTXKm807LCwYoCFDx97HdCQZ1kf1HpvqQ=
|
APP_KEY=
|
||||||
APP_DEBUG=true
|
APP_DEBUG=false
|
||||||
APP_URL=http://localhost
|
APP_URL=http://vwkgokg048o8gwok4sgko488.203.24.51.230.sslip.io
|
||||||
|
|
||||||
APP_LOCALE=en
|
APP_LOCALE=en
|
||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
APP_FAKER_LOCALE=en_US
|
APP_FAKER_LOCALE=en_US
|
||||||
|
|
||||||
APP_MAINTENANCE_DRIVER=file
|
APP_MAINTENANCE_DRIVER=file
|
||||||
# APP_MAINTENANCE_STORE=database
|
|
||||||
|
|
||||||
# PHP_CLI_SERVER_WORKERS=4
|
|
||||||
|
|
||||||
BCRYPT_ROUNDS=12
|
BCRYPT_ROUNDS=12
|
||||||
|
|
||||||
LOG_CHANNEL=stack
|
LOG_CHANNEL=stack
|
||||||
@@ -21,11 +17,11 @@ LOG_DEPRECATIONS_CHANNEL=null
|
|||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
DB_CONNECTION=mysql
|
DB_CONNECTION=mysql
|
||||||
DB_HOST=mysql
|
DB_HOST=sokgg4gook0w8c8484wck4ss
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
DB_DATABASE=webgis_miskin
|
DB_DATABASE=default
|
||||||
DB_USERNAME=webgis
|
DB_USERNAME=mysql
|
||||||
DB_PASSWORD=secret
|
DB_PASSWORD=N2eFvS3vqx4rTv4jQIqcA0qmom0gQROnnvkYm4PtGsVRFiPPAFQJrRzgBYiYPclV
|
||||||
|
|
||||||
SESSION_DRIVER=database
|
SESSION_DRIVER=database
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
@@ -36,14 +32,12 @@ SESSION_DOMAIN=null
|
|||||||
BROADCAST_CONNECTION=log
|
BROADCAST_CONNECTION=log
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
QUEUE_CONNECTION=database
|
QUEUE_CONNECTION=database
|
||||||
|
|
||||||
CACHE_STORE=database
|
CACHE_STORE=database
|
||||||
# CACHE_PREFIX=
|
|
||||||
|
|
||||||
MEMCACHED_HOST=127.0.0.1
|
MEMCACHED_HOST=127.0.0.1
|
||||||
|
|
||||||
REDIS_CLIENT=phpredis
|
REDIS_CLIENT=phpredis
|
||||||
REDIS_HOST=127.0.0.1
|
REDIS_HOST=redis
|
||||||
REDIS_PASSWORD=null
|
REDIS_PASSWORD=null
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|
||||||
+6
-6
@@ -20,12 +20,12 @@ LOG_STACK=single
|
|||||||
LOG_DEPRECATIONS_CHANNEL=null
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
DB_CONNECTION=sqlite
|
DB_CONNECTION=mysql
|
||||||
# DB_HOST=127.0.0.1
|
DB_HOST=mysql
|
||||||
# DB_PORT=3306
|
DB_PORT=3306
|
||||||
# DB_DATABASE=laravel
|
DB_DATABASE=webgis_miskin
|
||||||
# DB_USERNAME=root
|
DB_USERNAME=webgis
|
||||||
# DB_PASSWORD=
|
DB_PASSWORD=secret
|
||||||
|
|
||||||
SESSION_DRIVER=database
|
SESSION_DRIVER=database
|
||||||
SESSION_LIFETIME=120
|
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) |
|
|
||||||
|
|
||||||
+34
@@ -1,3 +1,28 @@
|
|||||||
|
FROM composer:2 AS vendor
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY composer.json composer.lock ./
|
||||||
|
|
||||||
|
# --no-scripts: artisan belum ada di stage ini, post-autoload-dump dijalankan di stage final
|
||||||
|
# tanpa --prefer-dist agar Composer bisa fallback ke git source saat unduhan dist gagal (mis. GitHub 504)
|
||||||
|
RUN composer install \
|
||||||
|
--no-dev \
|
||||||
|
--no-interaction \
|
||||||
|
--no-progress \
|
||||||
|
--no-scripts \
|
||||||
|
--no-autoloader
|
||||||
|
|
||||||
|
FROM node:22-alpine AS assets
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json vite.config.js ./
|
||||||
|
COPY resources ./resources
|
||||||
|
COPY public ./public
|
||||||
|
|
||||||
|
RUN npm ci && npm run build
|
||||||
|
|
||||||
FROM php:8.3-cli-bookworm
|
FROM php:8.3-cli-bookworm
|
||||||
|
|
||||||
WORKDIR /var/www/html
|
WORKDIR /var/www/html
|
||||||
@@ -23,7 +48,16 @@ COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
|||||||
COPY docker/app/php.ini /usr/local/etc/php/conf.d/99-webgis.ini
|
COPY docker/app/php.ini /usr/local/etc/php/conf.d/99-webgis.ini
|
||||||
COPY docker/app/entrypoint.sh /usr/local/bin/webgis-entrypoint
|
COPY docker/app/entrypoint.sh /usr/local/bin/webgis-entrypoint
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
COPY --from=vendor /app/vendor ./vendor
|
||||||
|
COPY --from=assets /app/public/build ./public/build
|
||||||
|
|
||||||
|
RUN composer dump-autoload --no-dev --optimize --no-interaction \
|
||||||
|
&& php artisan package:discover --ansi
|
||||||
|
|
||||||
RUN chmod +x /usr/local/bin/webgis-entrypoint
|
RUN chmod +x /usr/local/bin/webgis-entrypoint
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
ENTRYPOINT ["webgis-entrypoint"]
|
ENTRYPOINT ["webgis-entrypoint"]
|
||||||
CMD ["php", "artisan", "serve", "--host=0.0.0.0", "--port=8000"]
|
CMD ["php", "artisan", "serve", "--host=0.0.0.0", "--port=8000"]
|
||||||
|
|||||||
@@ -1,209 +1,207 @@
|
|||||||
# 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:
|
Repository ini sudah disiapkan untuk jalan lewat Docker Compose:
|
||||||
> **Setiap keluarga kurang mampu yang tinggal di sekitar rumah ibadah dianggap sebagai tanggungan atau prioritas utama untuk mendapatkan bantuan dari rumah ibadah tersebut.**
|
|
||||||
|
|
||||||
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
|
Image `app` sudah self-contained. Source code dan dependency Composer dibangun ke dalam image saat `docker compose build`, jadi tidak perlu bind mount proyek ke `/var/www/html` saat deploy production.
|
||||||
|
|
||||||
### 1. Peta Interaktif (Interactive WebGIS)
|
## SSL Di Coolify
|
||||||
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.
|
|
||||||
|
|
||||||
### 2. Manajemen Data (CRUD)
|
SSL tidak dipasang di container aplikasi. Untuk deploy di Coolify, HTTPS ditangani oleh reverse proxy Coolify dengan sertifikat Let's Encrypt.
|
||||||
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.
|
|
||||||
|
|
||||||
### 3. Dashboard Statistik & Gap Analysis
|
Di Coolify:
|
||||||
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.
|
|
||||||
|
|
||||||
### 5. Fitur Administrator Lanjutan
|
- set domain aplikasi dengan format `https://domain-anda.com`
|
||||||
- **Hapus Massal (Bulk Delete)**: Pilih beberapa baris via checkbox di tabel Data lalu hapus sekaligus (Administrator).
|
- arahkan domain ke service `app`
|
||||||
- **Import CSV**: Impor data Keluarga Miskin & Rumah Ibadah dari file CSV dengan format yang sama persis dengan hasil Ekspor CSV (round-trip).
|
- gunakan internal port `8000`
|
||||||
- **Log Aktivitas**: Setiap aksi penting (login, CRUD data, import, bulk delete, backup) dicatat dengan pengguna, deskripsi, IP, dan waktu.
|
- set `APP_URL=https://domain-anda.com` di environment
|
||||||
- **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.
|
|
||||||
|
|
||||||
### 4. Analisis Spasial Radius
|
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.
|
||||||
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.
|
|
||||||
|
|
||||||
## Arsitektur Teknis
|
## Kebutuhan Hosting
|
||||||
|
|
||||||
Proyek ini telah dimigrasikan ke framework **Laravel 13** dengan struktur sebagai berikut:
|
- Docker Engine
|
||||||
|
- Docker Compose plugin
|
||||||
|
- Domain atau subdomain yang diarahkan ke server
|
||||||
|
- Coolify reverse proxy yang diarahkan ke service `app` port internal `8000`
|
||||||
|
|
||||||
- **Backend**:
|
Document root tidak perlu diarahkan manual ke `public` karena aplikasi berjalan di container Laravel.
|
||||||
- **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.
|
|
||||||
|
|
||||||
## Cara Menjalankan Project
|
## Setup Pertama Di Server
|
||||||
|
|
||||||
### Prasyarat
|
1. Upload atau clone project ke server.
|
||||||
|
|
||||||
- **Docker Desktop** sudah terinstall dan berjalan.
|
2. Buat file environment Docker:
|
||||||
- Port **`8000`** (app), **`5173`** (Vite), dan **`3307`** (MySQL dari host) tidak dipakai aplikasi lain.
|
|
||||||
|
|
||||||
---
|
```bash
|
||||||
|
cp .env.docker.example .env
|
||||||
### 1. Jalankan Pertama Kali
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Saat container pertama kali dibuild dan dijalankan, entrypoint otomatis melakukan:
|
3. Edit `.env` minimal bagian ini:
|
||||||
|
|
||||||
| 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)
|
|
||||||
|
|
||||||
```env
|
```env
|
||||||
|
APP_ENV=production
|
||||||
|
APP_DEBUG=false
|
||||||
|
APP_URL=https://domain-anda.com
|
||||||
|
|
||||||
DB_CONNECTION=mysql
|
DB_CONNECTION=mysql
|
||||||
DB_HOST=mysql # nama service di docker-compose.yml
|
DB_HOST=mysql
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
DB_DATABASE=webgis_miskin
|
DB_DATABASE=webgis_miskin
|
||||||
DB_USERNAME=webgis
|
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.
|
```bash
|
||||||
2. **Peta Interaktif (`/map`)**: Antarmuka utama untuk melihat sebaran dan melakukan analisis radius.
|
npm install
|
||||||
3. **Data Tabular (`/data`)**: Manajemen data dalam format tabel dengan ringkasan statistik total.
|
npm run build
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
5. Build dan jalankan container:
|
||||||
*Dokumentasi ini dibuat untuk menjelaskan kapabilitas sistem WebGIS Pemetaan Kemiskinan versi Laravel.*
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Entrypoint container `app` otomatis menjalankan:
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
- Source code sudah masuk ke image saat build, jadi produksi tidak bergantung pada bind mount host ke `/var/www/html`.
|
||||||
|
- Jika hosting punya backup volume, backup volume MySQL dan folder project.
|
||||||
|
|||||||
+22
-4
@@ -1,9 +1,10 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="id" data-theme="light">
|
<html lang="id" data-theme="light">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>WebGIS Miskin & Ibadah</title>
|
<title>WebGIS Pemetaan Kemiskinan</title>
|
||||||
|
|
||||||
<!-- Leaflet -->
|
<!-- Leaflet -->
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||||
@@ -16,19 +17,31 @@
|
|||||||
<!-- Plus Jakarta Sans Font -->
|
<!-- Plus Jakarta Sans Font -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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>
|
<style>
|
||||||
* {
|
* {
|
||||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-weight: 500;
|
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;
|
font-weight: 700 !important;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
#bgMap {
|
#bgMap {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -39,11 +52,13 @@
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
filter: saturate(1.2) brightness(0.7);
|
filter: saturate(1.2) brightness(0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Glass effect for content */
|
/* Glass effect for content */
|
||||||
.hero-content {
|
.hero-content {
|
||||||
background: rgba(255, 255, 255, 0.7);
|
background: rgba(255, 255, 255, 0.7);
|
||||||
@@ -53,12 +68,14 @@
|
|||||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .hero-content {
|
[data-theme="dark"] .hero-content {
|
||||||
background: rgba(15, 23, 42, 0.7);
|
background: rgba(15, 23, 42, 0.7);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="min-h-screen bg-base-200 flex flex-col items-center justify-center overflow-hidden">
|
<body class="min-h-screen bg-base-200 flex flex-col items-center justify-center overflow-hidden">
|
||||||
|
|
||||||
<!-- Background Map -->
|
<!-- Background Map -->
|
||||||
@@ -68,7 +85,7 @@
|
|||||||
<div class="hero-content text-center flex-col gap-2">
|
<div class="hero-content text-center flex-col gap-2">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="text-7xl mb-2">🏘️</div>
|
<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">
|
<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.
|
analisis sebaran keluarga kurang mampu.
|
||||||
@@ -161,4 +178,5 @@
|
|||||||
}).catch(() => { });
|
}).catch(() => { });
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
+34
-9
@@ -1,9 +1,10 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="id" data-theme="light">
|
<html lang="id" data-theme="light">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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 -->
|
<!-- DaisyUI + Tailwind -->
|
||||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet">
|
||||||
@@ -13,14 +14,34 @@
|
|||||||
<!-- Plus Jakarta Sans Font -->
|
<!-- Plus Jakarta Sans Font -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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>
|
<style>
|
||||||
* { font-family: 'Plus Jakarta Sans', sans-serif; }
|
* {
|
||||||
body { font-weight: 500; background-color: #f8fafc; }
|
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||||
h1, h2, h3, h4, h5, h6, .btn, .card-title, .stat-value { font-weight: 700 !important; letter-spacing: -0.01em; }
|
}
|
||||||
|
|
||||||
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="min-h-screen">
|
<body class="min-h-screen">
|
||||||
|
|
||||||
<!-- ═══ NAVBAR ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══ NAVBAR ═══════════════════════════════════════════════════════════ -->
|
||||||
@@ -28,13 +49,14 @@
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<a href="../index.html" class="btn btn-ghost text-lg font-bold gap-2 text-primary">
|
<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>
|
<i class="fa-solid fa-house-chimney-crack"></i>
|
||||||
WebGIS Miskin & Ibadah
|
WebGIS Pemetaan Kemiskinan
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-none">
|
<div class="flex-none">
|
||||||
<ul class="menu menu-horizontal px-1 gap-1">
|
<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="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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,7 +74,8 @@
|
|||||||
<p class="text-base-content/50 mt-1">Daftar rumah ibadah dan keluarga miskin dalam matriks tabular.</p>
|
<p class="text-base-content/50 mt-1">Daftar rumah ibadah dan keluarga miskin dalam matriks tabular.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -77,7 +100,8 @@
|
|||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div role="tablist" class="tabs tabs-lifted tabs-lg mb-6">
|
<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 role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6 shadow-sm">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table table-zebra w-full" id="tableIbadah">
|
<table class="table table-zebra w-full" id="tableIbadah">
|
||||||
@@ -210,4 +234,5 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
+58
-16
@@ -1,9 +1,10 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="id" data-theme="light">
|
<html lang="id" data-theme="light">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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 -->
|
<!-- Leaflet -->
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||||
@@ -18,14 +19,45 @@
|
|||||||
<!-- Plus Jakarta Sans Font -->
|
<!-- Plus Jakarta Sans Font -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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>
|
<style>
|
||||||
* { font-family: 'Plus Jakarta Sans', sans-serif; }
|
* {
|
||||||
body { font-weight: 500; height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
|
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||||
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%; }
|
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 Controls */
|
||||||
.floating-panel {
|
.floating-panel {
|
||||||
@@ -46,7 +78,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Marker dragging hint */
|
/* Marker dragging hint */
|
||||||
#dragHint, #mapHint {
|
#dragHint,
|
||||||
|
#mapHint {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 24px;
|
bottom: 24px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@@ -65,6 +98,7 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- ═══ NAVBAR ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══ NAVBAR ═══════════════════════════════════════════════════════════ -->
|
||||||
@@ -72,7 +106,7 @@
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<a href="../index.html" class="btn btn-ghost text-lg font-bold gap-2 text-primary">
|
<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>
|
<i class="fa-solid fa-house-chimney-crack"></i>
|
||||||
WebGIS Miskin & Ibadah
|
WebGIS Pemetaan Kemiskinan
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-none">
|
<div class="flex-none">
|
||||||
@@ -104,13 +138,16 @@
|
|||||||
Mode Operasi
|
Mode Operasi
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid grid-cols-1 gap-2">
|
<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
|
<i class="fa-solid fa-eye"></i> View & Analisis
|
||||||
</button>
|
</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
|
<i class="fa-solid fa-plus"></i> Tambah Rumah Ibadah
|
||||||
</button>
|
</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
|
<i class="fa-solid fa-plus"></i> Tambah Rumah Miskin
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,7 +172,8 @@
|
|||||||
<label class="label py-1">
|
<label class="label py-1">
|
||||||
<span class="label-text-alt font-bold">Radius: <span id="radiusVal">500</span>m</span>
|
<span class="label-text-alt font-bold">Radius: <span id="radiusVal">500</span>m</span>
|
||||||
</label>
|
</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>
|
||||||
|
|
||||||
<div class="grid grid-cols-3 gap-2">
|
<div class="grid grid-cols-3 gap-2">
|
||||||
@@ -179,11 +217,13 @@
|
|||||||
<div class="bg-base-200 p-2 rounded text-xs font-mono" id="ib_coords">Lat: -, Lng: -</div>
|
<div class="bg-base-200 p-2 rounded text-xs font-mono" id="ib_coords">Lat: -, Lng: -</div>
|
||||||
<label class="form-control w-full">
|
<label class="form-control w-full">
|
||||||
<div class="label"><span class="label-text font-bold">Nama Rumah Ibadah</span></div>
|
<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>
|
||||||
<label class="form-control w-full">
|
<label class="form-control w-full">
|
||||||
<div class="label"><span class="label-text font-bold">Alamat</span></div>
|
<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>
|
||||||
<label class="form-control w-full">
|
<label class="form-control w-full">
|
||||||
<div class="label"><span class="label-text font-bold">Radius Default Analysis (Meter)</span></div>
|
<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>
|
<div class="bg-base-200 p-2 rounded text-xs font-mono" id="rm_coords">Lat: -, Lng: -</div>
|
||||||
<label class="form-control w-full">
|
<label class="form-control w-full">
|
||||||
<div class="label"><span class="label-text font-bold">Alamat</span></div>
|
<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>
|
||||||
<label class="form-control w-full">
|
<label class="form-control w-full">
|
||||||
<div class="label"><span class="label-text font-bold">ID Rumah / Nama Pemilik</span></div>
|
<div class="label"><span class="label-text font-bold">ID Rumah / Nama Pemilik</span></div>
|
||||||
@@ -659,4 +700,5 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -23,6 +23,25 @@ class BackupDatabaseCommand extends Command
|
|||||||
$filename = 'backup-webgis-' . now()->format('Ymd-His') . '.sql';
|
$filename = 'backup-webgis-' . now()->format('Ymd-His') . '.sql';
|
||||||
$path = $dir . '/' . $filename;
|
$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([
|
$process = new Process([
|
||||||
'mysqldump',
|
'mysqldump',
|
||||||
'-h', $db['host'],
|
'-h', $db['host'],
|
||||||
|
|||||||
@@ -9,6 +9,23 @@ class BackupController extends Controller
|
|||||||
{
|
{
|
||||||
public function download()
|
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');
|
$db = config('database.connections.mysql');
|
||||||
|
|
||||||
$process = new Process([
|
$process = new Process([
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use App\Models\ActivityLog;
|
use App\Models\ActivityLog;
|
||||||
use App\Models\RiwayatBantuan;
|
use App\Models\RiwayatBantuan;
|
||||||
|
use App\Models\RumahIbadah;
|
||||||
use App\Models\RumahMiskin;
|
use App\Models\RumahMiskin;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
@@ -36,6 +37,18 @@ class BantuanController extends Controller
|
|||||||
'tanggal_bantuan' => 'required|date',
|
'tanggal_bantuan' => 'required|date',
|
||||||
'ibadah_id' => 'nullable|integer|exists:rumah_ibadah,id',
|
'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([
|
$bantuan = RiwayatBantuan::create([
|
||||||
'id_rumah' => $idRumah,
|
'id_rumah' => $idRumah,
|
||||||
'dicatat_oleh' => auth()->id(),
|
'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
|
public function getUmurAttribute(): ?int
|
||||||
{
|
{
|
||||||
return $this->tanggal_lahir
|
return $this->tanggal_lahir
|
||||||
? now()->diffInYears(Carbon::parse($this->tanggal_lahir))
|
? (int) Carbon::parse($this->tanggal_lahir)->diffInYears(now())
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Models\PermohonanTransfer;
|
||||||
|
use App\Models\RumahIbadah;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use Illuminate\Support\Facades\View;
|
use Illuminate\Support\Facades\View;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
@@ -18,6 +20,27 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
$view->with('appSettings', []);
|
$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',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
|
$middleware->trustProxies(at: '*');
|
||||||
|
|
||||||
$middleware->alias([
|
$middleware->alias([
|
||||||
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
|
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
|
||||||
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
|
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: webgis_miskin_app
|
||||||
|
working_dir: /var/www/html
|
||||||
|
expose:
|
||||||
|
- "8000"
|
||||||
|
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_node_modules:
|
||||||
|
webgis_mysql_data:
|
||||||
@@ -8,15 +8,32 @@ if [ ! -f .env ] && [ -f .env.docker.example ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -f vendor/autoload.php ]; then
|
if [ ! -f vendor/autoload.php ]; then
|
||||||
composer install
|
echo "vendor/autoload.php is missing. Rebuild the image so Composer dependencies are baked in."
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
php artisan config:clear --no-ansi
|
php artisan config:clear --no-ansi
|
||||||
|
|
||||||
if ! grep -q '^APP_KEY=base64:' .env 2>/dev/null; then
|
# Coolify menyuntik APP_KEY lewat environment variable, jadi key:generate
|
||||||
|
# hanya perlu jalan kalau key tidak ada di env maupun .env
|
||||||
|
if [ -z "${APP_KEY:-}" ] && ! grep -q '^APP_KEY=base64:' .env 2>/dev/null; then
|
||||||
php artisan key:generate --force --no-ansi
|
php artisan key:generate --force --no-ansi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
DB_WAIT_HOST="${DB_HOST:-mysql}"
|
||||||
|
DB_WAIT_PORT="${DB_PORT:-3306}"
|
||||||
|
echo "Waiting for database at ${DB_WAIT_HOST}:${DB_WAIT_PORT}..."
|
||||||
|
tries=0
|
||||||
|
until mysqladmin ping -h "$DB_WAIT_HOST" -P "$DB_WAIT_PORT" --silent 2>/dev/null; do
|
||||||
|
tries=$((tries + 1))
|
||||||
|
if [ "$tries" -ge 30 ]; then
|
||||||
|
echo "Database at ${DB_WAIT_HOST}:${DB_WAIT_PORT} is unreachable after ${tries} attempts."
|
||||||
|
echo "Check that DB_HOST points to a reachable MySQL host on the same Docker network."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
php artisan migrate --force --no-ansi
|
php artisan migrate --force --no-ansi
|
||||||
php artisan db:seed --force --no-ansi
|
php artisan db:seed --force --no-ansi
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
@source '../**/*.js';
|
||||||
|
|
||||||
@theme {
|
@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';
|
'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; }
|
body { font-weight: 500; background-color: #f8fafc; }
|
||||||
h1, h2, h3, .btn { font-weight: 700 !important; letter-spacing: -0.01em; }
|
h1, h2, h3, .btn { font-weight: 700 !important; letter-spacing: -0.01em; }
|
||||||
</style>
|
</style>
|
||||||
|
<link rel="stylesheet" href="{{ asset('css/genesis.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen">
|
<body class="min-h-screen app-body">
|
||||||
<div class="navbar bg-base-100 border-b border-base-200 shadow-sm sticky top-0 z-[1000]">
|
@include('partials.sidebar')
|
||||||
<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>
|
|
||||||
|
|
||||||
<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">
|
<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>
|
<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>
|
<p class="text-base-content/50 mt-1">200 aktivitas terakhir pengguna sistem.</p>
|
||||||
@@ -97,6 +72,7 @@
|
|||||||
<tbody id="bodyLogs"></tbody>
|
<tbody id="bodyLogs"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div class="toast toast-end toast-bottom z-[9999]" id="toastBox"></div>
|
<div class="toast toast-end toast-bottom z-[9999]" id="toastBox"></div>
|
||||||
|
|||||||
@@ -14,38 +14,13 @@
|
|||||||
body { font-weight: 500; background-color: #f8fafc; }
|
body { font-weight: 500; background-color: #f8fafc; }
|
||||||
h1, h2, h3, .btn { font-weight: 700 !important; letter-spacing: -0.01em; }
|
h1, h2, h3, .btn { font-weight: 700 !important; letter-spacing: -0.01em; }
|
||||||
</style>
|
</style>
|
||||||
|
<link rel="stylesheet" href="{{ asset('css/genesis.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen">
|
<body class="min-h-screen app-body">
|
||||||
<div class="navbar bg-base-100 border-b border-base-200 shadow-sm sticky top-0 z-[1000]">
|
@include('partials.sidebar')
|
||||||
<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>
|
|
||||||
|
|
||||||
<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">
|
<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>
|
<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>
|
<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">
|
<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>
|
<p class="text-center py-8 opacity-30 italic" id="settingsLoading">Memuat pengaturan...</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div class="toast toast-end toast-bottom z-[9999]" id="toastBox"></div>
|
<div class="toast toast-end toast-bottom z-[9999]" id="toastBox"></div>
|
||||||
|
|||||||
@@ -16,82 +16,15 @@
|
|||||||
body { font-weight: 500; background-color: #f8fafc; }
|
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; }
|
h1, h2, h3, h4, h5, h6, .btn, .card-title, .stat-value { font-weight: 700 !important; letter-spacing: -0.01em; }
|
||||||
</style>
|
</style>
|
||||||
|
<link rel="stylesheet" href="{{ asset('css/genesis.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen">
|
<body class="min-h-screen app-body">
|
||||||
|
|
||||||
<!-- ═══ NAVBAR ═══════════════════════════════════════════════════════════ -->
|
@include('partials.sidebar')
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- ═══ MAIN CONTENT ════════════════════════════════════════════════════ -->
|
<!-- ═══ 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 -->
|
<!-- Header -->
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4 mb-8">
|
<div class="flex flex-wrap items-center justify-between gap-4 mb-8">
|
||||||
@@ -159,6 +92,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- ═══ MODALS ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══ 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">
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
<style>
|
<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 {
|
#bgMap {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0; left: 0;
|
top: 0; left: 0;
|
||||||
width: 100%; height: 100%;
|
width: 100%; height: 100%;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
opacity: 0.5;
|
opacity: 0.62;
|
||||||
filter: saturate(1.2) brightness(0.7);
|
filter: saturate(0.82) brightness(0.96) contrast(0.9);
|
||||||
}
|
}
|
||||||
.login-container {
|
.login-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
.glass-card {
|
.login-backdrop {
|
||||||
background: rgba(255, 255, 255, 0.92);
|
position: fixed;
|
||||||
backdrop-filter: blur(16px);
|
inset: 0;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
z-index: 1;
|
||||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.2);
|
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>
|
</style>
|
||||||
|
<link rel="stylesheet" href="{{ asset('css/genesis.css') }}">
|
||||||
</head>
|
</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 -->
|
<!-- Background Map -->
|
||||||
<div id="bgMap"></div>
|
<div id="bgMap"></div>
|
||||||
|
<div class="login-backdrop"></div>
|
||||||
|
|
||||||
<div class="login-container w-full max-w-sm">
|
<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-center mb-7">
|
||||||
<div class="text-5xl mb-3">🏘️</div>
|
<div class="login-mark mx-auto mb-4">GIS</div>
|
||||||
<h1 class="text-xl font-bold text-primary leading-tight">WebGIS Pemetaan Kemiskinan</h1>
|
<p class="genesis-overline mb-2">Akses operator</p>
|
||||||
<p class="text-sm text-base-content/50 mt-1">Masuk ke akun Anda untuk melanjutkan</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>
|
</div>
|
||||||
|
|
||||||
@if($errors->any())
|
@if($errors->any())
|
||||||
|
|||||||
@@ -18,84 +18,15 @@
|
|||||||
body { font-weight: 500; background-color: #f8fafc; }
|
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; }
|
h1, h2, h3, h4, h5, h6, .btn, .card-title, .stat-value { font-weight: 700 !important; letter-spacing: -0.01em; }
|
||||||
</style>
|
</style>
|
||||||
|
<link rel="stylesheet" href="{{ asset('css/genesis.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen">
|
<body class="min-h-screen app-body">
|
||||||
|
|
||||||
<!-- ═══ NAVBAR ═══════════════════════════════════════════════════════════ -->
|
@include('partials.sidebar')
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- ═══ MAIN CONTENT ════════════════════════════════════════════════════ -->
|
<!-- ═══ 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 -->
|
<!-- Header -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
@@ -131,6 +62,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Charts Row -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
<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">
|
<div class="bg-white rounded-2xl shadow-sm border border-base-200 p-6">
|
||||||
@@ -143,6 +102,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Gap Analysis Section -->
|
||||||
<div class="bg-white rounded-2xl shadow-sm border border-base-200 p-6 mb-8">
|
<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">
|
<h2 class="text-lg font-bold flex items-center gap-2 mb-6">
|
||||||
@@ -210,6 +185,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Toast Container -->
|
<!-- Toast Container -->
|
||||||
@@ -231,19 +207,22 @@
|
|||||||
return turf.distance(p1, p2, { units: 'meters' }) <= radius;
|
return turf.distance(p1, p2, { units: 'meters' }) <= radius;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ibadahData = [], miskinData = [];
|
let ibadahData = [], miskinData = [], dosenSummary = null;
|
||||||
let chartJenis = null, chartKondisi = null, chartGap = null;
|
let chartJenis = null, chartKondisi = null, chartGap = null;
|
||||||
|
let chartPendidikan = null, chartBantuanJenis = null, chartTransfer = null;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', loadDashboard);
|
document.addEventListener('DOMContentLoaded', loadDashboard);
|
||||||
|
|
||||||
async function loadDashboard() {
|
async function loadDashboard() {
|
||||||
try {
|
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/ibadah') }}').then(r => r.json()),
|
||||||
fetch('{{ url('/api/miskin') }}').then(r => r.json()),
|
fetch('{{ url('/api/miskin') }}').then(r => r.json()),
|
||||||
|
fetch('{{ url('/api/dashboard/dosen-summary') }}').then(r => r.json()),
|
||||||
]);
|
]);
|
||||||
ibadahData = ri;
|
ibadahData = ri;
|
||||||
miskinData = rm;
|
miskinData = rm;
|
||||||
|
dosenSummary = ds;
|
||||||
renderDashboard();
|
renderDashboard();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast('Gagal memuat data', 'error');
|
showToast('Gagal memuat data', 'error');
|
||||||
@@ -256,6 +235,7 @@
|
|||||||
document.getElementById('statTotalIbadah').textContent = ibadahData.length;
|
document.getElementById('statTotalIbadah').textContent = ibadahData.length;
|
||||||
document.getElementById('statTotalMiskin').textContent = miskinData.length;
|
document.getElementById('statTotalMiskin').textContent = miskinData.length;
|
||||||
document.getElementById('statTotalJiwa').textContent = totalJiwa;
|
document.getElementById('statTotalJiwa').textContent = totalJiwa;
|
||||||
|
renderDosenSummary();
|
||||||
|
|
||||||
// ── 2. GAP ANALYSIS ──────────────────────────────────────
|
// ── 2. GAP ANALYSIS ──────────────────────────────────────
|
||||||
const coveredSet = new Set();
|
const coveredSet = new Set();
|
||||||
@@ -388,7 +368,7 @@
|
|||||||
<td class="capitalize text-xs">${esc(m.kondisi_rumah || '-')}</td>
|
<td class="capitalize text-xs">${esc(m.kondisi_rumah || '-')}</td>
|
||||||
<td class="text-center">${m.jumlah_orang || 0}</td>
|
<td class="text-center">${m.jumlah_orang || 0}</td>
|
||||||
</tr>`).join('')
|
</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) {
|
function renderOrUpdateChart(canvasId, existingChart, config, setter) {
|
||||||
@@ -397,15 +377,125 @@
|
|||||||
setter(new Chart(ctx, config));
|
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 ────────────────────────────────────────
|
// ── 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() {
|
(function initNotifPolling() {
|
||||||
const POLL_MS = 30000;
|
const POLL_MS = 30000;
|
||||||
const KEY_IBADAH = 'notif_latest_ibadah';
|
const KEY_SEEN = 'notif_latest_seen';
|
||||||
const KEY_MISKIN = 'notif_latest_miskin';
|
|
||||||
|
|
||||||
async function pollNotifications() {
|
async function pollNotifications() {
|
||||||
try {
|
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(
|
const res = await fetch(
|
||||||
`{{ url('/api/notifications') }}?since=${encodeURIComponent(since)}`,
|
`{{ url('/api/notifications') }}?since=${encodeURIComponent(since)}`,
|
||||||
{ headers: { 'X-CSRF-TOKEN': csrfToken } }
|
{ headers: { 'X-CSRF-TOKEN': csrfToken } }
|
||||||
@@ -413,19 +503,27 @@
|
|||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
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_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_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 (typeof loadDashboard !== 'undefined') loadDashboard();
|
||||||
}
|
}
|
||||||
if (data.latest_ibadah) localStorage.setItem(KEY_IBADAH, data.latest_ibadah);
|
const latest = [data.latest_ibadah, data.latest_miskin, data.latest_anggota, data.latest_bantuan, data.latest_transfer]
|
||||||
if (data.latest_miskin) localStorage.setItem(KEY_MISKIN, data.latest_miskin);
|
.filter(Boolean)
|
||||||
|
.sort()
|
||||||
|
.pop();
|
||||||
|
if (latest) localStorage.setItem(KEY_SEEN, latest);
|
||||||
} catch (e) { /* silent */ }
|
} catch (e) { /* silent */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
if (!localStorage.getItem(KEY_IBADAH)) localStorage.setItem(KEY_IBADAH, new Date().toISOString());
|
if (!localStorage.getItem(KEY_SEEN)) localStorage.setItem(KEY_SEEN, new Date().toISOString());
|
||||||
if (!localStorage.getItem(KEY_MISKIN)) localStorage.setItem(KEY_MISKIN, new Date().toISOString());
|
|
||||||
setInterval(pollNotifications, POLL_MS);
|
setInterval(pollNotifications, POLL_MS);
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
+126
-91
@@ -21,76 +21,15 @@
|
|||||||
body { font-weight: 500; background-color: #f8fafc; }
|
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; }
|
h1, h2, h3, h4, h5, h6, .btn, .card-title, .stat-value { font-weight: 700 !important; letter-spacing: -0.01em; }
|
||||||
</style>
|
</style>
|
||||||
|
<link rel="stylesheet" href="{{ asset('css/genesis.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen">
|
<body class="min-h-screen app-body">
|
||||||
|
|
||||||
<!-- ═══ NAVBAR ═══════════════════════════════════════════════════════════ -->
|
@include('partials.sidebar')
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- ═══ MAIN CONTENT ════════════════════════════════════════════════════ -->
|
<!-- ═══ 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 -->
|
<!-- Header -->
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4 mb-8">
|
<div class="flex flex-wrap items-center justify-between gap-4 mb-8">
|
||||||
@@ -145,9 +84,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div role="tablist" class="tabs tabs-lifted tabs-lg mb-6">
|
<div class="genesis-tabs">
|
||||||
<input type="radio" name="data_tabs" role="tab" class="tab font-bold" aria-label="🕌 Rumah Ibadah" checked />
|
<div class="genesis-tab-list" role="tablist" aria-label="Jenis data">
|
||||||
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6 shadow-sm">
|
<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'))
|
@if(auth()->user()->hasRole('administrator'))
|
||||||
<div id="bulkBar_ibadah" class="hidden alert alert-warning py-2 mb-2 flex items-center justify-between">
|
<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>
|
<span class="text-sm font-semibold"><span id="bulkCount_ibadah">0</span> item terpilih</span>
|
||||||
@@ -175,8 +122,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="radio" name="data_tabs" role="tab" class="tab font-bold" aria-label="🏠 Rumah Miskin" />
|
<div id="panel_miskin" role="tabpanel" aria-labelledby="tab_miskin" class="genesis-tab-panel hidden">
|
||||||
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6 shadow-sm">
|
|
||||||
@if(auth()->user()->hasRole('administrator'))
|
@if(auth()->user()->hasRole('administrator'))
|
||||||
<div id="bulkBar_miskin" class="hidden alert alert-warning py-2 mb-2 flex items-center justify-between">
|
<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>
|
<span class="text-sm font-semibold"><span id="bulkCount_miskin">0</span> item terpilih</span>
|
||||||
@@ -204,6 +150,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Modal: Edit Rumah Ibadah -->
|
<!-- Modal: Edit Rumah Ibadah -->
|
||||||
@@ -374,8 +321,9 @@
|
|||||||
@if(auth()->user()->hasRole('administrator') || auth()->user()->hasRole('petugas_pendataan'))
|
@if(auth()->user()->hasRole('administrator') || auth()->user()->hasRole('petugas_pendataan'))
|
||||||
<div class="collapse collapse-arrow border border-base-300 rounded-lg">
|
<div class="collapse collapse-arrow border border-base-300 rounded-lg">
|
||||||
<input type="checkbox" id="toggleFormAnggota">
|
<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">
|
<div class="collapse-content">
|
||||||
|
<input type="hidden" id="anggota_edit_id">
|
||||||
<div class="grid grid-cols-2 gap-3 mt-2">
|
<div class="grid grid-cols-2 gap-3 mt-2">
|
||||||
<div class="form-control col-span-2">
|
<div class="form-control col-span-2">
|
||||||
<label class="label"><span class="label-text font-bold">Nama</span></label>
|
<label class="label"><span class="label-text font-bold">Nama</span></label>
|
||||||
@@ -423,7 +371,10 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@@ -495,9 +446,21 @@
|
|||||||
let selectedMiskin = new Set();
|
let selectedMiskin = new Set();
|
||||||
|
|
||||||
let ibadahData = [], miskinData = [];
|
let ibadahData = [], miskinData = [];
|
||||||
|
let activeDataTab = 'ibadah';
|
||||||
document.addEventListener('DOMContentLoaded', loadAllData);
|
document.addEventListener('DOMContentLoaded', loadAllData);
|
||||||
const searchInput = document.getElementById('searchInput');
|
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() {
|
async function loadAllData() {
|
||||||
try {
|
try {
|
||||||
const [ri, rm] = await Promise.all([
|
const [ri, rm] = await Promise.all([
|
||||||
@@ -592,10 +555,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function exportCSV() {
|
function exportCSV() {
|
||||||
const activeTab = document.querySelector('input[name="data_tabs"]:checked').getAttribute('aria-label');
|
|
||||||
let csvContent = "data:text/csv;charset=utf-8,";
|
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";
|
csvContent += "ID,Nama,Jenis,Alamat,Radius,Jemaah,Pengurus,Telepon,Lat,Lon\n";
|
||||||
ibadahData.forEach(i => {
|
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`;
|
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 ───────────────────────────────────────────────────
|
// ── ANGGOTA KELUARGA ───────────────────────────────────────────────────
|
||||||
let currentAnggotaRumah = null;
|
let currentAnggotaRumah = null;
|
||||||
|
let anggotaData = [];
|
||||||
const canEditAnggota = {{ auth()->user()->hasRole('administrator') || auth()->user()->hasRole('petugas_pendataan') ? 'true' : 'false' }};
|
const canEditAnggota = {{ auth()->user()->hasRole('administrator') || auth()->user()->hasRole('petugas_pendataan') ? 'true' : 'false' }};
|
||||||
|
|
||||||
async function openAnggotaModal(idRumah) {
|
async function openAnggotaModal(idRumah) {
|
||||||
currentAnggotaRumah = idRumah;
|
currentAnggotaRumah = idRumah;
|
||||||
const rm = miskinData.find(m => m.id_rumah === idRumah);
|
const rm = miskinData.find(m => m.id_rumah === idRumah);
|
||||||
document.getElementById('anggota_rumah_label').textContent = `ID: ${idRumah} — ${rm?.nama_kk || '-'}`;
|
document.getElementById('anggota_rumah_label').textContent = `ID: ${idRumah} — ${rm?.nama_kk || '-'}`;
|
||||||
|
resetAnggotaForm();
|
||||||
document.getElementById('modal_anggota').showModal();
|
document.getElementById('modal_anggota').showModal();
|
||||||
await loadAnggota(idRumah);
|
await loadAnggota(idRumah);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAnggota(idRumah) {
|
async function loadAnggota(idRumah) {
|
||||||
const res = await fetch(`{{ url('/api/miskin') }}/${encodeURIComponent(idRumah)}/anggota`, { headers: { 'X-CSRF-TOKEN': csrfToken } });
|
const res = await fetch(`{{ url('/api/miskin') }}/${encodeURIComponent(idRumah)}/anggota`, { headers: { 'X-CSRF-TOKEN': csrfToken } });
|
||||||
const list = await res.json();
|
anggotaData = await res.json();
|
||||||
document.getElementById('bodyAnggota').innerHTML = list.length ? list.map(a => `
|
document.getElementById('bodyAnggota').innerHTML = anggotaData.length ? anggotaData.map(a => `
|
||||||
<tr>
|
<tr>
|
||||||
<td class="font-semibold">${esc(a.nama)}</td>
|
<td class="font-semibold">${esc(a.nama)}</td>
|
||||||
<td class="capitalize text-xs">${esc(a.hubungan.replace('_', ' '))}</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="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 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><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>`).join('')
|
||||||
: '<tr><td colspan="7" class="text-center py-4 opacity-30 italic">Belum ada anggota</td></tr>';
|
: '<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() {
|
async function simpanAnggota() {
|
||||||
|
const editId = document.getElementById('anggota_edit_id')?.value || '';
|
||||||
const body = {
|
const body = {
|
||||||
nama: document.getElementById('anggota_nama').value.trim(),
|
nama: document.getElementById('anggota_nama').value.trim(),
|
||||||
hubungan: document.getElementById('anggota_hubungan').value,
|
hubungan: document.getElementById('anggota_hubungan').value,
|
||||||
@@ -873,14 +870,22 @@
|
|||||||
status_hidup: document.getElementById('anggota_status').value,
|
status_hidup: document.getElementById('anggota_status').value,
|
||||||
};
|
};
|
||||||
if (!body.nama) { showToast('Nama wajib diisi', 'error'); return; }
|
if (!body.nama) { showToast('Nama wajib diisi', 'error'); return; }
|
||||||
const res = await fetch(`{{ url('/api/miskin') }}/${encodeURIComponent(currentAnggotaRumah)}/anggota`, {
|
const url = editId
|
||||||
method: 'POST',
|
? `{{ 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 },
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (json.success) { showToast('Anggota ditambahkan', 'success'); loadAnggota(currentAnggotaRumah); }
|
if (json.success) {
|
||||||
else showToast('Gagal menambah anggota', 'error');
|
showToast(editId ? 'Anggota diperbarui' : 'Anggota ditambahkan', 'success');
|
||||||
|
resetAnggotaForm();
|
||||||
|
loadAnggota(currentAnggotaRumah);
|
||||||
|
} else {
|
||||||
|
showToast(editId ? 'Gagal memperbarui anggota' : 'Gagal menambah anggota', 'error');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteAnggota(id) {
|
async function deleteAnggota(id) {
|
||||||
@@ -896,6 +901,8 @@
|
|||||||
// ── RIWAYAT BANTUAN ────────────────────────────────────────────────────
|
// ── RIWAYAT BANTUAN ────────────────────────────────────────────────────
|
||||||
let currentBantuanRumah = null;
|
let currentBantuanRumah = null;
|
||||||
const canDeleteBantuan = {{ auth()->user()->hasRole('administrator') ? 'true' : 'false' }};
|
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) {
|
async function openBantuanModal(idRumah) {
|
||||||
currentBantuanRumah = idRumah;
|
currentBantuanRumah = idRumah;
|
||||||
@@ -904,8 +911,11 @@
|
|||||||
|
|
||||||
const sel = document.getElementById('bantuan_ibadah');
|
const sel = document.getElementById('bantuan_ibadah');
|
||||||
if (sel) {
|
if (sel) {
|
||||||
|
const availableIbadah = isPengurusIbadah
|
||||||
|
? ibadahData.filter(ib => Number(ib.user_id) === currentUserId)
|
||||||
|
: ibadahData;
|
||||||
sel.innerHTML = '<option value="">-- Pilih (opsional) --</option>' +
|
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');
|
const tgl = document.getElementById('bantuan_tanggal');
|
||||||
@@ -958,14 +968,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── NOTIFIKASI PERUBAHAN DATA ────────────────────────────────────────
|
// ── 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() {
|
(function initNotifPolling() {
|
||||||
const POLL_MS = 30000;
|
const POLL_MS = 30000;
|
||||||
const KEY_IBADAH = 'notif_latest_ibadah';
|
const KEY_SEEN = 'notif_latest_seen';
|
||||||
const KEY_MISKIN = 'notif_latest_miskin';
|
|
||||||
|
|
||||||
async function pollNotifications() {
|
async function pollNotifications() {
|
||||||
try {
|
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(
|
const res = await fetch(
|
||||||
`{{ url('/api/notifications') }}?since=${encodeURIComponent(since)}`,
|
`{{ url('/api/notifications') }}?since=${encodeURIComponent(since)}`,
|
||||||
{ headers: { 'X-CSRF-TOKEN': csrfToken } }
|
{ headers: { 'X-CSRF-TOKEN': csrfToken } }
|
||||||
@@ -973,22 +1000,30 @@
|
|||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
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_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_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
|
const fn = typeof loadAllData !== 'undefined' ? loadAllData
|
||||||
: typeof loadData !== 'undefined' ? loadData
|
: typeof loadData !== 'undefined' ? loadData
|
||||||
: null;
|
: null;
|
||||||
if (fn) fn();
|
if (fn) fn();
|
||||||
}
|
}
|
||||||
if (data.latest_ibadah) localStorage.setItem(KEY_IBADAH, data.latest_ibadah);
|
const latest = [data.latest_ibadah, data.latest_miskin, data.latest_anggota, data.latest_bantuan, data.latest_transfer]
|
||||||
if (data.latest_miskin) localStorage.setItem(KEY_MISKIN, data.latest_miskin);
|
.filter(Boolean)
|
||||||
|
.sort()
|
||||||
|
.pop();
|
||||||
|
if (latest) localStorage.setItem(KEY_SEEN, latest);
|
||||||
} catch (e) { /* silent */ }
|
} catch (e) { /* silent */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
if (!localStorage.getItem(KEY_IBADAH)) localStorage.setItem(KEY_IBADAH, new Date().toISOString());
|
if (!localStorage.getItem(KEY_SEEN)) localStorage.setItem(KEY_SEEN, new Date().toISOString());
|
||||||
if (!localStorage.getItem(KEY_MISKIN)) localStorage.setItem(KEY_MISKIN, new Date().toISOString());
|
|
||||||
setInterval(pollNotifications, POLL_MS);
|
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">
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
* { font-family: 'Plus Jakarta Sans', sans-serif; }
|
body { height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
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-wrapper { flex: 1; position: relative; min-height: 0; }
|
||||||
#map { width: 100%; height: 100%; }
|
#map { width: 100%; height: 100%; }
|
||||||
|
|
||||||
/* Floating Controls */
|
/* Floating Controls */
|
||||||
.floating-panel {
|
.floating-panel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 20px;
|
top: 24px;
|
||||||
left: 20px;
|
left: 24px;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
width: 300px;
|
width: 316px;
|
||||||
max-height: calc(100vh - 100px);
|
max-height: calc(100vh - 100px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-card {
|
.map-panel {
|
||||||
background: rgba(255, 255, 255, 0.85);
|
background: rgba(255, 255, 255, 0.92);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
border: 1px solid #e8e8ec;
|
||||||
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.15);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Marker dragging hint */
|
/* Marker dragging hint */
|
||||||
@@ -57,102 +54,27 @@
|
|||||||
|
|
||||||
/* Custom Layer Control */
|
/* Custom Layer Control */
|
||||||
.leaflet-control-layers {
|
.leaflet-control-layers {
|
||||||
border-radius: 12px !important;
|
border-radius: 8px !important;
|
||||||
border: none !important;
|
border: 1px solid #e8e8ec !important;
|
||||||
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.15) !important;
|
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
background: rgba(255, 255, 255, 0.85) !important;
|
background: rgba(255, 255, 255, 0.92) !important;
|
||||||
padding: 8px 12px !important;
|
padding: 8px 12px !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<link rel="stylesheet" href="{{ asset('css/genesis.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="app-body app-map-body">
|
||||||
|
|
||||||
<!-- ═══ NAVBAR ═══════════════════════════════════════════════════════════ -->
|
@include('partials.sidebar')
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- ═══ MAP CONTAINER ════════════════════════════════════════════════════ -->
|
<!-- ═══ MAP CONTAINER ════════════════════════════════════════════════════ -->
|
||||||
<div class="map-wrapper">
|
<div class="map-wrapper app-map-main">
|
||||||
<div id="map"></div>
|
<div id="map"></div>
|
||||||
|
|
||||||
<!-- Floating Control Panel -->
|
<!-- Floating Control Panel -->
|
||||||
<div class="floating-panel flex flex-col gap-3">
|
<div class="floating-panel flex flex-col gap-3">
|
||||||
<!-- Mode Selector -->
|
<!-- 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">
|
<h3 class="text-sm font-bold mb-3 flex items-center gap-2">
|
||||||
<i class="fa-solid fa-layer-group text-primary"></i>
|
<i class="fa-solid fa-layer-group text-primary"></i>
|
||||||
Mode Operasi
|
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')">
|
<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
|
<i class="fa-solid fa-eye"></i> View & Analisis
|
||||||
</button>
|
</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
|
<i class="fa-solid fa-plus"></i> Tambah Rumah Ibadah
|
||||||
</button>
|
</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
|
<i class="fa-solid fa-plus"></i> Tambah Rumah Miskin
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Analysis Panel (Conditional) -->
|
<!-- 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">
|
<h3 class="text-sm font-bold mb-2 flex items-center gap-2">
|
||||||
<i class="fa-solid fa-chart-pie text-secondary"></i>
|
<i class="fa-solid fa-chart-pie text-secondary"></i>
|
||||||
Statistik Radius
|
Statistik Radius
|
||||||
@@ -219,11 +141,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hints -->
|
<!-- 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>
|
<i class="fa-solid fa-mouse-pointer"></i>
|
||||||
<span>Klik peta untuk menentukan lokasi baru</span>
|
<span>Klik peta untuk menentukan lokasi baru</span>
|
||||||
</div>
|
</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>
|
<i class="fa-solid fa-arrows-up-down-left-right"></i>
|
||||||
<span>Lepaskan untuk menyimpan lokasi baru</span>
|
<span>Lepaskan untuk menyimpan lokasi baru</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -855,7 +777,7 @@
|
|||||||
function buildPopupIbadah(item) {
|
function buildPopupIbadah(item) {
|
||||||
const canEditThis = canEditIbadah && (authUserRole === 'administrator' || item.user_id === authUserId);
|
const canEditThis = canEditIbadah && (authUserRole === 'administrator' || item.user_id === authUserId);
|
||||||
return `<div class="p-2 min-w-[200px]">
|
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="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="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">
|
<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) {
|
function buildPopupMiskin(item) {
|
||||||
return `<div class="p-2 min-w-[200px]">
|
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-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="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">
|
<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 ────────────────────────────────────────
|
// ── 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() {
|
(function initNotifPolling() {
|
||||||
const POLL_MS = 30000;
|
const POLL_MS = 30000;
|
||||||
const KEY_IBADAH = 'notif_latest_ibadah';
|
const KEY_SEEN = 'notif_latest_seen';
|
||||||
const KEY_MISKIN = 'notif_latest_miskin';
|
|
||||||
|
|
||||||
async function pollNotifications() {
|
async function pollNotifications() {
|
||||||
try {
|
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(
|
const res = await fetch(
|
||||||
`{{ url('/api/notifications') }}?since=${encodeURIComponent(since)}`,
|
`{{ url('/api/notifications') }}?since=${encodeURIComponent(since)}`,
|
||||||
{ headers: { 'X-CSRF-TOKEN': csrfToken } }
|
{ headers: { 'X-CSRF-TOKEN': csrfToken } }
|
||||||
@@ -1147,23 +1086,31 @@
|
|||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
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_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_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
|
const fn = typeof loadAllData !== 'undefined' ? loadAllData
|
||||||
: typeof loadData !== 'undefined' ? loadData
|
: typeof loadData !== 'undefined' ? loadData
|
||||||
: typeof loadDashboard !== 'undefined' ? loadDashboard
|
: typeof loadDashboard !== 'undefined' ? loadDashboard
|
||||||
: null;
|
: null;
|
||||||
if (fn) fn();
|
if (fn) fn();
|
||||||
}
|
}
|
||||||
if (data.latest_ibadah) localStorage.setItem(KEY_IBADAH, data.latest_ibadah);
|
const latest = [data.latest_ibadah, data.latest_miskin, data.latest_anggota, data.latest_bantuan, data.latest_transfer]
|
||||||
if (data.latest_miskin) localStorage.setItem(KEY_MISKIN, data.latest_miskin);
|
.filter(Boolean)
|
||||||
|
.sort()
|
||||||
|
.pop();
|
||||||
|
if (latest) localStorage.setItem(KEY_SEEN, latest);
|
||||||
} catch (e) { /* silent */ }
|
} catch (e) { /* silent */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
if (!localStorage.getItem(KEY_IBADAH)) localStorage.setItem(KEY_IBADAH, new Date().toISOString());
|
if (!localStorage.getItem(KEY_SEEN)) localStorage.setItem(KEY_SEEN, new Date().toISOString());
|
||||||
if (!localStorage.getItem(KEY_MISKIN)) localStorage.setItem(KEY_MISKIN, new Date().toISOString());
|
|
||||||
setInterval(pollNotifications, POLL_MS);
|
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; }
|
body { font-weight: 500; background-color: #f8fafc; }
|
||||||
h1, h2, h3, .btn { font-weight: 700 !important; letter-spacing: -0.01em; }
|
h1, h2, h3, .btn { font-weight: 700 !important; letter-spacing: -0.01em; }
|
||||||
</style>
|
</style>
|
||||||
|
<link rel="stylesheet" href="{{ asset('css/genesis.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen">
|
<body class="min-h-screen app-body">
|
||||||
<div class="navbar bg-base-100 border-b border-base-200 shadow-sm sticky top-0 z-[1000]">
|
@include('partials.sidebar')
|
||||||
<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>
|
|
||||||
|
|
||||||
<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 class="mb-8 flex items-start justify-between flex-wrap gap-4">
|
||||||
<div>
|
<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>
|
<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>
|
<tbody id="bodyPermohonan"><tr><td colspan="8" class="text-center py-10 opacity-30 italic">Memuat...</td></tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Modal Ajukan Permohonan -->
|
<!-- Modal Ajukan Permohonan -->
|
||||||
@@ -137,6 +111,8 @@
|
|||||||
|
|
||||||
const statusBadge = { pending: 'badge-warning', disetujui: 'badge-success', ditolak: 'badge-error' };
|
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 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 allPermohonan = [];
|
||||||
let ibadahList = [];
|
let ibadahList = [];
|
||||||
@@ -153,9 +129,13 @@
|
|||||||
ibadahList = await ibadahRes.json();
|
ibadahList = await ibadahRes.json();
|
||||||
miskinList = await miskinRes.json();
|
miskinList = await miskinRes.json();
|
||||||
|
|
||||||
const optIbadah = ibadahList.map(ib => `<option value="${ib.id}">${esc(ib.nama)}</option>`).join('');
|
const fromIbadah = isPengurusIbadah
|
||||||
document.getElementById('perm_dari').innerHTML = optIbadah;
|
? ibadahList.filter(ib => Number(ib.user_id) === currentUserId)
|
||||||
document.getElementById('perm_ke').innerHTML = optIbadah;
|
: 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
|
document.getElementById('perm_miskin').innerHTML = miskinList
|
||||||
.map(m => `<option value="${esc(m.id_rumah)}">${esc(m.id_rumah)} — ${esc(m.nama_kk || '-')}</option>`).join('');
|
.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">
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
<style>
|
<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 {
|
#bgMap {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -37,73 +27,141 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
opacity: 0.5;
|
opacity: 0.78;
|
||||||
filter: saturate(1.2) brightness(0.7);
|
filter: saturate(0.88) brightness(0.96) contrast(0.92);
|
||||||
}
|
}
|
||||||
.hero {
|
.landing-shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
background: transparent !important;
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
align-items: end;
|
||||||
|
padding: 96px 24px 32px;
|
||||||
}
|
}
|
||||||
/* Glass effect for content */
|
.landing-shell::before {
|
||||||
.hero-content {
|
content: "";
|
||||||
background: rgba(255, 255, 255, 0.7);
|
position: fixed;
|
||||||
backdrop-filter: blur(12px);
|
inset: 0;
|
||||||
padding: 3rem;
|
z-index: -1;
|
||||||
border-radius: 2rem;
|
background:
|
||||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
linear-gradient(90deg, rgba(250, 250, 250, 0.94) 0%, rgba(250, 250, 250, 0.82) 42%, rgba(250, 250, 250, 0.22) 100%),
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
linear-gradient(180deg, rgba(250, 250, 250, 0.12) 0%, rgba(250, 250, 250, 0.84) 100%);
|
||||||
|
}
|
||||||
|
.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;
|
||||||
}
|
}
|
||||||
[data-theme="dark"] .hero-content {
|
|
||||||
background: rgba(15, 23, 42, 0.7);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<link rel="stylesheet" href="{{ asset('css/genesis.css') }}">
|
||||||
</head>
|
</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 -->
|
<!-- Background Map -->
|
||||||
<div id="bgMap"></div>
|
<div id="bgMap"></div>
|
||||||
|
|
||||||
<div class="hero min-h-screen">
|
<div class="landing-shell">
|
||||||
<div class="hero-content text-center flex-col gap-2">
|
<div class="landing-content">
|
||||||
<!-- Logo -->
|
<div class="brand-kicker">
|
||||||
<div class="text-7xl mb-2">🏘️</div>
|
<span class="brand-mark">GIS</span>
|
||||||
<h1 class="text-4xl font-bold tracking-tight text-primary">WebGIS Pemetaan Kemiskinan</h1>
|
Sistem informasi geografis desa
|
||||||
<p class="max-w-md text-base-content/60 mt-2 mb-6 leading-relaxed">
|
</div>
|
||||||
Sistem Informasi Geografis untuk pemetaan rumah ibadah dan
|
<h1 class="landing-title">Pemetaan kemiskinan berbasis radius layanan.</h1>
|
||||||
analisis sebaran keluarga kurang mampu.
|
<p class="landing-copy">
|
||||||
|
Pantau sebaran keluarga kurang mampu, rumah ibadah, dan celah jangkauan bantuan dalam satu ruang kerja spasial yang ringkas.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Buttons -->
|
<div class="landing-actions">
|
||||||
<div class="flex flex-col sm:flex-row gap-3">
|
|
||||||
<a href="{{ url('/map') }}" class="btn btn-primary btn-lg gap-2">
|
<a href="{{ url('/map') }}" class="btn btn-primary btn-lg gap-2">
|
||||||
<i class="fa-solid fa-map-location-dot"></i>
|
<i class="fa-solid fa-map-location-dot"></i>
|
||||||
Buka Peta & Input
|
Buka Peta
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url('/data') }}" class="btn btn-outline btn-lg gap-2">
|
<a href="{{ url('/data') }}" class="btn btn-outline btn-lg gap-2">
|
||||||
<i class="fa-solid fa-table-list"></i>
|
<i class="fa-solid fa-table-list"></i>
|
||||||
Lihat Data Tabular
|
Lihat Data
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- User info -->
|
<div class="landing-meta">
|
||||||
<div class="flex items-center gap-2 text-sm text-base-content/50 mt-2">
|
|
||||||
<i class="fa-solid fa-user text-xs"></i>
|
|
||||||
<span>{{ auth()->user()->name }}</span>
|
<span>{{ auth()->user()->name }}</span>
|
||||||
<span class="opacity-30">•</span>
|
<span>/</span>
|
||||||
<span class="capitalize">{{ str_replace('_', ' ', auth()->user()->getRoleNames()->first() ?? '') }}</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">
|
<form method="POST" action="{{ route('logout') }}" class="inline">
|
||||||
@csrf
|
@csrf
|
||||||
<button type="submit" class="hover:text-error transition-colors underline underline-offset-2">Keluar</button>
|
<button type="submit" class="hover:text-error transition-colors underline underline-offset-2">Keluar</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Redirect progress -->
|
<div class="redirect-strip">
|
||||||
<div class="mt-8 w-72">
|
<p class="text-xs text-base-content/50 mb-2">Mengarahkan ke halaman Peta dalam 5 detik</p>
|
||||||
<p class="text-xs text-base-content/40 mb-2">Mengarahkan ke halaman Peta dalam 5 detik...</p>
|
<progress class="progress w-full" id="redirectProgress" value="0" max="100"></progress>
|
||||||
<progress class="progress progress-primary w-full" id="redirectProgress" value="0" max="100"></progress>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\AnggotaKeluargaController;
|
|||||||
use App\Http\Controllers\AuthController;
|
use App\Http\Controllers\AuthController;
|
||||||
use App\Http\Controllers\BackupController;
|
use App\Http\Controllers\BackupController;
|
||||||
use App\Http\Controllers\BantuanController;
|
use App\Http\Controllers\BantuanController;
|
||||||
|
use App\Http\Controllers\DosenSummaryController;
|
||||||
use App\Http\Controllers\ExportController;
|
use App\Http\Controllers\ExportController;
|
||||||
use App\Http\Controllers\IbadahController;
|
use App\Http\Controllers\IbadahController;
|
||||||
use App\Http\Controllers\ImportController;
|
use App\Http\Controllers\ImportController;
|
||||||
@@ -64,15 +65,39 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::get('/notifications', function (\Illuminate\Http\Request $request) {
|
Route::get('/notifications', function (\Illuminate\Http\Request $request) {
|
||||||
$since = $request->query('since');
|
$since = $request->query('since');
|
||||||
$after = $since ? \Carbon\Carbon::parse($since) : now()->subMinutes(1);
|
$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([
|
return response()->json([
|
||||||
'new_ibadah' => \App\Models\RumahIbadah::where('created_at', '>', $after)->count(),
|
'new_ibadah' => \App\Models\RumahIbadah::where('created_at', '>', $after)->count(),
|
||||||
'new_miskin' => \App\Models\RumahMiskin::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_ibadah' => \App\Models\RumahIbadah::max('created_at'),
|
||||||
'latest_miskin' => \App\Models\RumahMiskin::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
|
// Rumah Ibadah — read: all roles, write: administrator + pengurus_ibadah
|
||||||
Route::get('/ibadah', [IbadahController::class, 'index']);
|
Route::get('/ibadah', [IbadahController::class, 'index']);
|
||||||
Route::middleware('role:administrator|pengurus_ibadah')->group(function () {
|
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 { defineConfig } from 'vite';
|
||||||
import laravel from 'laravel-vite-plugin';
|
import laravel from 'laravel-vite-plugin';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
laravel({
|
laravel({
|
||||||
input: ['resources/css/app.css', 'resources/js/app.js'],
|
input: ['resources/css/app.css'],
|
||||||
refresh: true,
|
refresh: true,
|
||||||
}),
|
}),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
],
|
],
|
||||||
server: {
|
|
||||||
watch: {
|
|
||||||
ignored: ['**/storage/framework/views/**'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user