commit bda6ce731a78ec98fb4d0c78d9c30d9bf9797668 Author: azgrey Date: Fri Jun 5 15:09:45 2026 +0700 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e4c92c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# Environment / local config +.env +.env.local +.env.*.local + +# Editor +.vscode/ +.idea/ +*.suo +*.ntvs* +*.njsproj +*.sln + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* + +# Vite cache +.vite/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..6a1163e --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# SIPKEM — Sistem Peta Kemiskinan Pontianak + +Aplikasi pemetaan rumah ibadah & rumah miskin berbasis React + Leaflet + PHP/MySQL. + +--- + +## Fitur + +### Akses Berbasis Peran +| Fitur | Pengguna | Pengurus | +|---|---|---| +| Lihat peta & marker | ✅ | ✅ | +| Lihat detail popup | ✅ | ✅ | +| Lihat riwayat bantuan | ✅ | ✅ | +| Tambah / edit / hapus data | ❌ | ✅ | +| Seret marker (drag) | ❌ | ✅ | +| Atur jangkauan radius | ❌ | ✅ | +| Konfirmasi bantuan ke rumah | ❌ | ✅ | +| Kelola jenis bantuan | ❌ | ✅ | + +### Bantuan +- **Jenis Bantuan** — Pengurus dapat menambah/menghapus jenis bantuan (Sembako, Kesehatan, Pendidikan, Sandang, Finansial, Lainnya) +- **Konfirmasi Bantuan** — Pengurus dapat mencatat bantuan yang diterima oleh suatu rumah miskin +- **Riwayat Bantuan** — Semua pengguna dapat melihat histori bantuan per rumah miskin dari popup peta + +--- + +## Setup + +### 1. Database +Pastikan XAMPP aktif, database `sipkem` sudah ada beserta tabel `rumah_ibadah`, `rumah_miskin`, `riwayat_binaan`, dan `users`. + +Jalankan migrasi baru: +```sql +-- Di phpMyAdmin, pilih database sipkem lalu jalankan: +SOURCE migration_bantuan.sql; +``` +Atau salin isi `migration_bantuan.sql` ke query editor phpMyAdmin. + +### 2. File PHP +Letakkan `api.php` di folder yang sama dengan `dist/` setelah build. + +### 3. React Dev +```bash +npm install +npm run dev +``` + +### 4. Build Produksi +```bash +npm run build +# Salin dist/ + api.php ke htdocs/sipkem/ +``` + +--- + +## Struktur Tabel Baru + +### `jenis_bantuan` +| Kolom | Tipe | Keterangan | +|---|---|---| +| id | CHAR(36) PK | UUID | +| nama | VARCHAR(120) | Nama bantuan | +| kategori | ENUM | Sembako / Kesehatan / Pendidikan / Sandang / Finansial / Lainnya | +| deskripsi | TEXT NULL | Penjelasan opsional | +| dibuat_pada | TIMESTAMP | Otomatis | + +### `riwayat_bantuan` +| Kolom | Tipe | Keterangan | +|---|---|---| +| id | CHAR(36) PK | UUID | +| rm_id | CHAR(36) FK | → rumah_miskin.id | +| bantuan_id | CHAR(36) FK | → jenis_bantuan.id | +| tanggal | DATE | Tanggal pemberian | +| catatan | TEXT NULL | Catatan opsional | +| user_id | CHAR(36) NULL | Pengurus yang mengkonfirmasi | +| dibuat_pada | TIMESTAMP | Otomatis | diff --git a/api.php b/api.php new file mode 100644 index 0000000..3e81ba8 --- /dev/null +++ b/api.php @@ -0,0 +1,281 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ] + ); +} catch (PDOException $e) { + http_response_code(500); + echo json_encode(['ok' => false, 'error' => 'Koneksi DB gagal: ' . $e->getMessage()]); + exit; +} + +$body = json_decode(file_get_contents('php://input'), true) ?? []; +$action = $body['action'] ?? ($_GET['action'] ?? ''); + +function pt(float $lng, float $lat): string { + return "POINT($lng $lat)"; +} + +try { + switch ($action) { + + // ── AUTH: REGISTER (Pengguna only) ────────────────────────────── + case 'register': + $nama = trim($body['nama'] ?? ''); + $email = trim($body['email'] ?? ''); + $pass = $body['password'] ?? ''; + + if (!$nama || !$email || strlen($pass) < 8) { + echo json_encode(['ok' => false, 'error' => 'Data tidak lengkap.']); break; + } + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + echo json_encode(['ok' => false, 'error' => 'Format email tidak valid.']); break; + } + + // Check duplicate + $chk = $pdo->prepare("SELECT id FROM users WHERE email = :e LIMIT 1"); + $chk->execute(['e' => $email]); + if ($chk->fetch()) { + echo json_encode(['ok' => false, 'error' => 'Email sudah terdaftar.']); break; + } + + $id = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + mt_rand(0,0xffff), mt_rand(0,0xffff), mt_rand(0,0xffff), + mt_rand(0,0x0fff)|0x4000, mt_rand(0,0x3fff)|0x8000, + mt_rand(0,0xffff), mt_rand(0,0xffff), mt_rand(0,0xffff)); + $hash = password_hash($pass, PASSWORD_BCRYPT); + + $st = $pdo->prepare( + "INSERT INTO users (id, nama, email, password_hash, role) + VALUES (:id, :nama, :email, :hash, 'pengguna')" + ); + $st->execute(['id' => $id, 'nama' => $nama, 'email' => $email, 'hash' => $hash]); + echo json_encode(['ok' => true]); + break; + + // ── AUTH: LOGIN ───────────────────────────────────────────────── + case 'login': + $email = trim($body['email'] ?? ''); + $pass = $body['password'] ?? ''; + $role = $body['role'] ?? 'pengguna'; // 'pengguna' or 'pengurus' + + if (!$email || !$pass) { + echo json_encode(['ok' => false, 'error' => 'Email dan kata sandi wajib diisi.']); break; + } + + $st = $pdo->prepare("SELECT id, nama, email, password_hash, role FROM users WHERE email = :e LIMIT 1"); + $st->execute(['e' => $email]); + $user = $st->fetch(); + + if (!$user || !password_verify($pass, $user['password_hash'])) { + echo json_encode(['ok' => false, 'error' => 'Email atau kata sandi salah.']); break; + } + + // Role mismatch guard + if ($user['role'] !== $role) { + if ($role === 'pengurus') { + echo json_encode(['ok' => false, 'error' => 'Akun ini bukan akun Pengurus.']); break; + } else { + echo json_encode(['ok' => false, 'error' => 'Akun Pengurus tidak bisa masuk sebagai Pengguna.']); break; + } + } + + echo json_encode([ + 'ok' => true, + 'user' => ['id' => $user['id'], 'nama' => $user['nama'], 'role' => $user['role']], + ]); + break; + + // ── LOAD SEMUA DATA ────────────────────────────────────────────── + case 'load': + $ri = $pdo->query( + "SELECT id, nama, jenis, kontak, alamat, + ST_Y(geom) AS lat, ST_X(geom) AS lng, + jangkauan_m + FROM rumah_ibadah ORDER BY dibuat_pada ASC" + )->fetchAll(); + + $rm = $pdo->query( + "SELECT id, nama_kk, kontak, alamat, + ST_Y(geom) AS lat, ST_X(geom) AS lng, + ri_id, jarak_ke_ri_m + FROM rumah_miskin ORDER BY dibuat_pada ASC" + )->fetchAll(); + + echo json_encode(['ok' => true, 'ri' => $ri, 'rm' => $rm]); + break; + + case 'add_ri': + $st = $pdo->prepare( + "INSERT INTO rumah_ibadah + (id, nama, jenis, kontak, alamat, geom, jangkauan_m) + VALUES (:id,:nama,:jenis,:kontak,:alamat,ST_GeomFromText(:geom),:jangkauan)" + ); + $st->execute([ + 'id' => $body['id'], + 'nama' => $body['nama'], + 'jenis' => $body['jenis'], + 'kontak' => $body['kontak'] ?? '-', + 'alamat' => $body['alamat'] ?? '', + 'geom' => pt((float)$body['lng'], (float)$body['lat']), + 'jangkauan' => (int)($body['jangkauan_m'] ?? 500), + ]); + echo json_encode(['ok' => true]); + break; + + case 'add_rm': + $st = $pdo->prepare( + "INSERT INTO rumah_miskin + (id, nama_kk, kontak, alamat, geom, ri_id, jarak_ke_ri_m) + VALUES (:id,:nama,:kontak,:alamat,ST_GeomFromText(:geom),:ri_id,:jarak)" + ); + $st->execute([ + 'id' => $body['id'], + 'nama' => $body['nama_kk'], + 'kontak' => $body['kontak'] ?? '-', + 'alamat' => $body['alamat'] ?? '', + 'geom' => pt((float)$body['lng'], (float)$body['lat']), + 'ri_id' => $body['ri_id'] ?: null, + 'jarak' => isset($body['jarak_ke_ri_m']) ? (float)$body['jarak_ke_ri_m'] : null, + ]); + echo json_encode(['ok' => true]); + break; + + case 'update_ri': + $st = $pdo->prepare("UPDATE rumah_ibadah SET nama=:nama,jenis=:jenis,kontak=:kontak WHERE id=:id"); + $st->execute(['nama'=>$body['nama'],'jenis'=>$body['jenis'],'kontak'=>$body['kontak'],'id'=>$body['id']]); + echo json_encode(['ok' => true]); + break; + + case 'update_ri_range': + $pdo->prepare("UPDATE rumah_ibadah SET jangkauan_m=:j WHERE id=:id") + ->execute(['j'=>(int)$body['jangkauan_m'],'id'=>$body['id']]); + echo json_encode(['ok' => true]); + break; + + case 'update_ri_pos': + $pdo->prepare("UPDATE rumah_ibadah SET geom=ST_GeomFromText(:geom),alamat=:alamat WHERE id=:id") + ->execute(['geom'=>pt((float)$body['lng'],(float)$body['lat']),'alamat'=>$body['alamat']??'','id'=>$body['id']]); + echo json_encode(['ok' => true]); + break; + + case 'update_rm': + $pdo->prepare("UPDATE rumah_miskin SET nama_kk=:nama,kontak=:kontak WHERE id=:id") + ->execute(['nama'=>$body['nama_kk'],'kontak'=>$body['kontak'],'id'=>$body['id']]); + echo json_encode(['ok' => true]); + break; + + case 'update_rm_pos': + $pdo->prepare("UPDATE rumah_miskin SET geom=ST_GeomFromText(:geom),alamat=:alamat WHERE id=:id") + ->execute(['geom'=>pt((float)$body['lng'],(float)$body['lat']),'alamat'=>$body['alamat']??'','id'=>$body['id']]); + echo json_encode(['ok' => true]); + break; + + case 'update_rm_assignment': + $old = $pdo->prepare("SELECT ri_id FROM rumah_miskin WHERE id=:id"); + $old->execute(['id' => $body['rm_id']]); + $row = $old->fetch(); + $oldRI = $row ? $row['ri_id'] : null; + $newRI = $body['ri_id'] ?: null; + + $pdo->prepare("UPDATE rumah_miskin SET ri_id=:ri_id,jarak_ke_ri_m=:jarak WHERE id=:id") + ->execute(['ri_id'=>$newRI,'jarak'=>isset($body['jarak_ke_ri_m'])?(float)$body['jarak_ke_ri_m']:null,'id'=>$body['rm_id']]); + + if ($oldRI !== $newRI) { + $pdo->prepare("INSERT INTO riwayat_binaan (rm_id,ri_id_lama,ri_id_baru,alasan) VALUES (:rm,:lama,:baru,:alasan)") + ->execute(['rm'=>$body['rm_id'],'lama'=>$oldRI,'baru'=>$newRI,'alasan'=>$body['alasan']??'otomatis']); + } + echo json_encode(['ok' => true]); + break; + + case 'delete_ri': + $pdo->prepare("DELETE FROM rumah_ibadah WHERE id=:id")->execute(['id'=>$body['id']]); + echo json_encode(['ok' => true]); + break; + + case 'delete_rm': + $pdo->prepare("DELETE FROM rumah_miskin WHERE id=:id")->execute(['id'=>$body['id']]); + echo json_encode(['ok' => true]); + break; + + // ── BANTUAN TYPES ──────────────────────────────────────────────── + case 'load_bantuan_types': + $rows = $pdo->query("SELECT id,nama,kategori,deskripsi FROM jenis_bantuan ORDER BY dibuat_pada ASC")->fetchAll(); + echo json_encode(['ok' => true, 'data' => $rows]); + break; + + case 'add_bantuan_type': + $id = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + mt_rand(0,0xffff),mt_rand(0,0xffff),mt_rand(0,0xffff), + mt_rand(0,0x0fff)|0x4000,mt_rand(0,0x3fff)|0x8000, + mt_rand(0,0xffff),mt_rand(0,0xffff),mt_rand(0,0xffff)); + $pdo->prepare("INSERT INTO jenis_bantuan (id,nama,kategori,deskripsi) VALUES (:id,:nama,:kategori,:deskripsi)") + ->execute(['id'=>$body['id']??$id,'nama'=>$body['nama'],'kategori'=>$body['kategori']??'Lainnya','deskripsi'=>$body['deskripsi']??null]); + echo json_encode(['ok' => true]); + break; + + case 'delete_bantuan_type': + $pdo->prepare("DELETE FROM jenis_bantuan WHERE id=:id")->execute(['id'=>$body['id']]); + echo json_encode(['ok' => true]); + break; + + case 'load_riwayat_bantuan': + $st = $pdo->prepare( + "SELECT rb.id, rb.tanggal, rb.catatan, + jb.nama AS bantuan_nama, jb.kategori, + u.nama AS dikonfirmasi_oleh + FROM riwayat_bantuan rb + JOIN jenis_bantuan jb ON jb.id = rb.bantuan_id + LEFT JOIN users u ON u.id = rb.user_id + WHERE rb.rm_id = :rm_id + ORDER BY rb.tanggal DESC, rb.dibuat_pada DESC" + ); + $st->execute(['rm_id' => $body['rm_id']]); + echo json_encode(['ok' => true, 'data' => $st->fetchAll()]); + break; + + case 'add_riwayat_bantuan': + $id = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + mt_rand(0,0xffff),mt_rand(0,0xffff),mt_rand(0,0xffff), + mt_rand(0,0x0fff)|0x4000,mt_rand(0,0x3fff)|0x8000, + mt_rand(0,0xffff),mt_rand(0,0xffff),mt_rand(0,0xffff)); + $pdo->prepare( + "INSERT INTO riwayat_bantuan (id,rm_id,bantuan_id,tanggal,catatan,user_id) + VALUES (:id,:rm_id,:bantuan_id,:tanggal,:catatan,:user_id)" + )->execute([ + 'id' => $body['id'] ?? $id, + 'rm_id' => $body['rm_id'], + 'bantuan_id' => $body['bantuan_id'], + 'tanggal' => $body['tanggal'], + 'catatan' => $body['catatan'] ?? null, + 'user_id' => $body['user_id'] ?? null, + ]); + echo json_encode(['ok' => true]); + break; + + default: + http_response_code(400); + echo json_encode(['ok' => false, 'error' => "Action tidak dikenal: $action"]); + } + +} catch (PDOException $e) { + http_response_code(500); + echo json_encode(['ok' => false, 'error' => $e->getMessage()]); +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..03b9350 --- /dev/null +++ b/index.html @@ -0,0 +1,22 @@ + + + + + + SIPKEM — Sistem Peta Kemiskinan + + + + + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b256553 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1755 @@ +{ + "name": "sipkem", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sipkem", + "version": "1.0.0", + "dependencies": { + "leaflet": "^1.9.4", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-leaflet": "^4.2.1", + "react-router-dom": "^6.22.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.1", + "vite": "^5.1.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@react-leaflet/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz", + "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.0.tgz", + "integrity": "sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.0.tgz", + "integrity": "sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.0.tgz", + "integrity": "sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.0.tgz", + "integrity": "sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.0.tgz", + "integrity": "sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.0.tgz", + "integrity": "sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.0.tgz", + "integrity": "sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.0.tgz", + "integrity": "sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.0.tgz", + "integrity": "sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.0.tgz", + "integrity": "sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.0.tgz", + "integrity": "sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.0.tgz", + "integrity": "sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.0.tgz", + "integrity": "sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.0.tgz", + "integrity": "sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.0.tgz", + "integrity": "sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.0.tgz", + "integrity": "sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.0.tgz", + "integrity": "sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.0.tgz", + "integrity": "sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.0.tgz", + "integrity": "sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.0.tgz", + "integrity": "sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.0.tgz", + "integrity": "sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.0.tgz", + "integrity": "sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.0.tgz", + "integrity": "sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.0.tgz", + "integrity": "sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.0.tgz", + "integrity": "sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.366", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.366.tgz", + "integrity": "sha512-OlRuhb688YTCzzU3gXPLn6nGyd+F+53INE1qaKKlu6kETErE8FYsyDh0XqXEU+uBRn0MpCzz2vfNwORhkap8qg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-leaflet": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz", + "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^2.1.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.0.tgz", + "integrity": "sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.0", + "@rollup/rollup-android-arm64": "4.61.0", + "@rollup/rollup-darwin-arm64": "4.61.0", + "@rollup/rollup-darwin-x64": "4.61.0", + "@rollup/rollup-freebsd-arm64": "4.61.0", + "@rollup/rollup-freebsd-x64": "4.61.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.0", + "@rollup/rollup-linux-arm-musleabihf": "4.61.0", + "@rollup/rollup-linux-arm64-gnu": "4.61.0", + "@rollup/rollup-linux-arm64-musl": "4.61.0", + "@rollup/rollup-linux-loong64-gnu": "4.61.0", + "@rollup/rollup-linux-loong64-musl": "4.61.0", + "@rollup/rollup-linux-ppc64-gnu": "4.61.0", + "@rollup/rollup-linux-ppc64-musl": "4.61.0", + "@rollup/rollup-linux-riscv64-gnu": "4.61.0", + "@rollup/rollup-linux-riscv64-musl": "4.61.0", + "@rollup/rollup-linux-s390x-gnu": "4.61.0", + "@rollup/rollup-linux-x64-gnu": "4.61.0", + "@rollup/rollup-linux-x64-musl": "4.61.0", + "@rollup/rollup-openbsd-x64": "4.61.0", + "@rollup/rollup-openharmony-arm64": "4.61.0", + "@rollup/rollup-win32-arm64-msvc": "4.61.0", + "@rollup/rollup-win32-ia32-msvc": "4.61.0", + "@rollup/rollup-win32-x64-gnu": "4.61.0", + "@rollup/rollup-win32-x64-msvc": "4.61.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..46d3892 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "sipkem", + "version": "1.0.0", + "private": true, + "description": "SIPKEM — Sistem Peta Kemiskinan Pontianak", + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.22.0", + "leaflet": "^1.9.4", + "react-leaflet": "^4.2.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.1", + "vite": "^5.1.0" + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + } +} diff --git a/sipkem_database.sql b/sipkem_database.sql new file mode 100644 index 0000000..4b31da9 --- /dev/null +++ b/sipkem_database.sql @@ -0,0 +1,158 @@ +-- ============================================================================= +-- SIPKEM - Sistem Informasi Pemetaan Kemiskinan +-- Database Setup Script +-- ============================================================================= +-- Usage: Run this file in your MySQL/MariaDB client: +-- mysql -u root -p < sipkem_database.sql +-- Or import it via phpMyAdmin / any MySQL GUI tool. +-- ============================================================================= + +-- Create and select the database +CREATE DATABASE IF NOT EXISTS `sipkem` + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +USE `sipkem`; + +-- ============================================================================= +-- TABLE: users +-- Stores both 'pengguna' (regular users) and 'pengurus' (admin) accounts. +-- ============================================================================= +CREATE TABLE IF NOT EXISTS `users` ( + `id` VARCHAR(36) NOT NULL, + `nama` VARCHAR(100) NOT NULL, + `email` VARCHAR(150) NOT NULL, + `password_hash` VARCHAR(255) NOT NULL, + `role` ENUM('pengguna', 'pengurus') NOT NULL DEFAULT 'pengguna', + `dibuat_pada` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_users_email` (`email`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +-- ============================================================================= +-- TABLE: rumah_ibadah +-- Houses of worship (mosques, churches, etc.) used as aid distribution hubs. +-- ============================================================================= +CREATE TABLE IF NOT EXISTS `rumah_ibadah` ( + `id` VARCHAR(36) NOT NULL, + `nama` VARCHAR(150) NOT NULL, + `jenis` VARCHAR(50) NOT NULL COMMENT 'e.g. Masjid, Gereja, Pura, Vihara', + `kontak` VARCHAR(100) NOT NULL DEFAULT '-', + `alamat` TEXT, + `geom` POINT NOT NULL COMMENT 'Spatial point (lng, lat)', + `jangkauan_m` INT NOT NULL DEFAULT 500 COMMENT 'Coverage radius in meters', + `dibuat_pada` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +-- ============================================================================= +-- TABLE: rumah_miskin +-- Low-income households to be mapped and assigned to a rumah_ibadah. +-- ============================================================================= +CREATE TABLE IF NOT EXISTS `rumah_miskin` ( + `id` VARCHAR(36) NOT NULL, + `nama_kk` VARCHAR(150) NOT NULL COMMENT 'Head of household name', + `kontak` VARCHAR(100) NOT NULL DEFAULT '-', + `alamat` TEXT, + `geom` POINT NOT NULL COMMENT 'Spatial point (lng, lat)', + `ri_id` VARCHAR(36) NULL COMMENT 'Assigned rumah_ibadah', + `jarak_ke_ri_m` FLOAT NULL COMMENT 'Distance to assigned rumah_ibadah in meters', + `dibuat_pada` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_rm_ri_id` (`ri_id`), + CONSTRAINT `fk_rm_ri` + FOREIGN KEY (`ri_id`) REFERENCES `rumah_ibadah` (`id`) + ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +-- ============================================================================= +-- TABLE: jenis_bantuan +-- Types / categories of aid that can be given to low-income households. +-- ============================================================================= +CREATE TABLE IF NOT EXISTS `jenis_bantuan` ( + `id` VARCHAR(36) NOT NULL, + `nama` VARCHAR(100) NOT NULL, + `kategori` VARCHAR(50) NOT NULL DEFAULT 'Lainnya', + `deskripsi` TEXT, + `dibuat_pada` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +-- ============================================================================= +-- TABLE: riwayat_bantuan +-- Aid history — records each instance of aid given to a household. +-- ============================================================================= +CREATE TABLE IF NOT EXISTS `riwayat_bantuan` ( + `id` VARCHAR(36) NOT NULL, + `rm_id` VARCHAR(36) NOT NULL, + `bantuan_id` VARCHAR(36) NOT NULL, + `tanggal` DATE NOT NULL, + `catatan` TEXT, + `user_id` VARCHAR(36) NULL COMMENT 'User who confirmed the aid', + `dibuat_pada` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_rb_rm_id` (`rm_id`), + KEY `idx_rb_bantuan_id` (`bantuan_id`), + CONSTRAINT `fk_rb_rm` + FOREIGN KEY (`rm_id`) REFERENCES `rumah_miskin` (`id`) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `fk_rb_bantuan` + FOREIGN KEY (`bantuan_id`) REFERENCES `jenis_bantuan` (`id`) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `fk_rb_user` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) + ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +-- ============================================================================= +-- TABLE: riwayat_binaan +-- Assignment history — tracks when a household is moved between rumah_ibadah. +-- ============================================================================= +CREATE TABLE IF NOT EXISTS `riwayat_binaan` ( + `id` INT NOT NULL AUTO_INCREMENT, + `rm_id` VARCHAR(36) NOT NULL, + `ri_id_lama` VARCHAR(36) NULL COMMENT 'Previous rumah_ibadah', + `ri_id_baru` VARCHAR(36) NULL COMMENT 'New rumah_ibadah', + `alasan` VARCHAR(255) NOT NULL DEFAULT 'otomatis', + `dibuat_pada` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_rib_rm_id` (`rm_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +-- ============================================================================= +-- DEFAULT SEED DATA +-- ============================================================================= + +-- Default 'pengurus' (admin) account +-- Email: admin@sipkem.id | Password: admin1234 +INSERT IGNORE INTO `users` (`id`, `nama`, `email`, `password_hash`, `role`) +VALUES ( + 'admin-0000-0000-0000-000000000001', + 'Administrator', + 'admin@sipkem.id', + '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', -- password: admin1234 + 'pengurus' +); + +-- Common aid types +INSERT IGNORE INTO `jenis_bantuan` (`id`, `nama`, `kategori`, `deskripsi`) VALUES + ('bt-0000-0000-0000-000000000001', 'Sembako', 'Pangan', 'Paket bahan pokok makanan'), + ('bt-0000-0000-0000-000000000002', 'Uang Tunai', 'Finansial', 'Bantuan langsung tunai'), + ('bt-0000-0000-0000-000000000003', 'Pakaian Layak', 'Sandang', 'Pakaian bekas layak pakai'), + ('bt-0000-0000-0000-000000000004', 'Biaya Sekolah', 'Pendidikan', 'Bantuan biaya pendidikan anak'), + ('bt-0000-0000-0000-000000000005', 'Obat-obatan', 'Kesehatan', 'Obat dan kebutuhan medis dasar'); + +-- ============================================================================= +-- DONE +-- ============================================================================= +-- After running this script: +-- 1. Open api.php and set DB_HOST, DB_USER, DB_PASS to match your environment. +-- 2. Default admin login: admin@sipkem.id / admin1234 (change this immediately). +-- 3. Run `npm install && npm run dev` in the project folder to start the frontend. +-- ============================================================================= diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..24b6585 --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,13 @@ +import { Routes, Route, Navigate } from 'react-router-dom' +import AuthPage from './pages/AuthPage' +import MapPage from './pages/MapPage' + +export default function App() { + return ( + + } /> + } /> + } /> + + ) +} diff --git a/src/components/BantuanModal.jsx b/src/components/BantuanModal.jsx new file mode 100644 index 0000000..998e2f2 --- /dev/null +++ b/src/components/BantuanModal.jsx @@ -0,0 +1,381 @@ +import { useEffect, useState, useRef } from 'react' +import { apiCall, genUUID } from '../utils/api' + +// ── ADD BANTUAN TO A HOUSE ──────────────────────────────────────────────── +export function AddBantuanModal({ rm, open, onClose, onSuccess }) { + const [bantuanTypes, setBantuanTypes] = useState([]) + const [selected, setSelected] = useState('') + const [catatan, setCatatan] = useState('') + const [loading, setLoading] = useState(false) + const [fetching, setFetching] = useState(false) + const [submitError, setSubmitError] = useState('') + + useEffect(() => { + if (!open) return + setSelected('') + setCatatan('') + setSubmitError('') + setFetching(true) + apiCall('load_bantuan_types').then(res => { + setFetching(false) + if (res.ok) setBantuanTypes(res.data || []) + }) + }, [open]) + + async function handleSubmit() { + if (!selected) return + setSubmitError('') + setLoading(true) + const id = genUUID() + const tanggal = new Date().toISOString().slice(0, 10) + try { + const res = await apiCall('add_riwayat_bantuan', { + id, + rm_id: rm?.id, + bantuan_id: selected, + tanggal, + catatan: catatan.trim() || null, + }) + setLoading(false) + if (res.ok) { + onSuccess?.() + onClose() + } else { + setSubmitError(res.error || 'Gagal menyimpan bantuan. Coba lagi.') + } + } catch (e) { + setLoading(false) + setSubmitError('Tidak dapat terhubung ke server.') + } + } + + if (!open) return null + + const selectedType = bantuanTypes.find(b => b.id === selected) + + return ( +
{ if (e.target === e.currentTarget) onClose() }}> +
+
+
+
+
Tambah Bantuan
+
{rm?.name || '—'}
+
+ +
+ +
+
+ {fetching ? ( +
+ Memuat data bantuan… +
+ ) : bantuanTypes.length === 0 ? ( +
+ Belum ada jenis bantuan.
Tambah dulu di menu Kelola Bantuan. +
+ ) : ( + <> +
+ + +
+ + {selectedType && ( +
+ + {selectedType.kategori} + {selectedType.deskripsi && ( + — {selectedType.deskripsi} + )} +
+ )} + +
+ + setCatatan(e.target.value)} + /> +
+ + )} +
+
+ +
+ {submitError && ( +
+ ⚠️ {submitError} +
+ )} +
+ + +
+
+
+
+ ) +} + +// ── HISTORY BANTUAN PANEL ───────────────────────────────────────────────── +export function HistoryBantuanModal({ rm, open, onClose }) { + const [history, setHistory] = useState([]) + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (!open || !rm) return + setLoading(true) + apiCall('load_riwayat_bantuan', { rm_id: rm.id }).then(res => { + setLoading(false) + if (res.ok) setHistory(res.data || []) + }) + }, [open, rm]) + + if (!open) return null + + return ( +
{ if (e.target === e.currentTarget) onClose() }}> +
+
+
+
+
Riwayat Bantuan
+
{rm?.name || '—'}
+
+ +
+ +
+ {loading ? ( +
+ Memuat riwayat… +
+ ) : history.length === 0 ? ( +
+
📭
+
Belum ada riwayat bantuan untuk rumah ini.
+
+ ) : ( +
+ {history.map((h, i) => ( +
+
+
+
+
+
+
{h.bantuan_nama}
+
+ + {h.kategori} + + {formatDate(h.tanggal)} +
+ {h.catatan && ( +
📝 {h.catatan}
+ )} + {h.dikonfirmasi_oleh && ( +
oleh {h.dikonfirmasi_oleh}
+ )} +
+
+ ))} +
+ )} +
+ +
+ +
+
+
+ ) +} + +// ── MANAGE BANTUAN TYPES (Pengurus) ────────────────────────────────────── +export function ManageBantuanModal({ open, onClose }) { + const [types, setTypes] = useState([]) + const [loading, setLoading] = useState(false) + const [adding, setAdding] = useState(false) + const [form, setForm] = useState({ nama: '', kategori: 'Sembako', deskripsi: '' }) + const [errors, setErrors] = useState({}) + const nameRef = useRef(null) + + const CATEGORIES = ['Sembako', 'Kesehatan', 'Pendidikan', 'Sandang', 'Finansial', 'Lainnya'] + + useEffect(() => { + if (!open) return + fetchTypes() + }, [open]) + + async function fetchTypes() { + setLoading(true) + const res = await apiCall('load_bantuan_types') + setLoading(false) + if (res.ok) setTypes(res.data || []) + } + + async function handleAdd() { + const errs = {} + if (!form.nama.trim()) errs.nama = 'Nama tidak boleh kosong.' + if (Object.keys(errs).length) { setErrors(errs); return } + setErrors({}) + setAdding(true) + const id = genUUID() + const res = await apiCall('add_bantuan_type', { + id, + nama: form.nama.trim(), + kategori: form.kategori, + deskripsi: form.deskripsi.trim() || null, + }) + setAdding(false) + if (res.ok) { + setForm({ nama: '', kategori: 'Sembako', deskripsi: '' }) + fetchTypes() + } + } + + async function handleDelete(id, nama) { + if (!window.confirm(`Hapus jenis bantuan "${nama}"?`)) return + await apiCall('delete_bantuan_type', { id }) + fetchTypes() + } + + if (!open) return null + + return ( +
{ if (e.target === e.currentTarget) onClose() }}> +
+
+
+
+
Kelola Jenis Bantuan
+
Daftar bantuan yang tersedia
+
+ +
+ +
+ {/* Add form */} +
+
+ Tambah Jenis Bantuan +
+
+ + { setForm(f => ({ ...f, nama: e.target.value })); setErrors({}) }} + onKeyDown={e => e.key === 'Enter' && handleAdd()} + /> + {errors.nama &&
{errors.nama}
} +
+
+ + +
+
+ + setForm(f => ({ ...f, deskripsi: e.target.value }))} + /> +
+
+ +
+
+ + {/* List */} +
+
+ Daftar Bantuan ({types.length}) +
+ {loading ? ( +
Memuat…
+ ) : types.length === 0 ? ( +
Belum ada jenis bantuan.
+ ) : ( +
+ {types.map(t => ( +
+
+
+
{t.nama}
+
+ + {t.kategori} + + {t.deskripsi && {t.deskripsi}} +
+
+ +
+ ))} +
+ )} +
+
+ +
+ +
+
+
+ ) +} + +// ── HELPERS ─────────────────────────────────────────────────────────────── +function getCategoryColor(cat) { + const map = { + Sembako: '#16a34a', + Kesehatan: '#2563eb', + Pendidikan: '#7c3aed', + Sandang: '#d97706', + Finansial: '#0891b2', + Lainnya: '#8e8e93', + } + return map[cat] || '#8e8e93' +} + +function formatDate(str) { + if (!str) return '—' + try { + return new Date(str).toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' }) + } catch { + return str + } +} \ No newline at end of file diff --git a/src/components/ConfirmDialog.jsx b/src/components/ConfirmDialog.jsx new file mode 100644 index 0000000..e0703a2 --- /dev/null +++ b/src/components/ConfirmDialog.jsx @@ -0,0 +1,15 @@ +export default function ConfirmDialog({ state, onChoice }) { + if (!state.open) return null + return ( +
onChoice(false)}> +
e.stopPropagation()}> +
Konfirmasi Hapus
+
{state.message}
+
+ + +
+
+
+ ) +} diff --git a/src/components/LegendPanel.jsx b/src/components/LegendPanel.jsx new file mode 100644 index 0000000..cad48e5 --- /dev/null +++ b/src/components/LegendPanel.jsx @@ -0,0 +1,47 @@ +import { useState } from 'react' + +export default function LegendPanel() { + const [open, setOpen] = useState(true) + + return ( +
+
setOpen(o => !o)}> +
Legenda
+
+
+ +
+
+
Rumah Ibadah
+ {[ + ['#059669', 'Masjid'], + ['#2563eb', 'Gereja'], + ['#d97706', 'Pura'], + ['#7c3aed', 'Vihara'], + ['#e11d48', 'Klenteng'], + ['#8e8e93', 'Lainnya'], + ].map(([color, label]) => ( +
+
+ {label} +
+ ))} +
+ +
+ +
+
Rumah Miskin
+
+
+ Di luar jangkauan +
+
+
+ Dalam binaan +
+
+
+
+ ) +} diff --git a/src/components/Modal.jsx b/src/components/Modal.jsx new file mode 100644 index 0000000..26c3ff6 --- /dev/null +++ b/src/components/Modal.jsx @@ -0,0 +1,104 @@ +import { useEffect, useRef } from 'react' +import { RI_TYPES } from '../utils/api' + +export default function Modal({ state, onClose, onSubmit }) { + const { open, mode, target, pendingLL } = state + const nameRef = useRef(null) + const kontakRef = useRef(null) + const typeRef = useRef(null) + + const isRI = mode?.endsWith('ri') + const isAdd = mode?.startsWith('add') + + const title = isAdd + ? (isRI ? 'Rumah Ibadah Baru' : 'Rumah Miskin Baru') + : (isRI ? 'Edit Rumah Ibadah' : 'Edit Rumah Miskin') + + const sub = isAdd && pendingLL + ? `${pendingLL.lat.toFixed(5)}, ${pendingLL.lng.toFixed(5)}` + : (target?.name || '') + + useEffect(() => { + if (open) { + setTimeout(() => nameRef.current?.focus(), 120) + } + }, [open]) + + useEffect(() => { + if (!open) return + const handler = (e) => { + if (e.key === 'Escape') onClose() + if (e.key === 'Enter') handleSubmit() + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }) + + function handleSubmit() { + const name = nameRef.current?.value.trim() + if (!name) { + nameRef.current?.classList.add('err') + nameRef.current?.focus() + return + } + nameRef.current?.classList.remove('err') + const kontak = kontakRef.current?.value.trim() || '-' + const type = typeRef.current?.value || 'Masjid' + onSubmit({ name, kontak, type }) + } + + return ( +
{ if (e.target === e.currentTarget) onClose() }}> +
+
+
+
+
{title}
+
{sub}
+
+ +
+ +
+
+
+ + +
+ {isRI && ( +
+ + +
+ )} +
+ + +
+
+
+ +
+ + +
+
+
+ ) +} diff --git a/src/components/ModePanel.jsx b/src/components/ModePanel.jsx new file mode 100644 index 0000000..1c4ba1f --- /dev/null +++ b/src/components/ModePanel.jsx @@ -0,0 +1,87 @@ +export default function ModePanel({ mode, onSetMode, role, onLogout, onManageBantuan }) { + const isPengurus = role === 'pengurus' + + const badge = { + select: { cls: 'mb-sel', dot: 'bd-sel', label: 'Mode Lihat' }, + 'add-ri': { cls: 'mb-ri', dot: 'bd-ri', label: 'Tambah Rumah Ibadah' }, + 'add-rm': { cls: 'mb-rm', dot: 'bd-rm', label: 'Tambah Rumah Miskin' }, + }[mode] || { cls: 'mb-sel', dot: 'bd-sel', label: 'Mode Lihat' } + + const hint = isPengurus + ? { + select: 'Klik marker untuk detail. Seret untuk memindahkan.', + 'add-ri': 'Klik peta untuk menentukan lokasi.', + 'add-rm': 'Klik peta untuk menentukan lokasi.', + }[mode] + : 'Klik marker untuk melihat detail data.' + + return ( +
+
+
+
+
SIPKEM
+
Peta Kemiskinan · Pontianak
+
+ +
+
+ + {badge.label} +
+
+ {isPengurus ? ( + <> + + + + Pengurus + + ) : ( + <> + + + + Pengguna + + )} +
+
+ + {isPengurus && ( +
+
Mode Input
+ + + + +
Bantuan
+ +
+ )} + +
{hint}
+
+ ) +} diff --git a/src/components/StatsPanel.jsx b/src/components/StatsPanel.jsx new file mode 100644 index 0000000..55c28ee --- /dev/null +++ b/src/components/StatsPanel.jsx @@ -0,0 +1,28 @@ +export default function StatsPanel({ riList, rmList }) { + const covered = rmList.filter(r => r.assignedTo !== null).length + const uncovered = rmList.length - covered + + return ( +
+
Statistik
+
+
+
{riList.length}
+
Rumah
Ibadah
+
+
+
{rmList.length}
+
Rumah
Miskin
+
+
+
{covered}
+
Terjangkau
+
+
+
{uncovered}
+
Belum
Terjangkau
+
+
+
+ ) +} diff --git a/src/components/Toast.jsx b/src/components/Toast.jsx new file mode 100644 index 0000000..d385952 --- /dev/null +++ b/src/components/Toast.jsx @@ -0,0 +1,7 @@ +export default function Toast({ message, visible }) { + return ( +
+ {message} +
+ ) +} diff --git a/src/hooks/useConfirm.js b/src/hooks/useConfirm.js new file mode 100644 index 0000000..7a4491d --- /dev/null +++ b/src/hooks/useConfirm.js @@ -0,0 +1,18 @@ +import { useState, useCallback } from 'react' + +export function useConfirm() { + const [state, setState] = useState({ open: false, message: '', resolve: null }) + + const confirm = useCallback((message) => { + return new Promise((resolve) => { + setState({ open: true, message, resolve }) + }) + }, []) + + const handleChoice = useCallback((result) => { + state.resolve?.(result) + setState({ open: false, message: '', resolve: null }) + }, [state]) + + return { confirmState: state, confirm, handleChoice } +} diff --git a/src/hooks/useToast.js b/src/hooks/useToast.js new file mode 100644 index 0000000..d051d35 --- /dev/null +++ b/src/hooks/useToast.js @@ -0,0 +1,16 @@ +import { useState, useCallback, useRef } from 'react' + +export function useToast() { + const [message, setMessage] = useState('') + const [visible, setVisible] = useState(false) + const timerRef = useRef(null) + + const showToast = useCallback((msg, duration = 2500) => { + if (timerRef.current) clearTimeout(timerRef.current) + setMessage(msg) + setVisible(true) + timerRef.current = setTimeout(() => setVisible(false), duration) + }, []) + + return { message, visible, showToast } +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..3e146ff --- /dev/null +++ b/src/index.css @@ -0,0 +1,273 @@ +*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } + +:root { + --white: #ffffff; + --off-white: #f7f8f7; + --border: rgba(0,0,0,0.08); + --sep: rgba(60,60,67,0.12); + --fill-1: rgba(120,120,128,0.10); + --fill-2: rgba(120,120,128,0.06); + --txt-1: #1c1c1e; + --txt-2: #636366; + --txt-3: #aeaeb2; + --shadow: 0 8px 32px rgba(0,0,0,0.10), 0 1.5px 4px rgba(0,0,0,0.05); + --glass: rgba(255,255,255,0.80); + --blur: blur(20px) saturate(180%); + + --ri-color: #1c8a5f; + --ri-light: rgba(28,138,95,0.10); + --ri-mid: rgba(28,138,95,0.18); + --rm-color: #c84b0f; + --rm-light: rgba(200,75,15,0.10); + + --blue: #007aff; + --green: #34c759; + --red: #ff3b30; + + --r-xl: 22px; + --r-lg: 16px; + --r-md: 12px; + --r-sm: 8px; + + --sys: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif; + --serif: 'DM Serif Display', Georgia, serif; + --trans: all 0.20s cubic-bezier(0.4, 0, 0.2, 1); +} + +html, body, #root { + height: 100%; + width: 100%; + font-family: var(--sys); + background: var(--off-white); + color: var(--txt-1); + overflow: hidden; +} + +/* ── LEAFLET OVERRIDES ── */ +.leaflet-control-zoom { + border: none !important; + box-shadow: var(--shadow) !important; +} +.leaflet-control-zoom a { + background: var(--glass) !important; + backdrop-filter: var(--blur) !important; + -webkit-backdrop-filter: var(--blur) !important; + color: var(--txt-1) !important; + border: none !important; + border-bottom: 1px solid var(--sep) !important; + font-size: 16px !important; + font-weight: 300 !important; + width: 36px !important; + height: 36px !important; + line-height: 36px !important; +} +.leaflet-control-zoom-in { border-radius: var(--r-md) var(--r-md) 0 0 !important; } +.leaflet-control-zoom-out { border-radius: 0 0 var(--r-md) var(--r-md) !important; border-bottom: none !important; } +.leaflet-control-attribution { + background: rgba(255,255,255,.62) !important; + backdrop-filter: blur(8px) !important; + border-radius: 8px 0 0 0 !important; + font-size: 9px !important; + color: var(--txt-3) !important; +} + +/* ── POPUP ── */ +.leaflet-popup-content-wrapper { + border-radius: 16px !important; + box-shadow: 0 12px 40px rgba(0,0,0,.18), 0 2px 6px rgba(0,0,0,.07) !important; + padding: 0 !important; + overflow: hidden; + border: 1px solid rgba(0,0,0,.06) !important; +} +.leaflet-popup-content { margin: 0 !important; min-width: 264px; } +.leaflet-popup-tip-container { display: none; } +.leaflet-popup-close-button { + top: 10px !important; + right: 10px !important; + color: rgba(255,255,255,.65) !important; + font-size: 18px !important; + z-index: 1; +} + +/* ── TOAST ── */ +.sipkem-toast { + position: fixed; + bottom: 100px; + left: 50%; + transform: translateX(-50%); + background: rgba(28,28,30,.86); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + color: #fff; + padding: 9px 20px; + border-radius: 22px; + font-size: 13px; + font-weight: 500; + pointer-events: none; + opacity: 0; + transition: opacity .22s; + z-index: 9000; + white-space: nowrap; + box-shadow: 0 4px 16px rgba(0,0,0,.2); +} +.sipkem-toast.show { opacity: 1; } + +/* ── PANEL ── */ +.panel { + position: fixed; + background: var(--glass); + backdrop-filter: var(--blur); + -webkit-backdrop-filter: var(--blur); + border: 1px solid var(--border); + border-radius: var(--r-lg); + box-shadow: var(--shadow); + z-index: 1000; + overflow: hidden; +} + +/* ── RANGE INPUT ── */ +input[type="range"] { + width: 100%; + cursor: pointer; + -webkit-appearance: none; + appearance: none; + height: 4px; + border-radius: 2px; + outline: none; +} +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 20px; + height: 20px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 4px rgba(0,0,0,.22); + cursor: pointer; +} + +/* ── POPUP CONTENT ── */ +.pu-wrap { font-family: var(--sys); } +.pu-head { padding: 14px 15px 11px; color: #fff; } +.pu-badge { font-size: 10px; font-weight: 600; letter-spacing: .4px; text-transform: uppercase; opacity: .75; display: block; margin-bottom: 3px; } +.pu-name { font-size: 16px; font-weight: 700; letter-spacing: -.3px; line-height: 1.2; } +.pu-body { padding: 10px 15px 8px; background: #fff; } +.irow { display: flex; gap: 8px; align-items: flex-start; margin-bottom: 6px; font-size: 12.5px; color: var(--txt-1); } +.iico { font-size: 13px; flex-shrink: 0; line-height: 1.35; } +.iaddr { font-size: 11.5px; color: var(--txt-2); line-height: 1.4; } +.icoords { font-family: 'SF Mono','Fira Code', monospace; font-size: 10px; color: var(--txt-3); } + +.pu-range { padding: 10px 15px 11px; background: #f2f2f7; border-top: .5px solid var(--sep); } +.rlbl { display: flex; justify-content: space-between; align-items: center; font-size: 11px; font-weight: 600; color: var(--txt-2); margin-bottom: 7px; } +.rval { font-family: monospace; font-size: 12px; font-weight: 700; color: var(--ri-color); background: rgba(28,138,95,.1); padding: 1px 7px; border-radius: 6px; } +.rticks { display: flex; justify-content: space-between; font-size: 9px; color: var(--txt-3); margin-top: 4px; font-family: monospace; } + +.pu-binaan { padding: 9px 15px 10px; background: #f9fff9; border-top: .5px solid rgba(52,199,89,.25); } +.binhd { font-size: 10px; font-weight: 700; letter-spacing: .4px; text-transform: uppercase; color: #1d9e3e; margin-bottom: 6px; } +.binitem { display: flex; align-items: center; gap: 7px; font-size: 12px; color: var(--txt-1); padding: 3px 0; border-bottom: .5px solid rgba(0,0,0,.05); } +.binitem:last-child { border-bottom: none; } +.bindist { color: var(--txt-3); font-size: 10px; margin-left: auto; font-family: monospace; } +.binempty { font-size: 11.5px; color: var(--txt-3); font-style: italic; } + +.pu-asgn { padding: 9px 15px; background: #fff; border-top: .5px solid var(--sep); } +.asgn-lbl { font-size: 10px; font-weight: 600; letter-spacing: .4px; text-transform: uppercase; color: var(--txt-2); margin-bottom: 5px; } +.achip { display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; } +.ayes { background: rgba(52,199,89,.12); color: #1d9e3e; } +.ano { background: rgba(255,59,48,.10); color: #c0392b; } + +.pu-acts { display: grid; grid-template-columns: 1fr 1fr; border-top: .5px solid var(--sep); } +.pu-btn { padding: 11px 0; border: none; background: #fff; font-family: var(--sys); font-size: 13.5px; font-weight: 500; cursor: pointer; transition: background .12s; } +.pu-btn:hover { background: #f2f2f7; } +.pu-edit { color: var(--blue); border-right: .5px solid var(--sep); } +.pu-del { color: var(--red); } + +/* ── MODAL ── */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,.4); + z-index: 9999; + display: flex; + align-items: flex-end; + justify-content: center; + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + opacity: 0; + pointer-events: none; + transition: opacity 0.2s; +} +.modal-overlay.show { opacity: 1; pointer-events: all; } + +.modal-box { + background: #f2f2f7; + border-radius: 22px 22px 0 0; + width: 100%; + max-width: 460px; + box-shadow: 0 -4px 40px rgba(0,0,0,.22); + overflow: hidden; + transform: translateY(100%); + transition: transform 0.26s cubic-bezier(.32,.72,0,1); +} +.modal-overlay.show .modal-box { transform: translateY(0); } + +.modal-drag { display: flex; justify-content: center; padding: 10px 0 4px; } +.modal-bar { width: 36px; height: 4px; border-radius: 2px; background: rgba(60,60,67,.25); } +.modal-hd { padding: 4px 20px 14px; display: flex; align-items: center; justify-content: space-between; } +.modal-ttl { font-size: 17px; font-weight: 700; letter-spacing: -.4px; color: var(--txt-1); } +.modal-sub { font-size: 11.5px; color: var(--txt-2); margin-top: 1px; font-family: monospace; } +.modal-x { width: 28px; height: 28px; border-radius: 50%; background: var(--fill-1); border: none; display: flex; align-items: center; justify-content: center; font-size: 13px; color: var(--txt-2); cursor: pointer; font-weight: 700; } +.modal-x:hover { background: var(--fill-2); } + +.modal-body-wrap { padding: 0 16px 12px; } +.fcard { background: #fff; border-radius: var(--r-md); overflow: hidden; margin-bottom: 10px; } +.frow { display: flex; align-items: center; padding: 0 14px; border-bottom: .5px solid var(--sep); min-height: 44px; } +.frow:last-child { border-bottom: none; } +.flbl { font-size: 13.5px; font-weight: 400; color: var(--txt-1); width: 90px; flex-shrink: 0; } +.finput, .fsel { + flex: 1; border: none; outline: none; font-family: var(--sys); + font-size: 13.5px; color: var(--txt-1); background: transparent; + padding: 12px 0; text-align: right; +} +.finput::placeholder { color: var(--txt-3); } +.finput.err { color: var(--red); } +.fsel { cursor: pointer; color: var(--blue); } + +.modal-ft { padding: 6px 16px 28px; display: flex; gap: 10px; } +.btn-can { flex: 1; padding: 13px; border: none; border-radius: var(--r-md); background: #fff; font-family: var(--sys); font-size: 15px; font-weight: 500; color: var(--txt-2); cursor: pointer; } +.btn-can:hover { background: #e8e8ed; } +.btn-ok { flex: 2; padding: 13px; border: none; border-radius: var(--r-md); font-family: var(--sys); font-size: 15px; font-weight: 600; cursor: pointer; color: #fff; transition: opacity .15s; } +.btn-ok:hover { opacity: .88; } +.btn-ok.ri { background: var(--ri-color); } +.btn-ok.rm { background: var(--rm-color); } +.btn-ok:disabled { opacity: .5; cursor: not-allowed; } + +/* ── CONFIRM DIALOG ── */ +.confirm-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,.45); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(6px); + animation: fadeIn .15s ease; +} +@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } } +.confirm-box { + background: var(--white); + border-radius: 18px; + padding: 24px 24px 16px; + max-width: 320px; + width: calc(100% - 48px); + box-shadow: 0 20px 60px rgba(0,0,0,.22); + text-align: center; + animation: popIn .18s cubic-bezier(.32,.72,0,1); +} +@keyframes popIn { from { transform: scale(.9); opacity: 0 } to { transform: scale(1); opacity: 1 } } +.confirm-title { font-size: 16px; font-weight: 700; color: var(--txt-1); margin-bottom: 8px; } +.confirm-msg { font-size: 13.5px; color: var(--txt-2); line-height: 1.5; margin-bottom: 20px; } +.confirm-btns { display: flex; gap: 8px; } +.confirm-btns button { flex: 1; padding: 11px; border: none; border-radius: 12px; font-family: var(--sys); font-size: 14px; font-weight: 600; cursor: pointer; transition: opacity .12s; } +.confirm-btns button:hover { opacity: .85; } +.confirm-cancel { background: var(--fill-1); color: var(--txt-2); } +.confirm-ok { background: var(--red); color: #fff; } diff --git a/src/main.jsx b/src/main.jsx new file mode 100644 index 0000000..40bea91 --- /dev/null +++ b/src/main.jsx @@ -0,0 +1,13 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + + + +) diff --git a/src/pages/AuthPage.css b/src/pages/AuthPage.css new file mode 100644 index 0000000..40b74f8 --- /dev/null +++ b/src/pages/AuthPage.css @@ -0,0 +1,533 @@ +/* ── AUTH PAGES ────────────────────────────────────────────────── */ + +.auth-root { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +/* Shared background */ +.auth-bg-layer { + position: fixed; + inset: 0; + z-index: 0; + background: var(--off-white); +} +.auth-bg-layer::before { + content: ''; + position: absolute; + inset: 0; + background-image: radial-gradient(circle, rgba(0,0,0,0.045) 1px, transparent 1px); + background-size: 28px 28px; +} +.auth-bg-layer::after { + content: ''; + position: absolute; + top: -160px; right: -160px; + width: 500px; height: 500px; + border-radius: 50%; + background: radial-gradient(circle, rgba(28,138,95,0.06) 0%, transparent 65%); +} + +/* ── SCREEN TRANSITIONS ── */ +.auth-screen { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 1; + opacity: 0; + pointer-events: none; + transform: translateY(16px); + transition: opacity 0.30s ease, transform 0.30s cubic-bezier(0.4,0,0.2,1); +} +.auth-screen.active { + opacity: 1; + pointer-events: all; + transform: translateY(0); +} + +/* ── LANDING SCREEN ── */ +.land-screen-bg { + position: fixed; + inset: 0; + background: var(--white); + z-index: 0; +} +.land-bg-dots { + position: absolute; + inset: 0; + background-image: radial-gradient(circle, rgba(0,0,0,0.055) 1px, transparent 1px); + background-size: 28px 28px; + pointer-events: none; +} +.land-bg-dots::before { + content: ''; + position: absolute; + top: -120px; right: -120px; + width: 420px; height: 420px; + border-radius: 50%; + background: radial-gradient(circle, rgba(28,138,95,0.07) 0%, transparent 65%); +} +.land-bg-dots::after { + content: ''; + position: absolute; + bottom: -80px; left: -80px; + width: 320px; height: 320px; + border-radius: 50%; + background: radial-gradient(circle, rgba(200,75,15,0.05) 0%, transparent 65%); +} +.land-lines-svg { + position: absolute; + inset: 0; + pointer-events: none; +} + +.land-card { + position: relative; + z-index: 10; + background: var(--white); + border: 1px solid var(--border); + border-radius: var(--r-xl); + box-shadow: var(--shadow); + padding: 52px 52px 44px; + max-width: 480px; + width: calc(100% - 32px); + text-align: center; +} + +.land-badge { + display: inline-flex; + align-items: center; + gap: 7px; + background: var(--ri-light); + border: 1px solid rgba(28,138,95,0.18); + border-radius: 40px; + padding: 4px 13px 4px 9px; + font-size: 11.5px; + font-weight: 600; + color: var(--ri-color); + letter-spacing: 0.2px; + margin-bottom: 28px; +} +.badge-dot { + width: 6px; height: 6px; + border-radius: 50%; + background: var(--ri-color); + animation: blink 2.4s ease infinite; +} +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} + +.land-logo { + font-family: var(--serif); + font-size: 68px; + line-height: 1; + color: var(--txt-1); + letter-spacing: -2px; + margin-bottom: 6px; +} +.land-logo em { + font-style: italic; + color: var(--ri-color); +} +.land-subtitle { + font-size: 11px; + font-weight: 600; + letter-spacing: 3px; + text-transform: uppercase; + color: var(--txt-3); + margin-bottom: 24px; +} +.land-rule { + width: 40px; height: 1.5px; + background: var(--sep); + margin: 0 auto 24px; +} +.land-desc { + font-size: 14.5px; + color: var(--txt-2); + line-height: 1.7; + max-width: 360px; + margin: 0 auto 36px; +} +.land-desc strong { color: var(--txt-1); font-weight: 600; } + +.btn-enter { + display: inline-flex; + align-items: center; + gap: 9px; + background: var(--ri-color); + color: #fff; + border: none; + border-radius: var(--r-lg); + padding: 14px 32px; + font-family: var(--sys); + font-size: 14.5px; + font-weight: 600; + cursor: pointer; + transition: var(--trans); + box-shadow: 0 4px 20px rgba(28,138,95,0.28); + letter-spacing: 0.1px; +} +.btn-enter:hover { + background: #178054; + box-shadow: 0 6px 28px rgba(28,138,95,0.36); + transform: translateY(-1px); +} +.btn-enter:active { transform: translateY(0); } +.btn-enter svg { transition: transform 0.18s ease; } +.btn-enter:hover svg { transform: translateX(3px); } + +.land-features { + display: flex; + gap: 10px; + justify-content: center; + flex-wrap: wrap; + margin-top: 36px; + padding-top: 28px; + border-top: 1px solid var(--sep); +} +.feat-chip { + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--fill-1); + border: 1px solid var(--border); + border-radius: 40px; + padding: 5px 13px 5px 9px; + font-size: 12px; + font-weight: 500; + color: var(--txt-2); +} + +/* ── AUTH CARD ── */ +.auth-card { + position: relative; + z-index: 10; + background: var(--white); + border: 1px solid var(--border); + border-radius: var(--r-xl); + box-shadow: var(--shadow); + width: 100%; + max-width: 420px; + padding: 36px 36px 32px; + margin: 20px; +} + +.card-back { + display: flex; + align-items: center; + gap: 7px; + font-size: 13px; + font-weight: 500; + color: var(--txt-3); + cursor: pointer; + border: none; + background: none; + padding: 0; + margin-bottom: 24px; + transition: color 0.16s; + font-family: var(--sys); +} +.card-back:hover { color: var(--txt-1); } + +.step-dots { display: flex; gap: 5px; margin-bottom: 24px; } +.step-dot { + height: 4px; + border-radius: 2px; + background: var(--fill-1); + transition: all 0.28s ease; + width: 20px; +} +.step-dot.active { background: var(--ri-color); width: 32px; } +.step-dot.done { background: rgba(28,138,95,0.35); } + +.card-eyebrow { + font-size: 11px; + font-weight: 600; + letter-spacing: 1.8px; + text-transform: uppercase; + color: var(--ri-color); + margin-bottom: 7px; +} +.card-title { + font-family: var(--serif); + font-size: 28px; + color: var(--txt-1); + line-height: 1.15; + margin-bottom: 5px; + letter-spacing: -0.4px; +} +.card-sub { + font-size: 13.5px; + color: var(--txt-2); + margin-bottom: 28px; + line-height: 1.5; +} + +/* ── ROLE CARDS ── */ +.role-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 18px; +} +.role-card { + border: 1.5px solid var(--border); + border-radius: var(--r-lg); + padding: 22px 16px; + cursor: pointer; + transition: var(--trans); + text-align: center; + background: var(--white); + position: relative; + overflow: hidden; +} +.role-card:hover { border-color: rgba(28,138,95,0.3); background: rgba(28,138,95,0.02); } +.role-card.pengurus:hover { border-color: rgba(200,75,15,0.3); background: rgba(200,75,15,0.02); } +.role-card.pengguna.selected { border-color: var(--ri-color); background: rgba(28,138,95,0.03); box-shadow: 0 0 0 3px rgba(28,138,95,0.10); } +.role-card.pengurus.selected { border-color: var(--rm-color); background: rgba(200,75,15,0.03); box-shadow: 0 0 0 3px rgba(200,75,15,0.10); } + +.check-badge { + position: absolute; + top: 9px; right: 9px; + width: 20px; height: 20px; + border-radius: 50%; + display: flex; align-items: center; justify-content: center; + opacity: 0; + transform: scale(0.5); + transition: var(--trans); +} +.role-card.pengguna .check-badge { background: var(--ri-color); } +.role-card.pengurus .check-badge { background: var(--rm-color); } +.role-card.selected .check-badge { opacity: 1; transform: scale(1); } + +.role-icon { + width: 48px; height: 48px; + border-radius: 14px; + display: flex; align-items: center; justify-content: center; + margin: 0 auto 12px; + font-size: 22px; + background: var(--fill-1); +} +.role-card.pengguna .role-icon { background: var(--ri-light); } +.role-card.pengurus .role-icon { background: var(--rm-light); } + +.role-name { font-size: 14px; font-weight: 600; color: var(--txt-1); margin-bottom: 4px; } +.role-desc { font-size: 11.5px; color: var(--txt-3); line-height: 1.4; } + +/* ── FORM ELEMENTS ── */ +.form-group { margin-bottom: 16px; } +.form-label { display: block; font-size: 12.5px; font-weight: 500; color: var(--txt-2); margin-bottom: 6px; } +.form-input { + width: 100%; + padding: 11px 14px; + border: 1.5px solid rgba(0,0,0,0.10); + border-radius: var(--r-md); + font-family: var(--sys); + font-size: 14px; + color: var(--txt-1); + background: var(--off-white); + outline: none; + transition: border-color 0.16s, box-shadow 0.16s, background 0.16s; +} +.form-input:hover { border-color: rgba(0,0,0,0.18); } +.form-input:focus { border-color: var(--ri-color); box-shadow: 0 0 0 3px rgba(28,138,95,0.10); background: var(--white); } +.form-input.err { border-color: #e05050; box-shadow: 0 0 0 3px rgba(224,80,80,0.10); } +.form-input::placeholder { color: var(--txt-3); } + +.input-wrap { position: relative; } +.input-wrap .form-input { padding-right: 42px; } +.input-eye { + position: absolute; + right: 12px; top: 50%; + transform: translateY(-50%); + background: none; border: none; + color: var(--txt-3); cursor: pointer; + padding: 4px; line-height: 1; + transition: color 0.14s; +} +.input-eye:hover { color: var(--txt-1); } + +.form-error { + font-size: 11.5px; + color: #e05050; + margin-top: 5px; + display: none; +} +.form-error.show { display: block; } + +/* ── PASSWORD STRENGTH ── */ +.strength-segs { + display: flex; + gap: 4px; + margin-top: 8px; +} +.strength-segs span { + flex: 1; + height: 3px; + border-radius: 2px; + background: rgba(120,120,128,0.12); + transition: background 0.2s; +} +.strength-label { + font-size: 11px; + margin-top: 4px; + font-weight: 500; +} + +/* ── BUTTONS ── */ +.btn-primary { + width: 100%; + padding: 13px; + border: none; + border-radius: var(--r-md); + font-family: var(--sys); + font-size: 15px; + font-weight: 600; + cursor: pointer; + color: #fff; + position: relative; + overflow: hidden; + transition: opacity 0.15s, transform 0.12s; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} +.btn-primary.green { background: var(--ri-color); box-shadow: 0 4px 16px rgba(28,138,95,0.28); } +.btn-primary.earth { background: var(--rm-color); box-shadow: 0 4px 16px rgba(200,75,15,0.28); } +.btn-primary:hover:not(:disabled) { opacity: .88; transform: translateY(-1px); } +.btn-primary:active:not(:disabled) { transform: translateY(0); } +.btn-primary:disabled { opacity: .5; cursor: not-allowed; } +.btn-primary.loading .btn-text { opacity: 0; } +.btn-primary.loading .spinner { display: block; } +.spinner { + display: none; + position: absolute; + width: 18px; height: 18px; + border: 2.5px solid rgba(255,255,255,0.3); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.7s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +.ripple { + position: absolute; + width: 10px; height: 10px; + border-radius: 50%; + background: rgba(255,255,255,0.38); + animation: rippleOut 0.5s ease-out forwards; + pointer-events: none; +} +@keyframes rippleOut { + to { transform: scale(28); opacity: 0; } +} + +/* ── ROLE PILL ── */ +.role-pill { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px 4px 8px; + border-radius: 20px; + font-size: 11.5px; + font-weight: 600; + margin-bottom: 8px; +} +.role-pill.pengguna { background: var(--ri-light); color: var(--ri-color); border: 1px solid var(--ri-mid); } +.role-pill.pengurus { background: var(--rm-light); color: var(--rm-color); border: 1px solid rgba(200,75,15,0.2); } + +/* ── FORM FOOTER ── */ +.form-footer-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + font-size: 13px; + color: var(--txt-2); +} +.remember-row { + display: flex; + align-items: center; + gap: 7px; + cursor: pointer; + user-select: none; +} +.remember-row input[type="checkbox"] { accent-color: var(--ri-color); width: 15px; height: 15px; cursor: pointer; } +.link-text { + background: none; + border: none; + font-family: var(--sys); + font-size: 13px; + font-weight: 500; + color: var(--ri-color); + cursor: pointer; + padding: 0; + transition: opacity 0.14s; +} +.link-text:hover { opacity: 0.75; } + +.bottom-link { + text-align: center; + margin-top: 18px; + font-size: 13px; + color: var(--txt-2); +} + +@media (max-width: 480px) { + .land-card { padding: 36px 28px 32px; } + .land-logo { font-size: 52px; } + .role-grid { grid-template-columns: 1fr; } + .land-features { gap: 8px; } + .auth-card { padding: 28px 24px 24px; margin: 16px; } +} + +/* ── ERROR BANNER ────────────────────────────────────────────────────────── */ +.error-banner { + display: flex; + align-items: flex-start; + gap: 8px; + background: rgba(220, 50, 50, 0.08); + border: 1px solid rgba(220, 50, 50, 0.2); + color: #c0392b; + border-radius: 10px; + padding: 10px 12px; + font-size: 13px; + line-height: 1.5; + margin-bottom: 14px; +} +.error-banner svg { + flex-shrink: 0; + margin-top: 1px; + color: #c0392b; +} + +/* ── PENGURUS NOTE ───────────────────────────────────────────────────────── */ +.pengurus-note { + display: flex; + align-items: flex-start; + gap: 7px; + font-size: 12px; + color: var(--txt-3); + background: rgba(200, 75, 15, 0.05); + border: 1px solid rgba(200, 75, 15, 0.12); + border-radius: 8px; + padding: 9px 11px; + margin-top: 14px; + line-height: 1.5; +} +.pengurus-note svg { + flex-shrink: 0; + margin-top: 1px; + color: #c84b0f; + opacity: .7; +} diff --git a/src/pages/AuthPage.jsx b/src/pages/AuthPage.jsx new file mode 100644 index 0000000..84fac3a --- /dev/null +++ b/src/pages/AuthPage.jsx @@ -0,0 +1,511 @@ +import { useState, useCallback } from 'react' +import { useNavigate } from 'react-router-dom' +import './AuthPage.css' +import Toast from '../components/Toast' +import { useToast } from '../hooks/useToast' +import { apiCall } from '../utils/api' + +// ── PASSWORD STRENGTH ───────────────────────────────────────────────────── +function PasswordStrength({ value }) { + if (!value) return null + let score = 0 + if (value.length >= 8) score++ + if (/[A-Z]/.test(value)) score++ + if (/[0-9]/.test(value)) score++ + if (/[^A-Za-z0-9]/.test(value)) score++ + const colors = ['#e06060', '#e0a060', '#d4b800', '#1c8a5f'] + const labels = ['Sangat Lemah', 'Lemah', 'Sedang', 'Kuat'] + const color = score > 0 ? colors[score - 1] : 'var(--txt-3)' + return ( +
+
+ {[0,1,2,3].map(i => ( + + ))} +
+ {score > 0 &&
{labels[score - 1]}
} +
+ ) +} + +// ── EYE BUTTON ──────────────────────────────────────────────────────────── +function EyeButton({ show, onToggle }) { + return ( + + ) +} + +function BackArrow() { + return ( + + + + ) +} + +// ── LANDING ─────────────────────────────────────────────────────────────── +function LandingScreen({ active, onEnter }) { + return ( +
+
+
+ +
+
+
Sistem Informasi Aktif
+
SIPKEM
+
Sistem Peta Kemiskinan
+
+

+ Platform pemetaan terpadu untuk rumah ibadah dan{' '} + rumah miskin di wilayah Pontianak — mendukung pendataan, + monitoring, dan pembinaan sosial berbasis lokasi. +

+ +
+ {[['🗺️','Peta Interaktif'],['🕌','Rumah Ibadah'],['🏠','Rumah Miskin'],['📊','Statistik']].map(([icon, label]) => ( +
{icon} {label}
+ ))} +
+
+
+ ) +} + +// ── ROLE SELECTION ──────────────────────────────────────────────────────── +function RoleScreen({ active, selectedRole, onSelect, onBack, onNext }) { + return ( +
+
+ +
+
+
+
+
Langkah 1 dari 2
+
Pilih Peran Anda
+

Akses sistem disesuaikan berdasarkan peran yang Anda pilih.

+ +
+ {[ + { + key: 'pengguna', + label: 'Pengguna', + desc: 'Melihat peta & data yang tersedia', + icon: ( + + + + ), + }, + { + key: 'pengurus', + label: 'Pengurus', + desc: 'Kelola & edit seluruh data sistem', + icon: ( + + + + + ), + }, + ].map(({ key, label, desc, icon }) => ( +
onSelect(key)} + > +
+ + + +
+
{icon}
+
{label}
+
{desc}
+
+ ))} +
+ + +
+
+ ) +} + +// ── LOGIN ───────────────────────────────────────────────────────────────── +function LoginScreen({ active, role, onBack, onSuccess, showToast, onGoRegister }) { + const [email, setEmail] = useState('') + const [pass, setPass] = useState('') + const [showPass, setShowPass] = useState(false) + const [loading, setLoading] = useState(false) + const [errors, setErrors] = useState({}) + + const isValidEmail = v => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) + + function validate() { + const errs = {} + if (!isValidEmail(email)) errs.email = 'Format email tidak valid.' + if (pass.length < 8) errs.pass = 'Kata sandi minimal 8 karakter.' + return errs + } + + async function handleLogin() { + const errs = validate() + if (Object.keys(errs).length) { setErrors(errs); return } + setErrors({}) + setLoading(true) + + const res = await apiCall('login', { email, password: pass, role }) + setLoading(false) + + if (!res.ok) { + // Map error to the right field + const msg = res.error || 'Login gagal.' + if (msg.toLowerCase().includes('email') || msg.toLowerCase().includes('kata sandi') || msg.toLowerCase().includes('salah')) { + setErrors({ api: msg }) + } else if (msg.toLowerCase().includes('pengurus') || msg.toLowerCase().includes('pengguna') || msg.toLowerCase().includes('akun')) { + setErrors({ role: msg }) + } else { + setErrors({ api: msg }) + } + return + } + + onSuccess(res.user.role, res.user) + } + + // Pengurus: no register link shown + const isPengurus = role === 'pengurus' + + return ( +
+
+ +
+
+
+
+ +
+ {isPengurus ? ( + <> + + + + + Pengurus + + ) : ( + <> + + + + Pengguna + + )} +
+ +
Masuk Akun
+
Selamat Datang
+

+ {isPengurus + ? 'Masukkan kredensial akun Pengurus Anda.' + : 'Masukkan email dan kata sandi Anda untuk melanjutkan.'} +

+ + {/* Role mismatch error banner */} + {errors.role && ( +
+ + + + {errors.role} +
+ )} + +
+ + { setEmail(e.target.value); setErrors(p => ({ ...p, email: '', api: '' })) }} + onKeyDown={e => e.key === 'Enter' && handleLogin()} + autoComplete="email" + /> + {errors.email &&
{errors.email}
} +
+ +
+ +
+ { setPass(e.target.value); setErrors(p => ({ ...p, pass: '', api: '' })) }} + onKeyDown={e => e.key === 'Enter' && handleLogin()} + autoComplete="current-password" + /> + setShowPass(s => !s)} /> +
+ {errors.pass &&
{errors.pass}
} + {errors.api &&
{errors.api}
} +
+ + + + {/* Pengurus: no register link */} + {!isPengurus && ( +
+ Belum punya akun? + +
+ )} + + {isPengurus && ( +
+ + + + Akun Pengurus hanya dapat dibuat oleh administrator sistem. +
+ )} +
+
+ ) +} + +// ── REGISTER (Pengguna only) ────────────────────────────────────────────── +function RegisterScreen({ active, onBack, showToast }) { + const [name, setName] = useState('') + const [email, setEmail] = useState('') + const [pass, setPass] = useState('') + const [pass2, setPass2] = useState('') + const [showP, setShowP] = useState(false) + const [showP2, setShowP2] = useState(false) + const [loading, setLoading] = useState(false) + const [errors, setErrors] = useState({}) + + const isValidEmail = v => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) + + function validate() { + const errs = {} + if (!name.trim()) errs.name = 'Nama tidak boleh kosong.' + if (!isValidEmail(email)) errs.email = 'Format email tidak valid.' + if (pass.length < 8) errs.pass = 'Kata sandi minimal 8 karakter.' + if (pass !== pass2 || !pass2) errs.pass2 = 'Kata sandi tidak cocok.' + return errs + } + + async function handleRegister() { + const errs = validate() + if (Object.keys(errs).length) { setErrors(errs); return } + setErrors({}) + setLoading(true) + + const res = await apiCall('register', { nama: name.trim(), email, password: pass }) + setLoading(false) + + if (!res.ok) { + const msg = res.error || 'Pendaftaran gagal.' + if (msg.toLowerCase().includes('email')) { + setErrors({ email: msg }) + } else { + setErrors({ api: msg }) + } + return + } + + showToast('✓ Akun berhasil dibuat — silakan masuk') + // Reset form + setName(''); setEmail(''); setPass(''); setPass2('') + setTimeout(() => onBack(), 1200) + } + + return ( +
+
+ +
Daftar Akun
+
Buat Akun Baru
+

Isi data di bawah untuk membuat akun Pengguna SIPKEM.

+ + {errors.api && ( +
+ + + + {errors.api} +
+ )} + +
+ + { setName(e.target.value); setErrors(p => ({ ...p, name: '' })) }} + autoComplete="name" + /> + {errors.name &&
{errors.name}
} +
+ +
+ + { setEmail(e.target.value); setErrors(p => ({ ...p, email: '' })) }} + autoComplete="email" + /> + {errors.email &&
{errors.email}
} +
+ +
+ +
+ { setPass(e.target.value); setErrors(p => ({ ...p, pass: '' })) }} + autoComplete="new-password" + /> + setShowP(s => !s)} /> +
+ + {errors.pass &&
{errors.pass}
} +
+ +
+ +
+ { setPass2(e.target.value); setErrors(p => ({ ...p, pass2: '' })) }} + onKeyDown={e => e.key === 'Enter' && handleRegister()} + autoComplete="new-password" + /> + setShowP2(s => !s)} /> +
+ {errors.pass2 &&
{errors.pass2}
} +
+ + + +
+ Sudah punya akun? + +
+
+
+ ) +} + +// ── MAIN ────────────────────────────────────────────────────────────────── +export default function AuthPage() { + const navigate = useNavigate() + const [screen, setScreen] = useState('landing') + const [role, setRole] = useState(null) + const { message, visible, showToast } = useToast() + + const goTo = useCallback((s) => setScreen(s), []) + + return ( +
+ {screen !== 'landing' &&
} + + goTo('role')} /> + + goTo('landing')} + onNext={() => { if (role) goTo('login') }} + /> + + goTo('role')} + onSuccess={(r, user) => navigate('/map', { state: { role: r, user } })} + showToast={showToast} + onGoRegister={() => goTo('register')} + /> + + {/* Register only accessible if role is pengguna (or role not yet set) */} + {role !== 'pengurus' && ( + goTo('login')} + showToast={showToast} + /> + )} + + +
+ ) +} diff --git a/src/pages/MapPage.css b/src/pages/MapPage.css new file mode 100644 index 0000000..c93c5e2 --- /dev/null +++ b/src/pages/MapPage.css @@ -0,0 +1,274 @@ +/* ── MAP PAGE ─────────────────────────────────────────────── */ + +#map { width: 100%; height: 100vh; } +#map.mode-add-ri { cursor: crosshair; } +#map.mode-add-rm { cursor: cell; } + +/* ── TOP LEFT PANEL ── */ +.app-hd { padding: 14px 15px 11px; border-bottom: 1px solid var(--sep); } +.app-title { font-size: 17px; font-weight: 700; letter-spacing: -.4px; color: var(--txt-1); } +.app-sub { font-size: 11px; color: var(--txt-2); margin-top: 2px; line-height: 1.35; } + +.mode-badge { + display: inline-flex; align-items: center; gap: 5px; + padding: 2px 9px 2px 5px; border-radius: 20px; + font-size: 10.5px; font-weight: 600; margin-top: 6px; +} +.mb-sel { background: rgba(120,120,128,.13); color: var(--txt-2); } +.mb-ri { background: rgba(28,138,95,.13); color: #1a7a3a; } +.mb-rm { background: rgba(200,75,15,.11); color: #a83a08; } + +.bd { width: 6px; height: 6px; border-radius: 50%; } +.bd-sel { background: #8e8e93; } +.bd-ri { background: #34c759; } +.bd-rm { background: #ff3b30; } + +.mode-list { padding: 8px 8px 10px; display: flex; flex-direction: column; gap: 3px; } +.mode-sec { font-size: 10px; font-weight: 600; letter-spacing: .5px; text-transform: uppercase; color: var(--txt-3); padding: 2px 6px 5px; } + +.mode-btn { + display: flex; align-items: center; gap: 10px; + padding: 8px 10px; border-radius: var(--r-md); + border: none; background: transparent; cursor: pointer; + font-family: var(--sys); font-size: 13.5px; font-weight: 500; color: var(--txt-1); + transition: background .14s; text-align: left; width: 100%; +} +.mode-btn:hover { background: var(--fill-1); } +.mode-btn.active { background: var(--fill-1); } +.mode-btn.active.ri-btn { background: rgba(52,199,89,.14); color: #1a7a3a; } +.mode-btn.active.rm-btn { background: rgba(200,75,15,.10); color: #a83a08; } + +.mico { + width: 30px; height: 30px; border-radius: 8px; + display: flex; align-items: center; justify-content: center; + font-size: 16px; flex-shrink: 0; background: var(--fill-1); +} +.mode-btn.active.ri-btn .mico { background: rgba(52,199,89,.18); } +.mode-btn.active.rm-btn .mico { background: rgba(200,75,15,.13); } + +.mode-hint { padding: 8px 14px 11px; border-top: 1px solid var(--sep); font-size: 10.5px; color: var(--txt-3); line-height: 1.5; } + +/* ── STATS PANEL ── */ +.pnl-hd { + padding: 10px 14px 8px; border-bottom: 1px solid var(--sep); + font-size: 11px; font-weight: 600; text-transform: uppercase; + letter-spacing: .5px; color: var(--txt-2); +} +.stats-grid { display: grid; grid-template-columns: 1fr 1fr; } +.sc { padding: 11px 14px; position: relative; } +.sc::after { + content: ''; position: absolute; right: 0; top: 22%; bottom: 22%; + width: .5px; background: var(--sep); +} +.sc.no-divider::after { display: none; } +.sc:nth-child(1), .sc:nth-child(2) { border-bottom: 1px solid var(--sep); } +.sn { + font-size: 28px; font-weight: 700; letter-spacing: -1.2px; line-height: 1; + color: var(--txt-1); font-variant-numeric: tabular-nums; +} +.sn.grn { color: #1d9e3e; } +.sn.org { color: #b96300; } +.sl { font-size: 10.5px; font-weight: 500; color: var(--txt-2); margin-top: 3px; line-height: 1.25; } + +/* ── LEGEND PANEL ── */ +.leg-hd { + padding: 10px 13px 9px; border-bottom: 1px solid var(--sep); + display: flex; align-items: center; justify-content: space-between; + cursor: pointer; user-select: none; +} +.leg-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .5px; color: var(--txt-2); } +.leg-chev { font-size: 10px; color: var(--txt-3); transition: transform .2s; } +.leg-chev.up { transform: rotate(180deg); } + +.leg-body-wrap { overflow: hidden; max-height: 300px; transition: max-height .24s ease; } +.leg-body-wrap.closed { max-height: 0 !important; } + +.leg-sec { padding: 8px 13px 7px; } +.leg-sec-lbl { + font-size: 9.5px; font-weight: 600; text-transform: uppercase; + letter-spacing: .5px; color: var(--txt-3); margin-bottom: 7px; +} +.leg-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; font-size: 12px; color: var(--txt-1); } +.ld { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } +.ls { width: 10px; height: 10px; border-radius: 3px; flex-shrink: 0; } +.leg-sep { height: .5px; background: var(--sep); margin: 0 13px 1px; } + +/* ── ROLE INDICATOR ──────────────────────────────────────────────────────── */ +.app-title-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} +.logout-btn { + flex-shrink: 0; + width: 28px; height: 28px; + background: rgba(120,120,128,.1); + border: none; + border-radius: 8px; + color: var(--txt-3); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background .15s, color .15s; + margin-top: 2px; +} +.logout-btn:hover { + background: rgba(200,75,15,.12); + color: #c84b0f; +} + +.role-indicator { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + font-weight: 600; + padding: 3px 9px; + border-radius: 20px; + margin-top: 6px; + letter-spacing: .02em; +} +.role-indicator[data-role="pengurus"] { + background: rgba(200,75,15,.1); + color: #c84b0f; +} +.role-indicator[data-role="pengguna"] { + background: rgba(28,138,95,.1); + color: #1c8a5f; +} + +/* ── POPUP READ-ONLY RANGE ───────────────────────────────────────────────── */ +.pu-range-ro { + padding: 8px 14px; + border-top: 1px solid rgba(120,120,128,.12); +} + +/* ── POPUP BANTUAN / HISTORY BUTTONS ────────────────────────────────────── */ +.pu-btn.pu-history { + background: rgba(37,99,235,.08); + color: #2563eb; + border-color: rgba(37,99,235,.2); +} +.pu-btn.pu-history:hover { background: rgba(37,99,235,.15); } +.pu-btn.pu-bantuan { + background: rgba(28,138,95,.08); + color: #1c8a5f; + border-color: rgba(28,138,95,.2); +} +.pu-btn.pu-bantuan:hover { background: rgba(28,138,95,.15); } + +/* ── MODE BANTUAN BUTTON ─────────────────────────────────────────────────── */ +.mode-btn.bantuan-btn { color: #1c8a5f; } +.mode-btn.bantuan-btn:hover, .mode-btn.bantuan-btn.active { + background: rgba(28,138,95,.12); + color: #1c8a5f; +} + +/* ── BANTUAN MODAL STYLES ────────────────────────────────────────────────── */ +.bantuan-desc-chip { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + padding: 7px 10px; + background: rgba(120,120,128,.07); + border-radius: 8px; + margin: 4px 0 8px; + color: var(--txt-2); +} +.bantuan-cat-dot { + width: 8px; height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.bantuan-type-list { + display: flex; + flex-direction: column; + gap: 6px; +} +.bantuan-type-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 8px; + background: rgba(120,120,128,.05); + transition: background .15s; +} +.bantuan-type-item:hover { background: rgba(120,120,128,.1); } +.bantuan-type-info { flex: 1; min-width: 0; } +.bantuan-type-nama { font-size: 13px; font-weight: 600; color: var(--txt-1); } +.bantuan-type-meta { display: flex; align-items: center; gap: 6px; margin-top: 2px; } +.bantuan-type-desc { font-size: 11px; color: var(--txt-3); } +.bantuan-del-btn { + flex-shrink: 0; + width: 26px; height: 26px; + background: none; + border: none; + border-radius: 6px; + color: var(--txt-3); + cursor: pointer; + display: flex; align-items: center; justify-content: center; + transition: background .15s, color .15s; +} +.bantuan-del-btn:hover { background: rgba(200,75,15,.1); color: #c84b0f; } + +/* ── HISTORY LIST ────────────────────────────────────────────────────────── */ +.history-empty { + padding: 32px 16px; + text-align: center; + color: var(--txt-3); + font-size: 13px; + line-height: 1.6; +} +.history-list { + padding: 8px 4px; + display: flex; + flex-direction: column; +} +.history-item { + display: flex; + gap: 12px; + padding: 8px 4px; +} +.history-item-left { + display: flex; + flex-direction: column; + align-items: center; + gap: 0; + flex-shrink: 0; + width: 14px; + padding-top: 4px; +} +.history-dot { + width: 12px; height: 12px; + border-radius: 50%; + flex-shrink: 0; + box-shadow: 0 0 0 3px rgba(255,255,255,.6); +} +.history-line { + width: 2px; + flex: 1; + min-height: 16px; + background: rgba(120,120,128,.2); + margin-top: 4px; +} +.history-item-body { + flex: 1; + min-width: 0; + padding-bottom: 12px; +} +.history-nama { font-size: 13px; font-weight: 600; color: var(--txt-1); } +.history-meta { display: flex; align-items: center; gap: 8px; margin-top: 4px; } +.history-cat { + display: inline-block; + font-size: 10px; font-weight: 600; + padding: 2px 7px; + border-radius: 20px; + letter-spacing: .04em; +} +.history-date { font-size: 11px; color: var(--txt-3); } +.history-catatan { font-size: 12px; color: var(--txt-2); margin-top: 4px; } +.history-by { font-size: 11px; color: var(--txt-3); margin-top: 2px; font-style: italic; } diff --git a/src/pages/MapPage.jsx b/src/pages/MapPage.jsx new file mode 100644 index 0000000..e197214 --- /dev/null +++ b/src/pages/MapPage.jsx @@ -0,0 +1,557 @@ +import { useEffect, useRef, useCallback, useState } from 'react' +import { useNavigate, useLocation } from 'react-router-dom' +import L from 'leaflet' +import './MapPage.css' +import { + apiCall, genUUID, debounce, reverseGeocode, + assignRM, TYPE_COLORS, TYPE_EMOJI, +} from '../utils/api' +import ModePanel from '../components/ModePanel' +import StatsPanel from '../components/StatsPanel' +import LegendPanel from '../components/LegendPanel' +import Modal from '../components/Modal' +import Toast from '../components/Toast' +import ConfirmDialog from '../components/ConfirmDialog' +import { AddBantuanModal, HistoryBantuanModal, ManageBantuanModal } from '../components/BantuanModal' +import { useToast } from '../hooks/useToast' +import { useConfirm } from '../hooks/useConfirm' + +// ── ICON FACTORIES ──────────────────────────────────────────────────────── +function riIcon(type) { + const c = TYPE_COLORS[type] || '#8e8e93' + const e = TYPE_EMOJI[type] || '🏛' + return L.divIcon({ + className: '', + html: `
${e}
`, + iconSize: [40, 40], iconAnchor: [20, 20], popupAnchor: [0, -24], + }) +} +function rmIcon(assigned) { + const c = assigned ? '#10b981' : '#f97316' + const s = assigned ? '✓' : '?' + return L.divIcon({ + className: '', + html: `
${s}
`, + iconSize: [30, 30], iconAnchor: [15, 15], popupAnchor: [0, -18], + }) +} + +// ── POPUP HTML BUILDERS ─────────────────────────────────────────────────── +function buildRIPopupHTML(ri, binaan, isPengurus) { + const c = TYPE_COLORS[ri.type] || '#8e8e93' + const pct = ((ri.range - 300) / 500 * 100).toFixed(1) + const bHTML = binaan.length + ? binaan.map(rm => `
🏠 ${rm.name}${Math.round(rm.assignedDist || 0)}m
`).join('') + : `
Belum ada dalam jangkauan
` + + const editBtns = isPengurus + ? `
+ + +
` + : '' + + const rangeSection = isPengurus + ? `
+
Jangkauan Binaan${ri.range}m
+ +
300m550m800m
+
` + : `
+
Jangkauan Binaan${ri.range}m
+
` + + return `
+
+ ${ri.type} +
${ri.name}
+
+
+
📞${ri.kontak}
+
📍${ri.address || 'Memuat alamat…'}
+
🌐${ri.lat.toFixed(5)}, ${ri.lng.toFixed(5)}
+
+ ${rangeSection} +
+
Rumah Binaan (${binaan.length})
+
${bHTML}
+
+ ${editBtns} +
` +} + +function buildRMPopupHTML(rm, riList, isPengurus) { + const assignedRI = rm.assignedTo ? riList.find(r => r.id === rm.assignedTo) : null + const hdr = rm.assignedTo + ? 'linear-gradient(145deg,#065f46f0,#047857b0)' + : 'linear-gradient(145deg,#9a3412f0,#c2410cb0)' + const chip = assignedRI + ? `✅ ${assignedRI.name} ${Math.round(rm.assignedDist || 0)}m` + : `❌ Di luar jangkauan` + + const editBtns = isPengurus + ? `
+ + +
+
+ + +
` + : `
+ +
` + + return `
+
+ Rumah Miskin +
${rm.name}
+
+
+
📞${rm.kontak}
+
📍${rm.address || 'Memuat alamat…'}
+
🌐${rm.lat.toFixed(5)}, ${rm.lng.toFixed(5)}
+
+
+
Status Binaan
+
${chip}
+
+ ${editBtns} +
` +} + +// ── MAIN COMPONENT ──────────────────────────────────────────────────────── +export default function MapPage() { + const navigate = useNavigate() + const location = useLocation() + const role = location.state?.role || 'pengguna' + const isPengurus = role === 'pengurus' + + const mapRef = useRef(null) + const mapObj = useRef(null) + const riListRef = useRef([]) + const rmListRef = useRef([]) + const modeRef = useRef('select') + const roleRef = useRef(role) + + const [riList, setRiList] = useState([]) + const [rmList, setRmList] = useState([]) + const [mode, setModeState] = useState('select') + const [modal, setModal] = useState({ open: false, mode: null, target: null, pendingLL: null }) + + // Bantuan modals + const [addBantuanModal, setAddBantuanModal] = useState({ open: false, rm: null }) + const [historyBantuanModal, setHistoryBantuanModal] = useState({ open: false, rm: null }) + const [manageBantuanModal, setManageBantuanModal] = useState(false) + + const { message: toastMsg, visible: toastVisible, showToast } = useToast() + const { confirmState, confirm, handleChoice } = useConfirm() + + const setMode = useCallback((m) => { + modeRef.current = m + setModeState(m) + const me = mapObj.current?.getContainer() + if (me) { + me.classList.remove('mode-add-ri', 'mode-add-rm') + if (m === 'add-ri') me.classList.add('mode-add-ri') + if (m === 'add-rm') me.classList.add('mode-add-rm') + } + if (m === 'add-ri') showToast('🕌 Klik peta untuk menambah Rumah Ibadah') + if (m === 'add-rm') showToast('🏠 Klik peta untuk menambah Rumah Miskin') + }, [showToast]) + + const distM = useCallback((a, b, c, d) => { + return mapObj.current?.distance([a, b], [c, d]) ?? 0 + }, []) + + const doAssignRM = useCallback((rm) => { + const result = assignRM(rm, riListRef.current, distM) + rm.assignedTo = result.assignedTo + rm.assignedDist = result.assignedDist + }, [distM]) + + const debounceSyncAssignments = useRef(debounce(async () => { + await Promise.all(rmListRef.current.map(rm => + apiCall('update_rm_assignment', { + rm_id: rm.id, + ri_id: rm.assignedTo, + jarak_ke_ri_m: rm.assignedDist, + }) + )) + }, 800)).current + + const reassignAll = useCallback(() => { + let anyChanged = false + for (const rm of rmListRef.current) { + const prev = rm.assignedTo + doAssignRM(rm) + rm.marker.setIcon(rmIcon(rm.assignedTo !== null)) + if (prev !== rm.assignedTo) anyChanged = true + } + setRmList([...rmListRef.current]) + if (anyChanged) debounceSyncAssignments() + }, [doAssignRM, debounceSyncAssignments]) + + // Wire RI popup + function wireRIPopupDynamic(ri) { + const curRole = roleRef.current + const isPeng = curRole === 'pengurus' + + if (isPeng) { + const sl = document.getElementById(`ri-rng-${ri.id}`) + const dv = document.getElementById(`ri-rdis-${ri.id}`) + const debouncedSave = debounce((r) => apiCall('update_ri_range', { id: r.id, jangkauan_m: r.range }), 900) + if (sl) { + sl.oninput = function () { + ri.range = +this.value + if (dv) dv.textContent = this.value + 'm' + const pct = ((ri.range - 300) / 500 * 100).toFixed(1) + this.style.background = `linear-gradient(to right,#1c8a5f 0%,#1c8a5f ${pct}%,#d1d1d6 ${pct}%)` + ri.circle.setRadius(ri.range) + for (const rm of rmListRef.current) { + const result = assignRM(rm, riListRef.current, (a, b, c, d) => mapObj.current.distance([a, b], [c, d])) + rm.assignedTo = result.assignedTo + rm.assignedDist = result.assignedDist + rm.marker.setIcon(rmIcon(rm.assignedTo !== null)) + } + debouncedSave(ri) + const binaan = rmListRef.current.filter(r => r.assignedTo === ri.id) + const bel = document.getElementById(`ri-blist-${ri.id}`) + const cel = document.getElementById(`ri-bcount-${ri.id}`) + if (cel) cel.textContent = `(${binaan.length})` + if (bel) bel.innerHTML = binaan.length + ? binaan.map(rm => `
🏠 ${rm.name}${Math.round(rm.assignedDist || 0)}m
`).join('') + : `
Belum ada dalam jangkauan
` + setRmList([...rmListRef.current]) + } + } + const eb = document.getElementById(`ri-ebtn-${ri.id}`) + const db = document.getElementById(`ri-dbtn-${ri.id}`) + if (eb) eb.onclick = () => { + ri.marker.closePopup() + setModal({ open: true, mode: 'edit-ri', target: ri, pendingLL: null }) + } + if (db) db.onclick = async () => { + const yes = await confirm(`Hapus "${ri.name}"?`) + if (!yes) return + mapObj.current.closePopup() + mapObj.current.removeLayer(ri.marker) + mapObj.current.removeLayer(ri.circle) + riListRef.current = riListRef.current.filter(r => r.id !== ri.id) + for (const rm of rmListRef.current) { + const result = assignRM(rm, riListRef.current, (a, b, c, d) => mapObj.current.distance([a, b], [c, d])) + rm.assignedTo = result.assignedTo + rm.assignedDist = result.assignedDist + rm.marker.setIcon(rmIcon(rm.assignedTo !== null)) + } + setRiList([...riListRef.current]) + setRmList([...rmListRef.current]) + await apiCall('delete_ri', { id: ri.id }) + } + } + } + + function wireRMPopupDynamic(rm) { + const curRole = roleRef.current + const isPeng = curRole === 'pengurus' + + // History button — always visible for both roles + const hb = document.getElementById(`rm-hbtn-${rm.id}`) + if (hb) hb.onclick = () => { + rm.marker.closePopup() + setHistoryBantuanModal({ open: true, rm: { id: rm.id, name: rm.name } }) + } + + if (isPeng) { + const bb = document.getElementById(`rm-bbtn-${rm.id}`) + const eb = document.getElementById(`rm-ebtn-${rm.id}`) + const db = document.getElementById(`rm-dbtn-${rm.id}`) + if (bb) bb.onclick = () => { + rm.marker.closePopup() + setAddBantuanModal({ open: true, rm: { id: rm.id, name: rm.name } }) + } + if (eb) eb.onclick = () => { + rm.marker.closePopup() + setModal({ open: true, mode: 'edit-rm', target: rm, pendingLL: null }) + } + if (db) db.onclick = async () => { + const yes = await confirm(`Hapus "${rm.name}"?`) + if (!yes) return + mapObj.current.closePopup() + mapObj.current.removeLayer(rm.marker) + rmListRef.current = rmListRef.current.filter(r => r.id !== rm.id) + setRmList([...rmListRef.current]) + await apiCall('delete_rm', { id: rm.id }) + } + } + } + + // ── MODAL HELPERS ───────────────────────────────────────────────────────── + const closeModal = useCallback(() => { + setModal({ open: false, mode: null, target: null, pendingLL: null }) + }, []) + + const handleModalSubmit = useCallback(async ({ name, kontak, type }) => { + const { mode: mMode, target, pendingLL } = modal + closeModal() + + if (mMode === 'add-ri' && pendingLL) { + const id = genUUID() + const ri = { id, lat: pendingLL.lat, lng: pendingLL.lng, name, kontak, type, range: 500, address: '' } + const c = TYPE_COLORS[ri.type] || '#8e8e93' + ri.circle = L.circle([ri.lat, ri.lng], { + radius: ri.range, color: c, fillColor: c, + fillOpacity: .1, weight: 2, dashArray: '8 5', + }).addTo(mapObj.current) + const binaan = [] + ri.marker = L.marker([ri.lat, ri.lng], { draggable: isPengurus, icon: riIcon(ri.type) }).addTo(mapObj.current) + ri.marker.bindPopup(buildRIPopupHTML(ri, binaan, isPengurus), { minWidth: 268, maxWidth: 320 }) + ri.marker.on('popupopen', () => wireRIPopupDynamic(ri)) + ri.marker.on('dragend', async (e) => { + if (!isPengurus) return + const p = e.target.getLatLng() + ri.lat = p.lat; ri.lng = p.lng + ri.circle.setLatLng(p) + reassignAll() + const a = await reverseGeocode(ri.lat, ri.lng) + ri.address = a + const binaan2 = rmListRef.current.filter(r => r.assignedTo === ri.id) + ri.marker.setPopupContent(buildRIPopupHTML(ri, binaan2, isPengurus)) + apiCall('update_ri_pos', { id: ri.id, lat: ri.lat, lng: ri.lng, alamat: a }) + }) + riListRef.current.push(ri) + setRiList([...riListRef.current]) + reassignAll() + await apiCall('add_ri', { id, nama: name, jenis: type, kontak, alamat: '', lat: pendingLL.lat, lng: pendingLL.lng, jangkauan_m: 500 }) + reverseGeocode(pendingLL.lat, pendingLL.lng).then(a => { + ri.address = a + const el = document.getElementById(`ri-addr-${ri.id}`) + if (el) el.textContent = a + apiCall('update_ri_pos', { id: ri.id, lat: pendingLL.lat, lng: pendingLL.lng, alamat: a }) + }) + setMode('select') + } + else if (mMode === 'add-rm' && pendingLL) { + const id = genUUID() + const rm = { id, lat: pendingLL.lat, lng: pendingLL.lng, name, kontak, assignedTo: null, assignedDist: null, address: '' } + rm.marker = L.marker([rm.lat, rm.lng], { draggable: isPengurus, icon: rmIcon(false) }).addTo(mapObj.current) + rm.marker.bindPopup(buildRMPopupHTML(rm, riListRef.current, isPengurus), { minWidth: 258, maxWidth: 308 }) + rm.marker.on('popupopen', () => wireRMPopupDynamic(rm)) + rm.marker.on('dragend', async (e) => { + if (!isPengurus) return + const p = e.target.getLatLng() + rm.lat = p.lat; rm.lng = p.lng + const result = assignRM(rm, riListRef.current, (a, b, c, d) => mapObj.current.distance([a, b], [c, d])) + rm.assignedTo = result.assignedTo + rm.assignedDist = result.assignedDist + rm.marker.setIcon(rmIcon(rm.assignedTo !== null)) + rm.marker.setPopupContent(buildRMPopupHTML(rm, riListRef.current, isPengurus)) + const a = await reverseGeocode(rm.lat, rm.lng) + rm.address = a + rm.marker.setPopupContent(buildRMPopupHTML(rm, riListRef.current, isPengurus)) + await apiCall('update_rm_pos', { id: rm.id, lat: rm.lat, lng: rm.lng, alamat: a }) + apiCall('update_rm_assignment', { rm_id: rm.id, ri_id: rm.assignedTo, jarak_ke_ri_m: rm.assignedDist }) + }) + rmListRef.current.push(rm) + setRmList([...rmListRef.current]) + doAssignRM(rm) + rm.marker.setIcon(rmIcon(rm.assignedTo !== null)) + await apiCall('add_rm', { + id, nama_kk: name, kontak, alamat: '', + lat: pendingLL.lat, lng: pendingLL.lng, + ri_id: rm.assignedTo, jarak_ke_ri_m: rm.assignedDist, + }) + reverseGeocode(pendingLL.lat, pendingLL.lng).then(a => { + rm.address = a + const el = document.getElementById(`rm-addr-${rm.id}`) + if (el) el.textContent = a + apiCall('update_rm_pos', { id: rm.id, lat: pendingLL.lat, lng: pendingLL.lng, alamat: a }) + }) + setMode('select') + } + else if (mMode === 'edit-ri' && target) { + const ri = target + const prev = ri.type + ri.name = name; ri.kontak = kontak; ri.type = type + ri.marker.setIcon(riIcon(type)) + if (type !== prev) { + const c = TYPE_COLORS[type] || '#8e8e93' + ri.circle.setStyle({ color: c, fillColor: c }) + } + const binaan = rmListRef.current.filter(r => r.assignedTo === ri.id) + ri.marker.setPopupContent(buildRIPopupHTML(ri, binaan, isPengurus)) + setRiList([...riListRef.current]) + await apiCall('update_ri', { id: ri.id, nama: name, jenis: type, kontak }) + } + else if (mMode === 'edit-rm' && target) { + target.name = name; target.kontak = kontak + target.marker.setPopupContent(buildRMPopupHTML(target, riListRef.current, isPengurus)) + setRmList([...rmListRef.current]) + await apiCall('update_rm', { id: target.id, nama_kk: name, kontak }) + } + }, [modal, closeModal, doAssignRM, reassignAll, setMode, isPengurus]) + + // ── MAP INIT ────────────────────────────────────────────────────────────── + useEffect(() => { + if (mapObj.current) return + + const map = L.map(mapRef.current, { + center: [-0.05543566387935323, 109.34960119463567], + zoom: 14, + zoomControl: false, + }) + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap', + maxZoom: 19, + }).addTo(map) + + L.control.zoom({ position: 'topright' }).addTo(map) + + map.on('click', (e) => { + if (modeRef.current === 'select') return + if (roleRef.current !== 'pengurus') return + setModal(prev => ({ + ...prev, + open: true, + mode: modeRef.current === 'add-ri' ? 'add-ri' : 'add-rm', + target: null, + pendingLL: e.latlng, + })) + }) + + mapObj.current = map + + ;(async () => { + const res = await apiCall('load') + if (!res.ok) { + showToast('❌ Gagal terhubung ke server. Pastikan XAMPP aktif dan api.php ada.') + return + } + + const curRole = roleRef.current + const isPeng = curRole === 'pengurus' + + for (const r of res.ri) { + const ri = { + id: r.id, lat: parseFloat(r.lat), lng: parseFloat(r.lng), + name: r.nama, kontak: r.kontak || '-', type: r.jenis, + range: parseInt(r.jangkauan_m, 10), address: r.alamat || '', + } + const c = TYPE_COLORS[ri.type] || '#8e8e93' + ri.circle = L.circle([ri.lat, ri.lng], { + radius: ri.range, color: c, fillColor: c, + fillOpacity: .1, weight: 2, dashArray: '8 5', + }).addTo(map) + ri.marker = L.marker([ri.lat, ri.lng], { draggable: isPeng, icon: riIcon(ri.type) }).addTo(map) + riListRef.current.push(ri) + } + + for (const r of res.rm) { + const rm = { + id: r.id, lat: parseFloat(r.lat), lng: parseFloat(r.lng), + name: r.nama_kk, kontak: r.kontak || '-', address: r.alamat || '', + assignedTo: r.ri_id || null, + assignedDist: r.jarak_ke_ri_m ? parseFloat(r.jarak_ke_ri_m) : null, + } + rm.marker = L.marker([rm.lat, rm.lng], { draggable: isPeng, icon: rmIcon(rm.assignedTo !== null) }).addTo(map) + rmListRef.current.push(rm) + } + + for (const ri of riListRef.current) { + const binaan = rmListRef.current.filter(r => r.assignedTo === ri.id) + ri.marker.bindPopup(buildRIPopupHTML(ri, binaan, isPeng), { minWidth: 268, maxWidth: 320 }) + ri.marker.on('popupopen', () => wireRIPopupDynamic(ri)) + if (isPeng) { + ri.marker.on('dragend', async (e) => { + const p = e.target.getLatLng() + ri.lat = p.lat; ri.lng = p.lng + ri.circle.setLatLng(p) + for (const rm of rmListRef.current) { + const result = assignRM(rm, riListRef.current, (a, b, c, d) => map.distance([a, b], [c, d])) + rm.assignedTo = result.assignedTo + rm.assignedDist = result.assignedDist + rm.marker.setIcon(rmIcon(rm.assignedTo !== null)) + } + const a = await reverseGeocode(ri.lat, ri.lng) + ri.address = a + const binaan2 = rmListRef.current.filter(r => r.assignedTo === ri.id) + ri.marker.setPopupContent(buildRIPopupHTML(ri, binaan2, isPeng)) + apiCall('update_ri_pos', { id: ri.id, lat: ri.lat, lng: ri.lng, alamat: a }) + }) + } + } + + for (const rm of rmListRef.current) { + rm.marker.bindPopup(buildRMPopupHTML(rm, riListRef.current, isPeng), { minWidth: 258, maxWidth: 308 }) + rm.marker.on('popupopen', () => wireRMPopupDynamic(rm)) + if (isPeng) { + rm.marker.on('dragend', async (e) => { + const p = e.target.getLatLng() + rm.lat = p.lat; rm.lng = p.lng + const result = assignRM(rm, riListRef.current, (a, b, c, d) => map.distance([a, b], [c, d])) + rm.assignedTo = result.assignedTo + rm.assignedDist = result.assignedDist + rm.marker.setIcon(rmIcon(rm.assignedTo !== null)) + rm.marker.setPopupContent(buildRMPopupHTML(rm, riListRef.current, isPeng)) + const a = await reverseGeocode(rm.lat, rm.lng) + rm.address = a + rm.marker.setPopupContent(buildRMPopupHTML(rm, riListRef.current, isPeng)) + await apiCall('update_rm_pos', { id: rm.id, lat: rm.lat, lng: rm.lng, alamat: a }) + apiCall('update_rm_assignment', { rm_id: rm.id, ri_id: rm.assignedTo, jarak_ke_ri_m: rm.assignedDist }) + }) + } + } + + setRiList([...riListRef.current]) + setRmList([...rmListRef.current]) + })() + + return () => { map.remove(); mapObj.current = null } + }, []) // eslint-disable-line + + // ── RENDER ──────────────────────────────────────────────────────────────── + return ( + <> +
+ + navigate('/')} + onManageBantuan={() => setManageBantuanModal(true)} + /> + + + + + + setAddBantuanModal({ open: false, rm: null })} + onSuccess={() => showToast('✅ Bantuan berhasil dikonfirmasi')} + /> + + setHistoryBantuanModal({ open: false, rm: null })} + /> + + setManageBantuanModal(false)} + /> + + + + + ) +} diff --git a/src/utils/api.js b/src/utils/api.js new file mode 100644 index 0000000..4b4ac48 --- /dev/null +++ b/src/utils/api.js @@ -0,0 +1,76 @@ +const API = 'api.php' + +export async function apiCall(action, data = {}) { + try { + const res = await fetch(API, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action, ...data }), + }) + const json = await res.json() + if (!json.ok) console.warn('[SIPKEM API]', action, json.error) + return json + } catch (e) { + console.error('[SIPKEM API] Fetch error:', e) + return { ok: false } + } +} + +export function genUUID() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = Math.random() * 16 | 0 + return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16) + }) +} + +export function debounce(fn, ms) { + let t + return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms) } +} + +export async function reverseGeocode(lat, lng) { + try { + const r = await fetch( + `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json`, + { headers: { 'Accept-Language': 'id' } } + ) + const d = await r.json() + const p = (d.display_name || '').split(', ') + return p.slice(0, Math.min(p.length - 2, 5)).join(', ') || d.display_name + } catch { + return `${lat.toFixed(5)}, ${lng.toFixed(5)}` + } +} + +// Assignment logic +export function assignRM(rm, riList, distanceFn) { + let best = null, minD = Infinity + for (const ri of riList) { + const d = distanceFn(rm.lat, rm.lng, ri.lat, ri.lng) + if (d <= ri.range && d < minD) { minD = d; best = ri } + } + return { + assignedTo: best ? best.id : null, + assignedDist: best ? minD : null, + } +} + +export const TYPE_COLORS = { + Masjid: '#059669', + Gereja: '#2563eb', + Pura: '#d97706', + Vihara: '#7c3aed', + Klenteng: '#e11d48', + Lainnya: '#8e8e93', +} + +export const TYPE_EMOJI = { + Masjid: '🕌', + Gereja: '⛪', + Pura: '🛕', + Vihara: '🏯', + Klenteng: '🏮', + Lainnya: '🏛', +} + +export const RI_TYPES = ['Masjid', 'Gereja', 'Pura', 'Vihara', 'Klenteng', 'Lainnya'] diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..8dc0cb7 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 3000, + proxy: { + '/api.php': { + target: 'http://127.0.0.1/sipkem', + changeOrigin: true, + } + } + } +}) + \ No newline at end of file