Compare commits

..

15 Commits

Author SHA1 Message Date
GuavaPopper 4907ff7ef8 chore: remove --prefer-dist from composer install to allow fallback to git source 2026-06-11 00:18:09 +07:00
GuavaPopper 4e19f27522 Bismillah 2026-06-10 23:13:30 +07:00
GuavaPopper 1b3031a1ab feat: add entrypoint script to handle environment configuration, database readiness, and service startup 2026-06-10 22:34:56 +07:00
GuavaPopper 70d6efdec6 chore: optimize Dockerfile build stages by deferring composer scripts and autoload dumping to final stage 2026-06-10 21:09:21 +07:00
GuavaPopper 2f8b14ed2a fix: make Coolify image self-contained 2026-06-10 20:38:59 +07:00
GuavaPopper 69b03fb467 chore: prepare repository for new remote 2026-06-10 19:00:07 +07:00
GuavaPopper 056520d48f chore: remove hardcoded ports for Coolify 2026-06-10 18:56:19 +07:00
GuavaPopper 18c0e1d489 . 2026-06-10 18:43:14 +07:00
GuavaPopper db5b20c572 . 2026-06-10 18:32:09 +07:00
GuavaPopper 024873cfb7 . 2026-06-10 18:27:04 +07:00
GuavaPopper f4b9501d50 chore: remove docs and tests from deploy package 2026-06-10 18:18:37 +07:00
GuavaPopper d85d066f6c feat: refresh UI and add sidebar navigation 2026-06-10 18:16:46 +07:00
GuavaPopper c4157cec8d feat: add dashboard transfer and assistance summaries 2026-06-10 18:13:40 +07:00
GuavaPopper 91fc2fbd16 fix: support sqlite database backups 2026-06-10 18:11:09 +07:00
GuavaPopper cdba16e9e5 chore: add Docker deployment setup 2026-06-10 18:08:57 +07:00
51 changed files with 3761 additions and 2710 deletions
+15
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
Binary file not shown.
-66
View File
@@ -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
View File
@@ -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"]
+177 -179
View File
@@ -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.
+30 -12
View File
@@ -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,14 +68,16 @@
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 -->
<div id="bgMap"></div> <div id="bgMap"></div>
@@ -68,9 +85,9 @@
<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.
</p> </p>
@@ -99,7 +116,7 @@
let val = 0; let val = 0;
const bar = document.getElementById('redirectProgress'); const bar = document.getElementById('redirectProgress');
const tick = setInterval(() => { const tick = setInterval(() => {
val += 100 / 50; val += 100 / 50;
bar.value = val; bar.value = val;
if (val >= 100) clearInterval(tick); if (val >= 100) clearInterval(tick);
}, 100); }, 100);
@@ -133,7 +150,7 @@
fetch('api/get_miskin.php').then(r => r.json()) fetch('api/get_miskin.php').then(r => r.json())
]).then(([ibadah, miskin]) => { ]).then(([ibadah, miskin]) => {
const markers = []; const markers = [];
ibadah.forEach(item => { ibadah.forEach(item => {
L.circleMarker([+item.latitude, +item.longitude], { L.circleMarker([+item.latitude, +item.longitude], {
radius: 7, fillColor: "#22c55e", color: "#fff", weight: 2, opacity: 1, fillOpacity: 0.8 radius: 7, fillColor: "#22c55e", color: "#fff", weight: 2, opacity: 1, fillOpacity: 0.8
@@ -151,14 +168,15 @@
if (markers.length) { if (markers.length) {
map.fitBounds(markers, { padding: [50, 50] }); map.fitBounds(markers, { padding: [50, 50] });
} }
// Subtle animation: slow pan // Subtle animation: slow pan
let lat = map.getCenter().lat; let lat = map.getCenter().lat;
setInterval(() => { setInterval(() => {
lat += 0.000005; lat += 0.000005;
map.panTo([lat, map.getCenter().lng], { animate: true, duration: 1 }); map.panTo([lat, map.getCenter().lng], { animate: true, duration: 1 });
}, 1000); }, 1000);
}).catch(() => {}); }).catch(() => { });
</script> </script>
</body> </body>
</html>
</html>
+38 -13
View File
@@ -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,20 +49,21 @@
<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>
<!-- ═══ MAIN CONTENT ════════════════════════════════════════════════════ --> <!-- ═══ MAIN CONTENT ════════════════════════════════════════════════════ -->
<main class="max-w-screen-xl mx-auto px-4 py-8"> <main 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">
<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">
@@ -189,8 +213,8 @@
document.getElementById('confirmDeleteBtn').onclick = async () => { document.getElementById('confirmDeleteBtn').onclick = async () => {
const { type, id } = deleteTarget; const { type, id } = deleteTarget;
const res = await fetch(`../api/delete_${type}.php`, { const res = await fetch(`../api/delete_${type}.php`, {
method: 'POST', body: JSON.stringify(type === 'ibadah' ? { id } : { id_rumah: id }) method: 'POST', body: JSON.stringify(type === 'ibadah' ? { id } : { id_rumah: id })
}); });
const resJson = await res.json(); const resJson = await res.json();
if (resJson.success) { if (resJson.success) {
@@ -210,4 +234,5 @@
} }
</script> </script>
</body> </body>
</html>
</html>
+104 -62
View File
@@ -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,15 +19,46 @@
<!-- 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 {
position: absolute; position: absolute;
@@ -37,7 +69,7 @@
max-height: calc(100vh - 100px); max-height: calc(100vh - 100px);
overflow-y: auto; overflow-y: auto;
} }
.glass-card { .glass-card {
background: rgba(255, 255, 255, 0.85); background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
@@ -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>
@@ -130,12 +167,13 @@
<div class="text-xs font-bold text-primary truncate" id="activeName">Nama Ibadah</div> <div class="text-xs font-bold text-primary truncate" id="activeName">Nama Ibadah</div>
<div class="text-[10px] text-base-content/40" id="activeAddr">Alamat</div> <div class="text-[10px] text-base-content/40" id="activeAddr">Alamat</div>
</div> </div>
<div class="form-control mb-4"> <div class="form-control mb-4">
<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">
@@ -168,7 +206,7 @@
</div> </div>
<!-- ═══ MODALS ═══════════════════════════════════════════════════════════ --> <!-- ═══ MODALS ═══════════════════════════════════════════════════════════ -->
<!-- Modal: Input Ibadah --> <!-- Modal: Input Ibadah -->
<dialog id="modalIbadah" class="modal"> <dialog id="modalIbadah" class="modal">
<div class="modal-box"> <div class="modal-box">
@@ -179,11 +217,13 @@
<div class="bg-base-200 p-2 rounded text-xs font-mono" id="ib_coords">Lat: -, Lng: -</div> <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>
@@ -255,7 +296,7 @@
let activeCircle = null, activeIbadah = null; let activeCircle = null, activeIbadah = null;
let ibadahData = [], miskinData = []; let ibadahData = [], miskinData = [];
let tempMarker = null; let tempMarker = null;
let ibadahMarkers = new Map(); // id -> marker let ibadahMarkers = new Map(); // id -> marker
let miskinMarkers = new Map(); // id_rumah -> marker let miskinMarkers = new Map(); // id_rumah -> marker
@@ -286,7 +327,7 @@
function initMap() { function initMap() {
map = L.map('map', { zoomControl: false }).setView([-0.0553, 109.3463], 17); map = L.map('map', { zoomControl: false }).setView([-0.0553, 109.3463], 17);
const osm = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }); const osm = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 });
const satellite = L.layerGroup([ const satellite = L.layerGroup([
L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { maxZoom: 19 }), L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { maxZoom: 19 }),
@@ -296,9 +337,9 @@
satellite.addTo(map); satellite.addTo(map);
L.control.zoom({ position: 'bottomright' }).addTo(map); L.control.zoom({ position: 'bottomright' }).addTo(map);
L.control.layers({ "Satelit": satellite, "Street": osm }, L.control.layers({ "Satelit": satellite, "Street": osm },
{ "Rumah Ibadah": layerIbadah, "Rumah Miskin": layerMiskin }, { "Rumah Ibadah": layerIbadah, "Rumah Miskin": layerMiskin },
{ position: 'topright' }).addTo(map); { position: 'topright' }).addTo(map);
layerIbadah.addTo(map); layerIbadah.addTo(map);
layerMiskin.addTo(map); layerMiskin.addTo(map);
@@ -315,7 +356,7 @@
fetch('../api/get_ibadah.php').then(r => r.json()), fetch('../api/get_ibadah.php').then(r => r.json()),
fetch('../api/get_miskin.php').then(r => r.json()) fetch('../api/get_miskin.php').then(r => r.json())
]); ]);
// Validate data is array // Validate data is array
if (!Array.isArray(ri) || !Array.isArray(rm)) { if (!Array.isArray(ri) || !Array.isArray(rm)) {
console.error("Invalid API response", { ri, rm }); console.error("Invalid API response", { ri, rm });
@@ -324,7 +365,7 @@
ibadahData = ri; ibadahData = ri;
miskinData = rm; miskinData = rm;
// Sync activeIbadah to point to the new object in the fresh data // Sync activeIbadah to point to the new object in the fresh data
if (activeIbadah) { if (activeIbadah) {
const matched = ibadahData.find(i => i.id == activeIbadah.id); const matched = ibadahData.find(i => i.id == activeIbadah.id);
@@ -332,9 +373,9 @@
} }
renderMarkers(); renderMarkers();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
showToast('Gagal memuat data', 'error'); showToast('Gagal memuat data', 'error');
} }
} }
@@ -348,16 +389,16 @@
const m = L.marker([item.latitude, item.longitude], { icon: icons.ibadah(), draggable: true }) const m = L.marker([item.latitude, item.longitude], { icon: icons.ibadah(), draggable: true })
.bindPopup(buildPopupIbadah(item)) .bindPopup(buildPopupIbadah(item))
.on('click', () => handleIbadahClick(item)); .on('click', () => handleIbadahClick(item));
m.on('dragend', e => updateLocation('ibadah', item.id, e.target.getLatLng(), item)); m.on('dragend', e => updateLocation('ibadah', item.id, e.target.getLatLng(), item));
layerIbadah.addLayer(m); layerIbadah.addLayer(m);
ibadahMarkers.set(item.id, m); ibadahMarkers.set(item.id, m);
}); });
miskinData.forEach(item => { miskinData.forEach(item => {
const marker = L.marker([item.latitude, item.longitude], { const marker = L.marker([item.latitude, item.longitude], {
icon: icons.miskin('#ef4444'), icon: icons.miskin('#ef4444'),
draggable: true draggable: true
}).bindPopup(buildPopupMiskin(item)); }).bindPopup(buildPopupMiskin(item));
marker.on('dragend', e => updateLocation('miskin', item.id_rumah, e.target.getLatLng(), item)); marker.on('dragend', e => updateLocation('miskin', item.id_rumah, e.target.getLatLng(), item));
@@ -376,10 +417,10 @@
currentMode = mode; currentMode = mode;
document.querySelectorAll('.btn-outline').forEach(b => b.classList.remove('active')); document.querySelectorAll('.btn-outline').forEach(b => b.classList.remove('active'));
document.getElementById('btnMode' + mode.charAt(0).toUpperCase() + mode.slice(1)).classList.add('active'); document.getElementById('btnMode' + mode.charAt(0).toUpperCase() + mode.slice(1)).classList.add('active');
document.getElementById('mapHint').classList.toggle('hidden', mode === 'view'); document.getElementById('mapHint').classList.toggle('hidden', mode === 'view');
document.getElementById('analysisPanel').classList.toggle('hidden', mode !== 'view'); document.getElementById('analysisPanel').classList.toggle('hidden', mode !== 'view');
if (mode !== 'view') { if (mode !== 'view') {
if (activeCircle) { map.removeLayer(activeCircle); activeCircle = null; } if (activeCircle) { map.removeLayer(activeCircle); activeCircle = null; }
document.getElementById('statsContent').classList.add('hidden'); document.getElementById('statsContent').classList.add('hidden');
@@ -391,13 +432,13 @@
function handleMapClick(latlng) { function handleMapClick(latlng) {
if (tempMarker) map.removeLayer(tempMarker); if (tempMarker) map.removeLayer(tempMarker);
tempMarker = L.marker(latlng, { icon: icons.temp() }).addTo(map); tempMarker = L.marker(latlng, { icon: icons.temp() }).addTo(map);
if (currentMode === 'ibadah') { if (currentMode === 'ibadah') {
document.getElementById('ib_coords').innerText = `Lat: ${latlng.lat.toFixed(6)}, Lng: ${latlng.lng.toFixed(6)}`; document.getElementById('ib_coords').innerText = `Lat: ${latlng.lat.toFixed(6)}, Lng: ${latlng.lng.toFixed(6)}`;
document.getElementById('ib_nama').value = ''; document.getElementById('ib_nama').value = '';
document.getElementById('ib_alamat').value = 'Mencari alamat...'; document.getElementById('ib_alamat').value = 'Mencari alamat...';
modalIbadah.showModal(); modalIbadah.showModal();
// Auto fetch address // Auto fetch address
reverseGeocode(latlng.lat, latlng.lng, 'ib_alamat'); reverseGeocode(latlng.lat, latlng.lng, 'ib_alamat');
} else { } else {
@@ -432,7 +473,7 @@
document.getElementById('statsContent').classList.remove('hidden'); document.getElementById('statsContent').classList.remove('hidden');
document.getElementById('activeName').innerText = item.nama; document.getElementById('activeName').innerText = item.nama;
document.getElementById('activeAddr').innerText = item.alamat; document.getElementById('activeAddr').innerText = item.alamat;
const radius = document.getElementById('radiusSlider').value = item.radius || 500; const radius = document.getElementById('radiusSlider').value = item.radius || 500;
updateRadius(item.latitude, item.longitude, radius); updateRadius(item.latitude, item.longitude, radius);
} }
@@ -441,25 +482,25 @@
function updateRadius(lat, lng, radius) { function updateRadius(lat, lng, radius) {
if (activeCircle) map.removeLayer(activeCircle); if (activeCircle) map.removeLayer(activeCircle);
activeCircle = L.circle([lat, lng], { activeCircle = L.circle([lat, lng], {
radius: parseInt(radius), radius: parseInt(radius),
color: '#2563eb', color: '#2563eb',
fillColor: '#3b82f6', fillColor: '#3b82f6',
fillOpacity: 0.2, fillOpacity: 0.2,
weight: 2, weight: 2,
dashArray: '5, 10' dashArray: '5, 10'
}).addTo(map); }).addTo(map);
document.getElementById('radiusVal').innerText = radius; document.getElementById('radiusVal').innerText = radius;
updateAnalysisUI([lat, lng], radius); updateAnalysisUI([lat, lng], radius);
} }
function updateAnalysisUI(center, radius) { function updateAnalysisUI(center, radius) {
let houses = 0, kk = 0, jiwa = 0; let houses = 0, kk = 0, jiwa = 0;
miskinData.forEach(h => { miskinData.forEach(h => {
const houseMarker = miskinMarkers.get(h.id_rumah); const houseMarker = miskinMarkers.get(h.id_rumah);
const isH = isInside(h, center, radius); const isH = isInside(h, center, radius);
if (isH) { if (isH) {
houses++; houses++;
kk += parseInt(h.jumlah_kk); kk += parseInt(h.jumlah_kk);
@@ -484,7 +525,7 @@
// center can be [lat, lng] or {lat, lng} // center can be [lat, lng] or {lat, lng}
const cLat = center.lat !== undefined ? center.lat : center[0]; const cLat = center.lat !== undefined ? center.lat : center[0];
const cLng = center.lng !== undefined ? center.lng : center[1]; const cLng = center.lng !== undefined ? center.lng : center[1];
const p1 = turf.point([Number(point.longitude), Number(point.latitude)]); const p1 = turf.point([Number(point.longitude), Number(point.latitude)]);
const p2 = turf.point([Number(cLng), Number(cLat)]); const p2 = turf.point([Number(cLng), Number(cLat)]);
return turf.distance(p1, p2, { units: 'meters' }) <= radius; return turf.distance(p1, p2, { units: 'meters' }) <= radius;
@@ -499,7 +540,7 @@
// Save radius to DB when user finishes sliding // Save radius to DB when user finishes sliding
document.getElementById('radiusSlider').addEventListener('change', async e => { document.getElementById('radiusSlider').addEventListener('change', async e => {
if (!activeIbadah) return; if (!activeIbadah) return;
const newRadius = e.target.value; const newRadius = e.target.value;
try { try {
const res = await fetch('../api/update_ibadah.php', { const res = await fetch('../api/update_ibadah.php', {
@@ -559,11 +600,11 @@
}; };
const btn = document.getElementById('btnSaveIbadah'); const btn = document.getElementById('btnSaveIbadah');
btn.disabled = true; btn.disabled = true;
const res = await fetch('../api/save_ibadah.php', { const res = await fetch('../api/save_ibadah.php', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data) body: JSON.stringify(data)
}); });
const resJson = await res.json(); const resJson = await res.json();
if (resJson.success) { if (resJson.success) {
@@ -586,10 +627,10 @@
const btn = document.getElementById('btnSaveMiskin'); const btn = document.getElementById('btnSaveMiskin');
btn.disabled = true; btn.disabled = true;
const res = await fetch('../api/save_miskin.php', { const res = await fetch('../api/save_miskin.php', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data) body: JSON.stringify(data)
}); });
const resJson = await res.json(); const resJson = await res.json();
if (resJson.success) { if (resJson.success) {
@@ -602,15 +643,15 @@
async function updateLocation(type, id, latlng, oldData) { async function updateLocation(type, id, latlng, oldData) {
const endpoint = `../api/update_${type}.php`; const endpoint = `../api/update_${type}.php`;
const payload = type === 'ibadah' const payload = type === 'ibadah'
? { id, nama: oldData.nama, alamat: oldData.alamat, latitude: latlng.lat, longitude: latlng.lng, radius: oldData.radius } ? { id, nama: oldData.nama, alamat: oldData.alamat, latitude: latlng.lat, longitude: latlng.lng, radius: oldData.radius }
: { id_rumah: id, alamat: oldData.alamat, jumlah_kk: oldData.jumlah_kk, jumlah_orang: oldData.jumlah_orang, latitude: latlng.lat, longitude: latlng.lng }; : { id_rumah: id, alamat: oldData.alamat, jumlah_kk: oldData.jumlah_kk, jumlah_orang: oldData.jumlah_orang, latitude: latlng.lat, longitude: latlng.lng };
try { try {
const res = await fetch(endpoint, { const res = await fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
const resJson = await res.json(); const resJson = await res.json();
if (resJson.success) { if (resJson.success) {
@@ -635,10 +676,10 @@
document.getElementById('confirmDeleteBtn').onclick = async () => { document.getElementById('confirmDeleteBtn').onclick = async () => {
const { type, id } = deleteTarget; const { type, id } = deleteTarget;
const res = await fetch(`../api/delete_${type}.php`, { const res = await fetch(`../api/delete_${type}.php`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(type === 'ibadah' ? { id } : { id_rumah: id }) body: JSON.stringify(type === 'ibadah' ? { id } : { id_rumah: id })
}); });
const resJson = await res.json(); const resJson = await res.json();
if (resJson.success) { if (resJson.success) {
@@ -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'],
+17
View File
@@ -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,
]);
}
}
+1 -1
View File
@@ -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;
} }
} }
+23
View File
@@ -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);
}
}); });
} }
} }
+2
View File
@@ -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,
+62
View File
@@ -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:
+19 -2
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
memory_limit=512M
upload_max_filesize=32M
post_max_size=32M
max_execution_time=120
date.timezone=Asia/Jakarta
+10
View File
@@ -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 "$@"
-746
View File
@@ -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 46 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 46:**
> - 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
-200
View File
@@ -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-02A-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 | 12 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 |
+1614
View File
File diff suppressed because it is too large Load Diff
-36
View File
@@ -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>
+788
View File
@@ -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;
}
}
View File
+3 -1
View File
@@ -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
View File
@@ -1 +0,0 @@
//
+6 -30
View File
@@ -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>
+6 -30
View File
@@ -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>
+6 -72
View File
@@ -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 ═══════════════════════════════════════════════════════════ -->
+34 -18
View File
@@ -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())
+183 -85
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}); });
})(); })();
+140
View File
@@ -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>
+15 -35
View File
@@ -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('');
+105 -47
View File
@@ -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%);
} }
[data-theme="dark"] .hero-content { .landing-content {
background: rgba(15, 23, 42, 0.7); max-width: 760px;
border: 1px solid rgba(255, 255, 255, 0.1); }
.brand-kicker {
display: inline-flex;
align-items: center;
gap: 10px;
color: #6b6b6b;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.brand-mark {
display: inline-grid;
width: 32px;
height: 32px;
place-items: center;
border: 1px solid #e8e8ec;
border-radius: 8px;
background: #fff;
color: #0a0a0a;
font-size: 12px;
font-weight: 700;
}
.landing-title {
max-width: 720px;
margin-top: 24px;
color: #0a0a0a;
font-size: clamp(44px, 7vw, 72px);
line-height: 0.94;
}
.landing-copy {
max-width: 520px;
margin-top: 20px;
color: #6b6b6b;
font-size: 16px;
line-height: 1.7;
}
.landing-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 32px;
}
.landing-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
margin-top: 28px;
color: #6b6b6b;
font-size: 13px;
}
.redirect-strip {
width: min(420px, 100%);
margin-top: 40px;
}
.progress {
height: 4px;
background: #e8e8ec;
}
.progress::-webkit-progress-value {
background: #6366f1;
}
@media (max-width: 640px) {
.landing-shell {
padding: 80px 20px 28px;
}
.landing-title {
font-size: 44px;
}
} }
</style> </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>
+25
View File
@@ -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 () {
-156
View File
@@ -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');
}
}
-127
View File
@@ -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)
);
}
}
-131
View File
@@ -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);
}
}
-56
View File
@@ -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();
}
}
-144
View File
@@ -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']);
}
}
-19
View File
@@ -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');
}
}
-75
View File
@@ -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();
}
}
-112
View File
@@ -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);
}
}
-10
View File
@@ -1,10 +0,0 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
//
}
-16
View File
@@ -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
View File
@@ -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/**'],
},
},
}); });