diff --git a/backend/routes/poverty.js b/backend/routes/poverty.js
index a3d6a65..2b5b731 100644
--- a/backend/routes/poverty.js
+++ b/backend/routes/poverty.js
@@ -5,15 +5,17 @@ const TEMPAT_IBADAH_TYPES = ["Gereja Katolik", "GPIB", "GKI", "HKBP", "Pentakost
const createTempatIbadah = `
CREATE TABLE IF NOT EXISTS tempat_ibadah (
- id INT AUTO_INCREMENT PRIMARY KEY,
- nama VARCHAR(150) NOT NULL,
- jenis ENUM('Gereja Katolik','GPIB','GKI','HKBP','Pentakosta') NOT NULL,
- alamat TEXT NOT NULL,
- kelurahan VARCHAR(100) NOT NULL,
- lat DOUBLE NOT NULL,
- lng DOUBLE NOT NULL,
- aktif TINYINT(1) NOT NULL DEFAULT 1,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ nama VARCHAR(150) NOT NULL,
+ jenis ENUM('Gereja Katolik','GPIB','GKI','HKBP','Pentakosta') NOT NULL,
+ alamat TEXT NOT NULL,
+ kelurahan VARCHAR(100) NOT NULL,
+ lat DOUBLE NOT NULL,
+ lng DOUBLE NOT NULL,
+ kapasitas_bantuan INT NOT NULL DEFAULT 0,
+ keterangan TEXT,
+ aktif TINYINT(1) NOT NULL DEFAULT 1,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`;
@@ -26,6 +28,12 @@ async function initTempatIbadahTable() {
if (!columns.includes("created_at")) {
await db.execute("ALTER TABLE tempat_ibadah ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP");
}
+ if (!columns.includes("kapasitas_bantuan")) {
+ await db.execute("ALTER TABLE tempat_ibadah ADD COLUMN kapasitas_bantuan INT NOT NULL DEFAULT 0");
+ }
+ if (!columns.includes("keterangan")) {
+ await db.execute("ALTER TABLE tempat_ibadah ADD COLUMN keterangan TEXT");
+ }
await db.execute(`
UPDATE tempat_ibadah
SET jenis = 'Gereja Katolik'
@@ -291,6 +299,24 @@ async function ensureTables() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
+ await db.execute(`
+ CREATE TABLE IF NOT EXISTS pengajuan_bantuan (
+ id_pengajuan INT AUTO_INCREMENT PRIMARY KEY,
+ nama_pemohon VARCHAR(160) NOT NULL,
+ nik VARCHAR(20) NOT NULL,
+ alamat TEXT NOT NULL,
+ wilayah VARCHAR(120) NOT NULL DEFAULT 'Belum Dipetakan',
+ alasan TEXT NOT NULL,
+ status ENUM('menunggu','diverifikasi','disetujui','ditolak','selesai') NOT NULL DEFAULT 'menunggu',
+ catatan_admin TEXT,
+ id_keluarga INT,
+ tanggal_pengajuan DATE NOT NULL,
+ tanggal_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT fk_pengajuan_keluarga FOREIGN KEY (id_keluarga) REFERENCES keluarga(id_keluarga) ON DELETE SET NULL
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+ `);
+
await seedDefaults();
await calculateSaw();
tablesReady = true;
@@ -560,9 +586,33 @@ function validateIbadah(body) {
if (!Number.isFinite(Number(body.lat)) || !Number.isFinite(Number(body.lng))) {
return "Latitude dan longitude wajib valid.";
}
+ if (body.kapasitas_bantuan !== undefined && !Number.isFinite(Number(body.kapasitas_bantuan))) {
+ return "Kapasitas bantuan harus berupa angka.";
+ }
return null;
}
+function validatePengajuan(body) {
+ const required = ["nama_pemohon", "nik", "alamat", "alasan", "tanggal_pengajuan"];
+ for (const key of required) {
+ if (!body[key] || String(body[key]).trim() === "") {
+ return `${key} wajib diisi.`;
+ }
+ }
+ return null;
+}
+
+// Haversine distance in meters
+function haversineMeters(lat1, lng1, lat2, lng2) {
+ const R = 6371000;
+ const dLat = ((lat2 - lat1) * Math.PI) / 180;
+ const dLng = ((lng2 - lng1) * Math.PI) / 180;
+ const a =
+ Math.sin(dLat / 2) ** 2 +
+ Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLng / 2) ** 2;
+ return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+}
+
router.use(async (req, res, next) => {
try {
await ensureTables();
@@ -1000,8 +1050,8 @@ router.post("/ibadah", async (req, res) => {
const body = req.body;
try {
const [result] = await db.execute(
- "INSERT INTO tempat_ibadah (nama, jenis, alamat, kelurahan, lat, lng) VALUES (?, ?, ?, ?, ?, ?)",
- [body.nama, body.jenis, body.alamat, body.kelurahan, Number(body.lat), Number(body.lng)],
+ "INSERT INTO tempat_ibadah (nama, jenis, alamat, kelurahan, lat, lng, kapasitas_bantuan, keterangan) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ [body.nama, body.jenis, body.alamat, body.kelurahan, Number(body.lat), Number(body.lng), toNumber(body.kapasitas_bantuan), body.keterangan || null],
);
res.status(201).json({ success: true, id: result.insertId, message: "Tempat ibadah berhasil ditambahkan." });
} catch (err) {
@@ -1015,8 +1065,8 @@ router.put("/ibadah/:id", async (req, res) => {
const body = req.body;
try {
const [result] = await db.execute(
- "UPDATE tempat_ibadah SET nama=?, jenis=?, alamat=?, kelurahan=?, lat=?, lng=? WHERE id=? AND aktif = 1",
- [body.nama, body.jenis, body.alamat, body.kelurahan, Number(body.lat), Number(body.lng), req.params.id],
+ "UPDATE tempat_ibadah SET nama=?, jenis=?, alamat=?, kelurahan=?, lat=?, lng=?, kapasitas_bantuan=?, keterangan=? WHERE id=? AND aktif = 1",
+ [body.nama, body.jenis, body.alamat, body.kelurahan, Number(body.lat), Number(body.lng), toNumber(body.kapasitas_bantuan), body.keterangan || null, req.params.id],
);
if (!result.affectedRows) return res.status(404).json({ success: false, message: "Tempat ibadah tidak ditemukan." });
res.json({ success: true, message: "Tempat ibadah berhasil diperbarui." });
@@ -1025,6 +1075,104 @@ router.put("/ibadah/:id", async (req, res) => {
}
});
+// ─── Nearest Church ─────────────────────────────────────────────────────────
+router.get("/nearest-church", async (req, res) => {
+ try {
+ const lat = Number(req.query.lat);
+ const lng = Number(req.query.lng);
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
+ return res.status(400).json({ success: false, message: "lat dan lng wajib valid." });
+ }
+ const [rows] = await db.execute("SELECT * FROM tempat_ibadah WHERE aktif = 1 ORDER BY nama ASC");
+ if (!rows.length) return res.json({ success: true, data: null });
+ let nearest = null;
+ let minDist = Infinity;
+ for (const item of rows) {
+ const dist = haversineMeters(lat, lng, Number(item.lat), Number(item.lng));
+ if (dist < minDist) {
+ minDist = dist;
+ nearest = { ...item, jarak_meter: Math.round(dist) };
+ }
+ }
+ // Also return all churches sorted by distance
+ const sorted = rows
+ .map((item) => ({ ...item, jarak_meter: Math.round(haversineMeters(lat, lng, Number(item.lat), Number(item.lng))) }))
+ .sort((a, b) => a.jarak_meter - b.jarak_meter);
+ res.json({ success: true, data: { nearest, all: sorted } });
+ } catch (error) {
+ res.status(500).json({ success: false, message: error.message });
+ }
+});
+
+// ─── Pengajuan Bantuan ───────────────────────────────────────────────────────
+router.get("/pengajuan", async (req, res) => {
+ try {
+ const status = req.query.status || "";
+ const sql = status
+ ? "SELECT * FROM pengajuan_bantuan WHERE status = ? ORDER BY created_at DESC"
+ : "SELECT * FROM pengajuan_bantuan ORDER BY created_at DESC";
+ const params = status ? [status] : [];
+ const [rows] = await db.execute(sql, params);
+ res.json({ success: true, data: rows });
+ } catch (error) {
+ res.status(500).json({ success: false, message: error.message });
+ }
+});
+
+router.get("/pengajuan/:id", async (req, res) => {
+ try {
+ const [rows] = await db.execute("SELECT * FROM pengajuan_bantuan WHERE id_pengajuan = ?", [req.params.id]);
+ if (!rows.length) return res.status(404).json({ success: false, message: "Pengajuan tidak ditemukan." });
+ res.json({ success: true, data: rows[0] });
+ } catch (error) {
+ res.status(500).json({ success: false, message: error.message });
+ }
+});
+
+router.post("/pengajuan", async (req, res) => {
+ const error = validatePengajuan(req.body);
+ if (error) return res.status(400).json({ success: false, message: error });
+ const body = req.body;
+ try {
+ const [result] = await db.execute(
+ `INSERT INTO pengajuan_bantuan (nama_pemohon, nik, alamat, wilayah, alasan, tanggal_pengajuan)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ [body.nama_pemohon.trim(), body.nik.trim(), body.alamat.trim(), body.wilayah || "Belum Dipetakan", body.alasan.trim(), body.tanggal_pengajuan],
+ );
+ res.status(201).json({ success: true, id: result.insertId, message: "Pengajuan berhasil dikirim. Kami akan segera menghubungi Anda." });
+ } catch (err) {
+ res.status(500).json({ success: false, message: err.message });
+ }
+});
+
+router.put("/pengajuan/:id/status", async (req, res) => {
+ const { status, catatan_admin, id_keluarga } = req.body;
+ const validStatuses = ["menunggu", "diverifikasi", "disetujui", "ditolak", "selesai"];
+ if (!validStatuses.includes(status)) {
+ return res.status(400).json({ success: false, message: "Status tidak valid." });
+ }
+ try {
+ const [result] = await db.execute(
+ "UPDATE pengajuan_bantuan SET status=?, catatan_admin=?, id_keluarga=? WHERE id_pengajuan=?",
+ [status, catatan_admin || null, id_keluarga || null, req.params.id],
+ );
+ if (!result.affectedRows) return res.status(404).json({ success: false, message: "Pengajuan tidak ditemukan." });
+ res.json({ success: true, message: `Status pengajuan diperbarui menjadi '${status}'.` });
+ } catch (error) {
+ res.status(500).json({ success: false, message: error.message });
+ }
+});
+
+router.delete("/pengajuan/:id", async (req, res) => {
+ try {
+ const [result] = await db.execute("DELETE FROM pengajuan_bantuan WHERE id_pengajuan = ?", [req.params.id]);
+ if (!result.affectedRows) return res.status(404).json({ success: false, message: "Pengajuan tidak ditemukan." });
+ res.json({ success: true, message: "Pengajuan berhasil dihapus." });
+ } catch (error) {
+ res.status(500).json({ success: false, message: error.message });
+ }
+});
+
router.delete("/ibadah/:id", async (req, res) => {
try {
const [result] = await db.execute("UPDATE tempat_ibadah SET aktif = 0 WHERE id = ?", [req.params.id]);
diff --git a/frontend/admin-poverty.html b/frontend/admin-poverty.html
index aa6e85d..d6cf8b8 100644
--- a/frontend/admin-poverty.html
+++ b/frontend/admin-poverty.html
@@ -137,6 +137,12 @@
.butuh { background: #ffedd5; color: #7c2d12; }
.rentan { background: #fef9c3; color: #713f12; }
.mampu { background: #dcfce7; color: #14532d; }
+ /* Status pengajuan */
+ .status-menunggu { background: #fef3c7; color: #92400e; }
+ .status-diverifikasi { background: #dbeafe; color: #1e40af; }
+ .status-disetujui { background: #dcfce7; color: #14532d; }
+ .status-ditolak { background: #fee2e2; color: #7f1d1d; }
+ .status-selesai { background: #e0e7ff; color: #3730a3; }
.bar-row { display: grid; grid-template-columns: 150px 1fr 48px; gap: 10px; align-items: center; margin: 10px 0; font-size: .86rem; }
.bar-track { height: 12px; border-radius: 99px; background: #e2e8f0; overflow: hidden; }
.bar-fill { height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--accent), var(--accent-2)); }
@@ -203,8 +209,10 @@
+
Buka Peta Poverty
+ Form Pengajuan Publik
Admin SPBU
@@ -343,6 +351,9 @@
+
+
+
@@ -365,13 +376,73 @@
Daftar Tempat Ibadah
0 data
- | # | Nama | Jenis | Alamat | Kelurahan | Koordinat | Aksi |
+ | # | Nama | Jenis | Alamat | Kelurahan | Koordinat | Kapasitas | Aksi |
+
+
+
+
Total Pengajuan0
+
Menunggu
+
Disetujui0
+
+
+
+
Daftar Pengajuan Bantuan
+
+
+
+
+ | # | Pemohon | NIK | Wilayah | Alasan | Tgl Pengajuan | Status | Aksi |
+
+
+
+
+
+
+
+
+
Update Status Pengajuan
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Preview Peta Keluarga Prioritas
Klik peta untuk mengisi koordinat form keluarga.
@@ -384,7 +455,7 @@
+