Initial commit

This commit is contained in:
azgrey
2026-06-05 15:09:45 +07:00
commit bda6ce731a
25 changed files with 5313 additions and 0 deletions
+29
View File
@@ -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/
+77
View File
@@ -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 |
+281
View File
@@ -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
View File
@@ -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>
+1755
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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"
}
}
+158
View File
@@ -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
View File
@@ -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>
)
}
+381
View File
@@ -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
}
}
+15
View File
@@ -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>
)
}
+47
View File
@@ -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>
)
}
+104
View File
@@ -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>
)
}
+87
View File
@@ -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 &amp; 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>
)
}
+28
View File
@@ -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>
)
}
+7
View File
@@ -0,0 +1,7 @@
export default function Toast({ message, visible }) {
return (
<div className={`sipkem-toast${visible ? ' show' : ''}`}>
{message}
</div>
)
}
+18
View File
@@ -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 }
}
+16
View File
@@ -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
View File
@@ -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; }
+13
View File
@@ -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>
)
+533
View File
@@ -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;
}
+511
View File
@@ -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>
)
}
+274
View File
@@ -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; }
+557
View File
@@ -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} />
</>
)
}
+76
View File
@@ -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']
+16
View File
@@ -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,
}
}
}
})