Initial commit
This commit is contained in:
+29
@@ -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/
|
||||
@@ -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 |
|
||||
@@ -0,0 +1,281 @@
|
||||
<?php
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: POST, GET, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
|
||||
|
||||
define('DB_HOST', 'localhost');
|
||||
define('DB_NAME', 'sipkem');
|
||||
define('DB_USER', 'root');
|
||||
define('DB_PASS', '');
|
||||
|
||||
try {
|
||||
$pdo = new PDO(
|
||||
'mysql:host='.DB_HOST.';dbname='.DB_NAME.';charset=utf8mb4',
|
||||
DB_USER, DB_PASS,
|
||||
[
|
||||
PDO::ATTR_ERRMODE => 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()]);
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SIPKEM — Sistem Peta Kemiskinan</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,300&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1755
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
-- =============================================================================
|
||||
+13
@@ -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 (
|
||||
<Routes>
|
||||
<Route path="/" element={<AuthPage />} />
|
||||
<Route path="/map" element={<MapPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="modal-overlay show" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal-box" style={{ maxWidth: 400 }}>
|
||||
<div className="modal-drag"><div className="modal-bar" /></div>
|
||||
<div className="modal-hd">
|
||||
<div>
|
||||
<div className="modal-ttl">Tambah Bantuan</div>
|
||||
<div className="modal-sub">{rm?.name || '—'}</div>
|
||||
</div>
|
||||
<button className="modal-x" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body-wrap">
|
||||
<div className="fcard">
|
||||
{fetching ? (
|
||||
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--txt-3)', fontSize: 14 }}>
|
||||
Memuat data bantuan…
|
||||
</div>
|
||||
) : bantuanTypes.length === 0 ? (
|
||||
<div style={{ padding: '16px 0', textAlign: 'center', color: 'var(--txt-3)', fontSize: 13 }}>
|
||||
Belum ada jenis bantuan.<br />Tambah dulu di menu Kelola Bantuan.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="frow">
|
||||
<label className="flbl">Jenis Bantuan</label>
|
||||
<select
|
||||
className="fsel"
|
||||
value={selected}
|
||||
onChange={e => setSelected(e.target.value)}
|
||||
>
|
||||
<option value="">— Pilih jenis bantuan —</option>
|
||||
{bantuanTypes.map(b => (
|
||||
<option key={b.id} value={b.id}>{b.nama}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedType && (
|
||||
<div className="bantuan-desc-chip">
|
||||
<span className="bantuan-cat-dot" style={{ background: getCategoryColor(selectedType.kategori) }} />
|
||||
<span>{selectedType.kategori}</span>
|
||||
{selectedType.deskripsi && (
|
||||
<span style={{ color: 'var(--txt-3)', marginLeft: 6 }}>— {selectedType.deskripsi}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="frow">
|
||||
<label className="flbl">Catatan <span style={{ fontWeight: 400, opacity: .6 }}>(opsional)</span></label>
|
||||
<input
|
||||
className="finput"
|
||||
placeholder="Misal: Tahap 1, periode Januari…"
|
||||
value={catatan}
|
||||
onChange={e => setCatatan(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-ft">
|
||||
{submitError && (
|
||||
<div style={{ width: '100%', fontSize: 12, color: '#c0392b', background: 'rgba(220,50,50,0.07)', border: '1px solid rgba(220,50,50,0.18)', borderRadius: 8, padding: '7px 10px', marginBottom: 8 }}>
|
||||
⚠️ {submitError}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8, width: '100%' }}>
|
||||
<button className="btn-can" onClick={onClose}>Batal</button>
|
||||
<button
|
||||
className="btn-ok rm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!selected || loading || fetching}
|
||||
>
|
||||
{loading ? 'Menyimpan…' : 'Konfirmasi Bantuan'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className="modal-overlay show" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal-box" style={{ maxWidth: 420 }}>
|
||||
<div className="modal-drag"><div className="modal-bar" /></div>
|
||||
<div className="modal-hd">
|
||||
<div>
|
||||
<div className="modal-ttl">Riwayat Bantuan</div>
|
||||
<div className="modal-sub">{rm?.name || '—'}</div>
|
||||
</div>
|
||||
<button className="modal-x" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body-wrap">
|
||||
{loading ? (
|
||||
<div style={{ padding: '24px 0', textAlign: 'center', color: 'var(--txt-3)', fontSize: 14 }}>
|
||||
Memuat riwayat…
|
||||
</div>
|
||||
) : history.length === 0 ? (
|
||||
<div className="history-empty">
|
||||
<div style={{ fontSize: 36, marginBottom: 8 }}>📭</div>
|
||||
<div>Belum ada riwayat bantuan untuk rumah ini.</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="history-list">
|
||||
{history.map((h, i) => (
|
||||
<div key={h.id || i} className="history-item">
|
||||
<div className="history-item-left">
|
||||
<div className="history-dot" style={{ background: getCategoryColor(h.kategori) }} />
|
||||
<div className="history-line" style={{ display: i === history.length - 1 ? 'none' : undefined }} />
|
||||
</div>
|
||||
<div className="history-item-body">
|
||||
<div className="history-nama">{h.bantuan_nama}</div>
|
||||
<div className="history-meta">
|
||||
<span className="history-cat" style={{ background: getCategoryColor(h.kategori) + '22', color: getCategoryColor(h.kategori) }}>
|
||||
{h.kategori}
|
||||
</span>
|
||||
<span className="history-date">{formatDate(h.tanggal)}</span>
|
||||
</div>
|
||||
{h.catatan && (
|
||||
<div className="history-catatan">📝 {h.catatan}</div>
|
||||
)}
|
||||
{h.dikonfirmasi_oleh && (
|
||||
<div className="history-by">oleh {h.dikonfirmasi_oleh}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-ft" style={{ justifyContent: 'flex-end' }}>
|
||||
<button className="btn-can" onClick={onClose}>Tutup</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className="modal-overlay show" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal-box" style={{ maxWidth: 440 }}>
|
||||
<div className="modal-drag"><div className="modal-bar" /></div>
|
||||
<div className="modal-hd">
|
||||
<div>
|
||||
<div className="modal-ttl">Kelola Jenis Bantuan</div>
|
||||
<div className="modal-sub">Daftar bantuan yang tersedia</div>
|
||||
</div>
|
||||
<button className="modal-x" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body-wrap" style={{ maxHeight: 480, overflowY: 'auto' }}>
|
||||
{/* Add form */}
|
||||
<div className="fcard" style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--txt-3)', textTransform: 'uppercase', letterSpacing: '.08em', marginBottom: 10 }}>
|
||||
Tambah Jenis Bantuan
|
||||
</div>
|
||||
<div className="frow">
|
||||
<label className="flbl">Nama Bantuan</label>
|
||||
<input
|
||||
ref={nameRef}
|
||||
className={`finput${errors.nama ? ' err' : ''}`}
|
||||
placeholder="Misal: Sembako Bulanan"
|
||||
value={form.nama}
|
||||
onChange={e => { setForm(f => ({ ...f, nama: e.target.value })); setErrors({}) }}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
/>
|
||||
{errors.nama && <div className="form-error show" style={{ marginTop: 4 }}>{errors.nama}</div>}
|
||||
</div>
|
||||
<div className="frow">
|
||||
<label className="flbl">Kategori</label>
|
||||
<select className="fsel" value={form.kategori} onChange={e => setForm(f => ({ ...f, kategori: e.target.value }))}>
|
||||
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="frow">
|
||||
<label className="flbl">Deskripsi <span style={{ fontWeight: 400, opacity: .6 }}>(opsional)</span></label>
|
||||
<input
|
||||
className="finput"
|
||||
placeholder="Penjelasan singkat…"
|
||||
value={form.deskripsi}
|
||||
onChange={e => setForm(f => ({ ...f, deskripsi: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<button className="btn-ok rm" style={{ width: '100%', marginTop: 0 }} onClick={handleAdd} disabled={adding}>
|
||||
{adding ? 'Menyimpan…' : '+ Tambah Jenis Bantuan'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="fcard">
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--txt-3)', textTransform: 'uppercase', letterSpacing: '.08em', marginBottom: 10 }}>
|
||||
Daftar Bantuan ({types.length})
|
||||
</div>
|
||||
{loading ? (
|
||||
<div style={{ padding: '12px 0', textAlign: 'center', color: 'var(--txt-3)', fontSize: 13 }}>Memuat…</div>
|
||||
) : types.length === 0 ? (
|
||||
<div style={{ padding: '12px 0', textAlign: 'center', color: 'var(--txt-3)', fontSize: 13 }}>Belum ada jenis bantuan.</div>
|
||||
) : (
|
||||
<div className="bantuan-type-list">
|
||||
{types.map(t => (
|
||||
<div key={t.id} className="bantuan-type-item">
|
||||
<div className="bantuan-cat-dot" style={{ background: getCategoryColor(t.kategori) }} />
|
||||
<div className="bantuan-type-info">
|
||||
<div className="bantuan-type-nama">{t.nama}</div>
|
||||
<div className="bantuan-type-meta">
|
||||
<span style={{ color: getCategoryColor(t.kategori), background: getCategoryColor(t.kategori) + '22' }} className="history-cat">
|
||||
{t.kategori}
|
||||
</span>
|
||||
{t.deskripsi && <span className="bantuan-type-desc">{t.deskripsi}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button className="bantuan-del-btn" onClick={() => handleDelete(t.id, t.nama)} title="Hapus">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-ft" style={{ justifyContent: 'flex-end' }}>
|
||||
<button className="btn-can" onClick={onClose}>Tutup</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export default function ConfirmDialog({ state, onChoice }) {
|
||||
if (!state.open) return null
|
||||
return (
|
||||
<div className="confirm-overlay" onClick={() => onChoice(false)}>
|
||||
<div className="confirm-box" onClick={e => e.stopPropagation()}>
|
||||
<div className="confirm-title">Konfirmasi Hapus</div>
|
||||
<div className="confirm-msg">{state.message}</div>
|
||||
<div className="confirm-btns">
|
||||
<button className="confirm-cancel" onClick={() => onChoice(false)}>Batal</button>
|
||||
<button className="confirm-ok" onClick={() => onChoice(true)}>Hapus</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function LegendPanel() {
|
||||
const [open, setOpen] = useState(true)
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ bottom: 36, right: 16, width: 186 }}>
|
||||
<div className="leg-hd" onClick={() => setOpen(o => !o)}>
|
||||
<div className="leg-title">Legenda</div>
|
||||
<div className={`leg-chev${open ? '' : ' up'}`}>▲</div>
|
||||
</div>
|
||||
|
||||
<div className={`leg-body-wrap${open ? '' : ' closed'}`}>
|
||||
<div className="leg-sec">
|
||||
<div className="leg-sec-lbl">Rumah Ibadah</div>
|
||||
{[
|
||||
['#059669', 'Masjid'],
|
||||
['#2563eb', 'Gereja'],
|
||||
['#d97706', 'Pura'],
|
||||
['#7c3aed', 'Vihara'],
|
||||
['#e11d48', 'Klenteng'],
|
||||
['#8e8e93', 'Lainnya'],
|
||||
].map(([color, label]) => (
|
||||
<div key={label} className="leg-row">
|
||||
<div className="ld" style={{ background: color }} />
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="leg-sep" />
|
||||
|
||||
<div className="leg-sec" style={{ paddingTop: 7 }}>
|
||||
<div className="leg-sec-lbl">Rumah Miskin</div>
|
||||
<div className="leg-row">
|
||||
<div className="ls" style={{ background: '#f97316' }} />
|
||||
Di luar jangkauan
|
||||
</div>
|
||||
<div className="leg-row">
|
||||
<div className="ls" style={{ background: '#10b981' }} />
|
||||
Dalam binaan
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={`modal-overlay${open ? ' show' : ''}`} onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal-box">
|
||||
<div className="modal-drag"><div className="modal-bar" /></div>
|
||||
<div className="modal-hd">
|
||||
<div>
|
||||
<div className="modal-ttl">{title}</div>
|
||||
<div className="modal-sub">{sub}</div>
|
||||
</div>
|
||||
<button className="modal-x" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body-wrap">
|
||||
<div className="fcard">
|
||||
<div className="frow">
|
||||
<label className="flbl">{isRI ? 'Nama' : 'Nama KK'}</label>
|
||||
<input
|
||||
ref={nameRef}
|
||||
className="finput"
|
||||
placeholder={isRI ? 'Masjid Al-Falah' : 'Budi Santoso'}
|
||||
defaultValue={target?.name || ''}
|
||||
key={`name-${open}-${target?.id}`}
|
||||
/>
|
||||
</div>
|
||||
{isRI && (
|
||||
<div className="frow">
|
||||
<label className="flbl">Jenis</label>
|
||||
<select ref={typeRef} className="fsel" defaultValue={target?.type || 'Masjid'} key={`type-${open}-${target?.id}`}>
|
||||
{RI_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="frow">
|
||||
<label className="flbl">Kontak</label>
|
||||
<input
|
||||
ref={kontakRef}
|
||||
className="finput"
|
||||
placeholder="0812-3456-7890"
|
||||
defaultValue={target?.kontak !== '-' ? (target?.kontak || '') : ''}
|
||||
key={`kontak-${open}-${target?.id}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-ft">
|
||||
<button className="btn-can" onClick={onClose}>Batal</button>
|
||||
<button className={`btn-ok ${isRI ? 'ri' : 'rm'}`} onClick={handleSubmit}>
|
||||
{isAdd ? 'Tambah' : 'Simpan'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="panel" style={{ top: 16, left: 16, width: 226 }}>
|
||||
<div className="app-hd">
|
||||
<div className="app-title-row">
|
||||
<div>
|
||||
<div className="app-title">SIPKEM</div>
|
||||
<div className="app-sub">Peta Kemiskinan · Pontianak</div>
|
||||
</div>
|
||||
<button className="logout-btn" onClick={onLogout} title="Keluar">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className={`mode-badge ${badge.cls}`}>
|
||||
<span className={`bd ${badge.dot}`} />
|
||||
{badge.label}
|
||||
</div>
|
||||
<div className="role-indicator" data-role={role}>
|
||||
{isPengurus ? (
|
||||
<>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2L4 5v6c0 5.5 3.5 10.7 8 12 4.5-1.3 8-6.5 8-12V5l-8-3z"/><path d="M9 12l2 2 4-4"/>
|
||||
</svg>
|
||||
Pengurus
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/>
|
||||
</svg>
|
||||
Pengguna
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPengurus && (
|
||||
<div className="mode-list">
|
||||
<div className="mode-sec">Mode Input</div>
|
||||
<button
|
||||
className={`mode-btn${mode === 'select' ? ' active' : ''}`}
|
||||
onClick={() => onSetMode('select')}
|
||||
>
|
||||
<div className="mico">🖱</div> Pilih & Pindah
|
||||
</button>
|
||||
<button
|
||||
className={`mode-btn ri-btn${mode === 'add-ri' ? ' active' : ''}`}
|
||||
onClick={() => onSetMode('add-ri')}
|
||||
>
|
||||
<div className="mico">🕌</div> Rumah Ibadah
|
||||
</button>
|
||||
<button
|
||||
className={`mode-btn rm-btn${mode === 'add-rm' ? ' active' : ''}`}
|
||||
onClick={() => onSetMode('add-rm')}
|
||||
>
|
||||
<div className="mico">🏠</div> Rumah Miskin
|
||||
</button>
|
||||
|
||||
<div className="mode-sec" style={{ marginTop: 8 }}>Bantuan</div>
|
||||
<button className="mode-btn bantuan-btn" onClick={onManageBantuan}>
|
||||
<div className="mico">🎁</div> Kelola Bantuan
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mode-hint">{hint}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="panel" style={{ bottom: 36, left: 16, width: 216 }}>
|
||||
<div className="pnl-hd">Statistik</div>
|
||||
<div className="stats-grid">
|
||||
<div className="sc">
|
||||
<div className="sn">{riList.length}</div>
|
||||
<div className="sl">Rumah<br />Ibadah</div>
|
||||
</div>
|
||||
<div className="sc no-divider">
|
||||
<div className="sn">{rmList.length}</div>
|
||||
<div className="sl">Rumah<br />Miskin</div>
|
||||
</div>
|
||||
<div className="sc">
|
||||
<div className="sn grn">{covered}</div>
|
||||
<div className="sl">Terjangkau</div>
|
||||
</div>
|
||||
<div className="sc no-divider">
|
||||
<div className="sn org">{uncovered}</div>
|
||||
<div className="sl">Belum<br />Terjangkau</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export default function Toast({ message, visible }) {
|
||||
return (
|
||||
<div className={`sipkem-toast${visible ? ' show' : ''}`}>
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
+273
@@ -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; }
|
||||
@@ -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(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div className="strength-segs">
|
||||
{[0,1,2,3].map(i => (
|
||||
<span key={i} style={{ background: i < score ? color : 'rgba(120,120,128,0.12)' }} />
|
||||
))}
|
||||
</div>
|
||||
{score > 0 && <div className="strength-label" style={{ color }}>{labels[score - 1]}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── EYE BUTTON ────────────────────────────────────────────────────────────
|
||||
function EyeButton({ show, onToggle }) {
|
||||
return (
|
||||
<button className="input-eye" type="button" onClick={onToggle}>
|
||||
{show ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94"/>
|
||||
<path d="M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19"/>
|
||||
<line x1="1" y1="1" x2="23" y2="23"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function BackArrow() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── LANDING ───────────────────────────────────────────────────────────────
|
||||
function LandingScreen({ active, onEnter }) {
|
||||
return (
|
||||
<div className={`auth-screen${active ? ' active' : ''}`} style={{ zIndex: 2 }}>
|
||||
<div className="land-screen-bg">
|
||||
<div className="land-bg-dots" />
|
||||
<svg className="land-lines-svg" viewBox="0 0 1200 800" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
|
||||
<path d="M0,200 Q150,150 300,190 T600,170 Q750,150 900,195 T1200,180" fill="none" stroke="rgba(28,138,95,0.07)" strokeWidth="1.5"/>
|
||||
<path d="M0,380 Q200,320 400,370 T800,340 Q1000,310 1200,355" fill="none" stroke="rgba(28,138,95,0.05)" strokeWidth="1"/>
|
||||
<path d="M0,560 Q180,500 360,545 T720,510 Q900,480 1200,530" fill="none" stroke="rgba(200,75,15,0.04)" strokeWidth="0.8"/>
|
||||
<circle cx="290" cy="195" r="2.5" fill="rgba(28,138,95,0.18)"/>
|
||||
<circle cx="600" cy="172" r="3.5" fill="rgba(28,138,95,0.14)"/>
|
||||
<circle cx="870" cy="200" r="2.5" fill="rgba(28,138,95,0.18)"/>
|
||||
<circle cx="155" cy="370" r="2" fill="rgba(28,138,95,0.12)"/>
|
||||
<circle cx="460" cy="345" r="3" fill="rgba(28,138,95,0.14)"/>
|
||||
<circle cx="740" cy="308" r="2.5" fill="rgba(200,75,15,0.16)"/>
|
||||
<circle cx="1020" cy="350" r="2" fill="rgba(28,138,95,0.12)"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="land-card">
|
||||
<div className="land-badge"><span className="badge-dot" />Sistem Informasi Aktif</div>
|
||||
<div className="land-logo">SIP<em>KEM</em></div>
|
||||
<div className="land-subtitle">Sistem Peta Kemiskinan</div>
|
||||
<div className="land-rule" />
|
||||
<p className="land-desc">
|
||||
Platform pemetaan terpadu untuk <strong>rumah ibadah</strong> dan{' '}
|
||||
<strong>rumah miskin</strong> di wilayah Pontianak — mendukung pendataan,
|
||||
monitoring, dan pembinaan sosial berbasis lokasi.
|
||||
</p>
|
||||
<button className="btn-enter" onClick={onEnter}>
|
||||
Masuk ke Sistem
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div className="land-features">
|
||||
{[['🗺️','Peta Interaktif'],['🕌','Rumah Ibadah'],['🏠','Rumah Miskin'],['📊','Statistik']].map(([icon, label]) => (
|
||||
<div key={label} className="feat-chip"><span>{icon}</span> {label}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── ROLE SELECTION ────────────────────────────────────────────────────────
|
||||
function RoleScreen({ active, selectedRole, onSelect, onBack, onNext }) {
|
||||
return (
|
||||
<div className={`auth-screen${active ? ' active' : ''}`}>
|
||||
<div className="auth-card">
|
||||
<button className="card-back" onClick={onBack}><BackArrow /> Kembali</button>
|
||||
<div className="step-dots">
|
||||
<div className="step-dot active" />
|
||||
<div className="step-dot" />
|
||||
</div>
|
||||
<div className="card-eyebrow">Langkah 1 dari 2</div>
|
||||
<div className="card-title">Pilih Peran Anda</div>
|
||||
<p className="card-sub">Akses sistem disesuaikan berdasarkan peran yang Anda pilih.</p>
|
||||
|
||||
<div className="role-grid">
|
||||
{[
|
||||
{
|
||||
key: 'pengguna',
|
||||
label: 'Pengguna',
|
||||
desc: 'Melihat peta & data yang tersedia',
|
||||
icon: (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#1c8a5f" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'pengurus',
|
||||
label: 'Pengurus',
|
||||
desc: 'Kelola & edit seluruh data sistem',
|
||||
icon: (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#c84b0f" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2L4 5v6c0 5.5 3.5 10.7 8 12 4.5-1.3 8-6.5 8-12V5l-8-3z"/>
|
||||
<path d="M9 12l2 2 4-4"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
].map(({ key, label, desc, icon }) => (
|
||||
<div
|
||||
key={key}
|
||||
className={`role-card ${key}${selectedRole === key ? ' selected' : ''}`}
|
||||
onClick={() => onSelect(key)}
|
||||
>
|
||||
<div className="check-badge">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 6L9 17l-5-5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="role-icon">{icon}</div>
|
||||
<div className="role-name">{label}</div>
|
||||
<div className="role-desc">{desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`btn-primary ${selectedRole === 'pengurus' ? 'earth' : 'green'}`}
|
||||
disabled={!selectedRole}
|
||||
onClick={onNext}
|
||||
>
|
||||
<span className="btn-text">Lanjutkan</span>
|
||||
<span className="spinner" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className={`auth-screen${active ? ' active' : ''}`}>
|
||||
<div className="auth-card">
|
||||
<button className="card-back" onClick={onBack}><BackArrow /> Ganti Peran</button>
|
||||
<div className="step-dots">
|
||||
<div className="step-dot done" />
|
||||
<div className="step-dot active" />
|
||||
</div>
|
||||
|
||||
<div className={`role-pill ${role}`}>
|
||||
{isPengurus ? (
|
||||
<>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2L4 5v6c0 5.5 3.5 10.7 8 12 4.5-1.3 8-6.5 8-12V5l-8-3z"/>
|
||||
<path d="M9 12l2 2 4-4"/>
|
||||
</svg>
|
||||
Pengurus
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/>
|
||||
</svg>
|
||||
Pengguna
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card-eyebrow" style={{ marginTop: 8 }}>Masuk Akun</div>
|
||||
<div className="card-title">Selamat Datang</div>
|
||||
<p className="card-sub">
|
||||
{isPengurus
|
||||
? 'Masukkan kredensial akun Pengurus Anda.'
|
||||
: 'Masukkan email dan kata sandi Anda untuk melanjutkan.'}
|
||||
</p>
|
||||
|
||||
{/* Role mismatch error banner */}
|
||||
{errors.role && (
|
||||
<div className="error-banner">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
{errors.role}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Alamat Email</label>
|
||||
<input
|
||||
className={`form-input${errors.email ? ' err' : ''}`}
|
||||
type="email"
|
||||
placeholder="nama@email.com"
|
||||
value={email}
|
||||
onChange={e => { setEmail(e.target.value); setErrors(p => ({ ...p, email: '', api: '' })) }}
|
||||
onKeyDown={e => e.key === 'Enter' && handleLogin()}
|
||||
autoComplete="email"
|
||||
/>
|
||||
{errors.email && <div className="form-error show">{errors.email}</div>}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Kata Sandi</label>
|
||||
<div className="input-wrap">
|
||||
<input
|
||||
className={`form-input${errors.pass || errors.api ? ' err' : ''}`}
|
||||
type={showPass ? 'text' : 'password'}
|
||||
placeholder="Min. 8 karakter"
|
||||
value={pass}
|
||||
onChange={e => { setPass(e.target.value); setErrors(p => ({ ...p, pass: '', api: '' })) }}
|
||||
onKeyDown={e => e.key === 'Enter' && handleLogin()}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<EyeButton show={showPass} onToggle={() => setShowPass(s => !s)} />
|
||||
</div>
|
||||
{errors.pass && <div className="form-error show">{errors.pass}</div>}
|
||||
{errors.api && <div className="form-error show">{errors.api}</div>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`btn-primary ${isPengurus ? 'earth' : 'green'}${loading ? ' loading' : ''}`}
|
||||
onClick={handleLogin}
|
||||
disabled={loading}
|
||||
>
|
||||
<span className="btn-text">Masuk</span>
|
||||
<span className="spinner" />
|
||||
</button>
|
||||
|
||||
{/* Pengurus: no register link */}
|
||||
{!isPengurus && (
|
||||
<div className="bottom-link">
|
||||
Belum punya akun?
|
||||
<button className="link-text" style={{ marginLeft: 4 }} onClick={onGoRegister}>
|
||||
Daftar di sini
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPengurus && (
|
||||
<div className="pengurus-note">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
Akun Pengurus hanya dapat dibuat oleh administrator sistem.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className={`auth-screen${active ? ' active' : ''}`}>
|
||||
<div className="auth-card">
|
||||
<button className="card-back" onClick={onBack}><BackArrow /> Kembali</button>
|
||||
<div className="card-eyebrow">Daftar Akun</div>
|
||||
<div className="card-title">Buat Akun Baru</div>
|
||||
<p className="card-sub">Isi data di bawah untuk membuat akun Pengguna SIPKEM.</p>
|
||||
|
||||
{errors.api && (
|
||||
<div className="error-banner">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
{errors.api}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Nama Lengkap</label>
|
||||
<input
|
||||
className={`form-input${errors.name ? ' err' : ''}`}
|
||||
type="text" placeholder="Nama Anda"
|
||||
value={name}
|
||||
onChange={e => { setName(e.target.value); setErrors(p => ({ ...p, name: '' })) }}
|
||||
autoComplete="name"
|
||||
/>
|
||||
{errors.name && <div className="form-error show">{errors.name}</div>}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Alamat Email</label>
|
||||
<input
|
||||
className={`form-input${errors.email ? ' err' : ''}`}
|
||||
type="email" placeholder="nama@email.com"
|
||||
value={email}
|
||||
onChange={e => { setEmail(e.target.value); setErrors(p => ({ ...p, email: '' })) }}
|
||||
autoComplete="email"
|
||||
/>
|
||||
{errors.email && <div className="form-error show">{errors.email}</div>}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Kata Sandi</label>
|
||||
<div className="input-wrap">
|
||||
<input
|
||||
className={`form-input${errors.pass ? ' err' : ''}`}
|
||||
type={showP ? 'text' : 'password'} placeholder="Min. 8 karakter"
|
||||
value={pass}
|
||||
onChange={e => { setPass(e.target.value); setErrors(p => ({ ...p, pass: '' })) }}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<EyeButton show={showP} onToggle={() => setShowP(s => !s)} />
|
||||
</div>
|
||||
<PasswordStrength value={pass} />
|
||||
{errors.pass && <div className="form-error show">{errors.pass}</div>}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Ulangi Kata Sandi</label>
|
||||
<div className="input-wrap">
|
||||
<input
|
||||
className={`form-input${errors.pass2 ? ' err' : ''}`}
|
||||
type={showP2 ? 'text' : 'password'} placeholder="Ulangi kata sandi"
|
||||
value={pass2}
|
||||
onChange={e => { setPass2(e.target.value); setErrors(p => ({ ...p, pass2: '' })) }}
|
||||
onKeyDown={e => e.key === 'Enter' && handleRegister()}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<EyeButton show={showP2} onToggle={() => setShowP2(s => !s)} />
|
||||
</div>
|
||||
{errors.pass2 && <div className="form-error show">{errors.pass2}</div>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`btn-primary green${loading ? ' loading' : ''}`}
|
||||
onClick={handleRegister}
|
||||
disabled={loading}
|
||||
>
|
||||
<span className="btn-text">Buat Akun</span>
|
||||
<span className="spinner" />
|
||||
</button>
|
||||
|
||||
<div className="bottom-link" style={{ marginTop: 16 }}>
|
||||
Sudah punya akun?
|
||||
<button className="link-text" style={{ marginLeft: 4 }} onClick={onBack}>
|
||||
Masuk di sini
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className="auth-root">
|
||||
{screen !== 'landing' && <div className="auth-bg-layer" />}
|
||||
|
||||
<LandingScreen active={screen === 'landing'} onEnter={() => goTo('role')} />
|
||||
|
||||
<RoleScreen
|
||||
active={screen === 'role'}
|
||||
selectedRole={role}
|
||||
onSelect={setRole}
|
||||
onBack={() => goTo('landing')}
|
||||
onNext={() => { if (role) goTo('login') }}
|
||||
/>
|
||||
|
||||
<LoginScreen
|
||||
active={screen === 'login'}
|
||||
role={role || 'pengguna'}
|
||||
onBack={() => 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' && (
|
||||
<RegisterScreen
|
||||
active={screen === 'register'}
|
||||
onBack={() => goTo('login')}
|
||||
showToast={showToast}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Toast message={message} visible={visible} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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: `<div style="width:40px;height:40px;background:${c};border-radius:50%;border:3px solid #fff;box-shadow:0 3px 14px rgba(0,0,0,.28);display:flex;align-items:center;justify-content:center;font-size:19px;">${e}</div>`,
|
||||
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: `<div style="width:30px;height:30px;background:${c};border-radius:8px;border:2.5px solid #fff;box-shadow:0 2px 10px rgba(0,0,0,.22);display:flex;align-items:center;justify-content:center;color:#fff;font-weight:900;font-size:14px;">${s}</div>`,
|
||||
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 => `<div class="binitem">🏠 <span>${rm.name}</span><span class="bindist">${Math.round(rm.assignedDist || 0)}m</span></div>`).join('')
|
||||
: `<div class="binempty">Belum ada dalam jangkauan</div>`
|
||||
|
||||
const editBtns = isPengurus
|
||||
? `<div class="pu-acts">
|
||||
<button class="pu-btn pu-edit" id="ri-ebtn-${ri.id}">Edit</button>
|
||||
<button class="pu-btn pu-del" id="ri-dbtn-${ri.id}">Hapus</button>
|
||||
</div>`
|
||||
: ''
|
||||
|
||||
const rangeSection = isPengurus
|
||||
? `<div class="pu-range">
|
||||
<div class="rlbl"><span>Jangkauan Binaan</span><span class="rval" id="ri-rdis-${ri.id}">${ri.range}m</span></div>
|
||||
<input type="range" id="ri-rng-${ri.id}" min="300" max="800" step="50" value="${ri.range}"
|
||||
style="background:linear-gradient(to right,#1c8a5f 0%,#1c8a5f ${pct}%,#d1d1d6 ${pct}%)">
|
||||
<div class="rticks"><span>300m</span><span>550m</span><span>800m</span></div>
|
||||
</div>`
|
||||
: `<div class="pu-range-ro">
|
||||
<div class="rlbl"><span>Jangkauan Binaan</span><span class="rval">${ri.range}m</span></div>
|
||||
</div>`
|
||||
|
||||
return `<div class="pu-wrap">
|
||||
<div class="pu-head" style="background:linear-gradient(145deg,${c}f0,${c}b0);">
|
||||
<span class="pu-badge">${ri.type}</span>
|
||||
<div class="pu-name">${ri.name}</div>
|
||||
</div>
|
||||
<div class="pu-body">
|
||||
<div class="irow"><span class="iico">📞</span><span>${ri.kontak}</span></div>
|
||||
<div class="irow"><span class="iico">📍</span><span class="iaddr" id="ri-addr-${ri.id}">${ri.address || 'Memuat alamat…'}</span></div>
|
||||
<div class="irow"><span class="iico">🌐</span><span class="icoords">${ri.lat.toFixed(5)}, ${ri.lng.toFixed(5)}</span></div>
|
||||
</div>
|
||||
${rangeSection}
|
||||
<div class="pu-binaan">
|
||||
<div class="binhd">Rumah Binaan <span id="ri-bcount-${ri.id}">(${binaan.length})</span></div>
|
||||
<div id="ri-blist-${ri.id}">${bHTML}</div>
|
||||
</div>
|
||||
${editBtns}
|
||||
</div>`
|
||||
}
|
||||
|
||||
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
|
||||
? `<span class="achip ayes">✅ ${assignedRI.name} <span style="font-weight:400;opacity:.7;">${Math.round(rm.assignedDist || 0)}m</span></span>`
|
||||
: `<span class="achip ano">❌ Di luar jangkauan</span>`
|
||||
|
||||
const editBtns = isPengurus
|
||||
? `<div class="pu-acts">
|
||||
<button class="pu-btn pu-history" id="rm-hbtn-${rm.id}">📋 Riwayat</button>
|
||||
<button class="pu-btn pu-bantuan" id="rm-bbtn-${rm.id}">🎁 Bantuan</button>
|
||||
</div>
|
||||
<div class="pu-acts" style="margin-top:4px">
|
||||
<button class="pu-btn pu-edit" id="rm-ebtn-${rm.id}">Edit</button>
|
||||
<button class="pu-btn pu-del" id="rm-dbtn-${rm.id}">Hapus</button>
|
||||
</div>`
|
||||
: `<div class="pu-acts">
|
||||
<button class="pu-btn pu-history" id="rm-hbtn-${rm.id}">📋 Riwayat Bantuan</button>
|
||||
</div>`
|
||||
|
||||
return `<div class="pu-wrap">
|
||||
<div class="pu-head" style="background:${hdr};">
|
||||
<span class="pu-badge">Rumah Miskin</span>
|
||||
<div class="pu-name">${rm.name}</div>
|
||||
</div>
|
||||
<div class="pu-body">
|
||||
<div class="irow"><span class="iico">📞</span><span>${rm.kontak}</span></div>
|
||||
<div class="irow"><span class="iico">📍</span><span class="iaddr" id="rm-addr-${rm.id}">${rm.address || 'Memuat alamat…'}</span></div>
|
||||
<div class="irow"><span class="iico">🌐</span><span class="icoords">${rm.lat.toFixed(5)}, ${rm.lng.toFixed(5)}</span></div>
|
||||
</div>
|
||||
<div class="pu-asgn">
|
||||
<div class="asgn-lbl">Status Binaan</div>
|
||||
<div id="rm-chip-${rm.id}">${chip}</div>
|
||||
</div>
|
||||
${editBtns}
|
||||
</div>`
|
||||
}
|
||||
|
||||
// ── 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 => `<div class="binitem">🏠 <span>${rm.name}</span><span class="bindist">${Math.round(rm.assignedDist || 0)}m</span></div>`).join('')
|
||||
: `<div class="binempty">Belum ada dalam jangkauan</div>`
|
||||
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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
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 (
|
||||
<>
|
||||
<div ref={mapRef} id="map" style={{ width: '100%', height: '100vh' }} />
|
||||
|
||||
<ModePanel
|
||||
mode={mode}
|
||||
onSetMode={setMode}
|
||||
role={role}
|
||||
onLogout={() => navigate('/')}
|
||||
onManageBantuan={() => setManageBantuanModal(true)}
|
||||
/>
|
||||
<StatsPanel riList={riList} rmList={rmList} />
|
||||
<LegendPanel />
|
||||
|
||||
<Modal
|
||||
state={modal}
|
||||
onClose={closeModal}
|
||||
onSubmit={handleModalSubmit}
|
||||
/>
|
||||
|
||||
<AddBantuanModal
|
||||
rm={addBantuanModal.rm}
|
||||
open={addBantuanModal.open}
|
||||
onClose={() => setAddBantuanModal({ open: false, rm: null })}
|
||||
onSuccess={() => showToast('✅ Bantuan berhasil dikonfirmasi')}
|
||||
/>
|
||||
|
||||
<HistoryBantuanModal
|
||||
rm={historyBantuanModal.rm}
|
||||
open={historyBantuanModal.open}
|
||||
onClose={() => setHistoryBantuanModal({ open: false, rm: null })}
|
||||
/>
|
||||
|
||||
<ManageBantuanModal
|
||||
open={manageBantuanModal}
|
||||
onClose={() => setManageBantuanModal(false)}
|
||||
/>
|
||||
|
||||
<Toast message={toastMsg} visible={toastVisible} />
|
||||
<ConfirmDialog state={confirmState} onChoice={handleChoice} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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']
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user