diff --git a/backend/routes/poverty.js b/backend/routes/poverty.js
new file mode 100644
index 0000000..c30b69e
--- /dev/null
+++ b/backend/routes/poverty.js
@@ -0,0 +1,325 @@
+const express = require("express");
+const db = require("../db");
+
+const router = express.Router();
+
+const JENIS_IBADAH = ["masjid", "gereja", "pura", "vihara", "kelenteng", "lainnya"];
+const KATEGORI_KEMISKINAN = ["sangat_miskin", "miskin", "rentan_miskin"];
+
+let tablesReady = false;
+
+function toFeatureCollection(rows) {
+ return {
+ type: "FeatureCollection",
+ features: rows.map((row) => {
+ const { lat, lng, ...properties } = row;
+ return {
+ type: "Feature",
+ geometry: {
+ type: "Point",
+ coordinates: [Number(lng), Number(lat)],
+ },
+ properties: {
+ ...properties,
+ lat: Number(lat),
+ lng: Number(lng),
+ },
+ };
+ }),
+ };
+}
+
+function validateCoordinate(lat, lng) {
+ return Number.isFinite(Number(lat)) && Number.isFinite(Number(lng));
+}
+
+function escapeCsv(value) {
+ return `"${String(value ?? "").replace(/"/g, '""')}"`;
+}
+
+async function createTableWithFallback(primarySql, fallbackSql) {
+ try {
+ await db.execute(primarySql);
+ } catch (error) {
+ if (!fallbackSql) throw error;
+ await db.execute(fallbackSql);
+ }
+}
+
+async function ensureTables() {
+ if (tablesReady) return;
+
+ const tempatIbadahWithGeom = `
+ CREATE TABLE IF NOT EXISTS tempat_ibadah (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ nama VARCHAR(255) NOT NULL,
+ jenis ENUM('masjid','gereja','pura','vihara','kelenteng','lainnya') NOT NULL,
+ alamat TEXT,
+ kelurahan VARCHAR(100),
+ lat DOUBLE NOT NULL,
+ lng DOUBLE NOT NULL,
+ geom POINT GENERATED ALWAYS AS (ST_SRID(POINT(lng, lat), 4326)) STORED,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ SPATIAL INDEX idx_tempat_ibadah_geom (geom)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+ `;
+
+ const tempatIbadahFallback = `
+ CREATE TABLE IF NOT EXISTS tempat_ibadah (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ nama VARCHAR(255) NOT NULL,
+ jenis ENUM('masjid','gereja','pura','vihara','kelenteng','lainnya') NOT NULL,
+ alamat TEXT,
+ kelurahan VARCHAR(100),
+ lat DOUBLE NOT NULL,
+ lng DOUBLE NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ INDEX idx_tempat_ibadah_lat_lng (lat, lng)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+ `;
+
+ const keluargaWithGeom = `
+ CREATE TABLE IF NOT EXISTS keluarga_miskin (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ nama_kk VARCHAR(255) NOT NULL,
+ jumlah_anggota INT NOT NULL DEFAULT 1,
+ kategori ENUM('sangat_miskin','miskin','rentan_miskin') NOT NULL,
+ alamat TEXT,
+ rt VARCHAR(5),
+ rw VARCHAR(5),
+ kelurahan VARCHAR(100),
+ keterangan TEXT,
+ lat DOUBLE NOT NULL,
+ lng DOUBLE NOT NULL,
+ geom POINT GENERATED ALWAYS AS (ST_SRID(POINT(lng, lat), 4326)) STORED,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ SPATIAL INDEX idx_keluarga_geom (geom)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+ `;
+
+ const keluargaFallback = `
+ CREATE TABLE IF NOT EXISTS keluarga_miskin (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ nama_kk VARCHAR(255) NOT NULL,
+ jumlah_anggota INT NOT NULL DEFAULT 1,
+ kategori ENUM('sangat_miskin','miskin','rentan_miskin') NOT NULL,
+ alamat TEXT,
+ rt VARCHAR(5),
+ rw VARCHAR(5),
+ kelurahan VARCHAR(100),
+ keterangan TEXT,
+ lat DOUBLE NOT NULL,
+ lng DOUBLE NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX idx_keluarga_lat_lng (lat, lng)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+ `;
+
+ await createTableWithFallback(tempatIbadahWithGeom, tempatIbadahFallback);
+ await createTableWithFallback(keluargaWithGeom, keluargaFallback);
+ await seedPovertyDataIfEmpty();
+ tablesReady = true;
+}
+
+async function seedPovertyDataIfEmpty() {
+ const [[{ count: tempatCount }]] = await db.execute("SELECT COUNT(*) AS count FROM tempat_ibadah");
+ if (Number(tempatCount) === 0) {
+ const rows = [
+ ["Masjid Jami Sultan Syarif Abdurrahman", "masjid", "Jl. Tanjungpura", "Dalam Bugis", -0.0226, 109.3316],
+ ["Gereja Bethel Indonesia", "gereja", "Jl. Gajahmada", "Sungai Bangkong", -0.0334, 109.3253],
+ ["Vihara Bodhisattva", "vihara", "Jl. Diponegoro", "Mariana", -0.0289, 109.3401],
+ ];
+ for (const row of rows) {
+ await db.execute(
+ "INSERT INTO tempat_ibadah (nama, jenis, alamat, kelurahan, lat, lng) VALUES (?, ?, ?, ?, ?, ?)",
+ row,
+ );
+ }
+ }
+
+ const [[{ count: keluargaCount }]] = await db.execute("SELECT COUNT(*) AS count FROM keluarga_miskin");
+ if (Number(keluargaCount) === 0) {
+ const rows = [
+ ["Ahmad Fauzi", 5, "sangat_miskin", "Dalam Bugis", -0.0231, 109.3321],
+ ["Siti Rahmah", 3, "miskin", "Dalam Bugis", -0.0219, 109.3308],
+ ["Budi Santoso", 6, "rentan_miskin", "Sungai Bangkong", -0.0341, 109.3261],
+ ["Maria Goretti", 4, "miskin", "Mariana", -0.0295, 109.3412],
+ ["Lim Ah Kow", 2, "sangat_miskin", "Mariana", -0.0501, 109.36],
+ ];
+ for (const row of rows) {
+ await db.execute(
+ "INSERT INTO keluarga_miskin (nama_kk, jumlah_anggota, kategori, kelurahan, lat, lng) VALUES (?, ?, ?, ?, ?, ?)",
+ row,
+ );
+ }
+ }
+}
+
+router.use(async (req, res, next) => {
+ try {
+ await ensureTables();
+ next();
+ } catch (error) {
+ res.status(500).json({ success: false, message: error.message });
+ }
+});
+
+router.get("/tempat-ibadah", async (req, res) => {
+ try {
+ const [rows] = await db.execute(
+ "SELECT id, nama, jenis, alamat, kelurahan, lat, lng, created_at FROM tempat_ibadah ORDER BY nama ASC",
+ );
+ res.json(toFeatureCollection(rows));
+ } catch (error) {
+ res.status(500).json({ success: false, message: error.message });
+ }
+});
+
+router.post("/tempat-ibadah", async (req, res) => {
+ const { nama, jenis, alamat, kelurahan, lat, lng } = req.body;
+ if (!nama || !JENIS_IBADAH.includes(jenis) || !validateCoordinate(lat, lng)) {
+ return res.status(400).json({ success: false, message: "Nama, jenis, lat, dan lng wajib valid." });
+ }
+
+ try {
+ const [result] = await db.execute(
+ "INSERT INTO tempat_ibadah (nama, jenis, alamat, kelurahan, lat, lng) VALUES (?, ?, ?, ?, ?, ?)",
+ [nama.trim(), jenis, alamat || null, kelurahan || null, Number(lat), Number(lng)],
+ );
+ res.status(201).json({ success: true, id: result.insertId, message: "Tempat ibadah berhasil ditambahkan." });
+ } catch (error) {
+ res.status(500).json({ success: false, message: error.message });
+ }
+});
+
+router.put("/tempat-ibadah/:id", async (req, res) => {
+ const { nama, jenis, alamat, kelurahan, lat, lng } = req.body;
+ if (!nama || !JENIS_IBADAH.includes(jenis) || !validateCoordinate(lat, lng)) {
+ return res.status(400).json({ success: false, message: "Nama, jenis, lat, dan lng wajib valid." });
+ }
+
+ try {
+ const [result] = await db.execute(
+ "UPDATE tempat_ibadah SET nama=?, jenis=?, alamat=?, kelurahan=?, lat=?, lng=? WHERE id=?",
+ [nama.trim(), jenis, alamat || null, kelurahan || null, Number(lat), Number(lng), 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." });
+ } catch (error) {
+ res.status(500).json({ success: false, message: error.message });
+ }
+});
+
+router.delete("/tempat-ibadah/:id", async (req, res) => {
+ try {
+ const [result] = await db.execute("DELETE FROM tempat_ibadah WHERE id = ?", [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 dihapus." });
+ } catch (error) {
+ res.status(500).json({ success: false, message: error.message });
+ }
+});
+
+router.get("/keluarga", async (req, res) => {
+ try {
+ const [rows] = await db.execute(
+ `SELECT id, nama_kk, jumlah_anggota, kategori, alamat, rt, rw, kelurahan, keterangan, lat, lng, created_at, updated_at
+ FROM keluarga_miskin
+ ORDER BY nama_kk ASC`,
+ );
+ res.json(toFeatureCollection(rows));
+ } catch (error) {
+ res.status(500).json({ success: false, message: error.message });
+ }
+});
+
+router.post("/keluarga", async (req, res) => {
+ const { nama_kk, jumlah_anggota, kategori, alamat, rt, rw, kelurahan, keterangan, lat, lng } = req.body;
+ if (!nama_kk || !KATEGORI_KEMISKINAN.includes(kategori) || !validateCoordinate(lat, lng)) {
+ return res.status(400).json({ success: false, message: "Nama KK, kategori, lat, dan lng wajib valid." });
+ }
+
+ try {
+ const [result] = await db.execute(
+ `INSERT INTO keluarga_miskin
+ (nama_kk, jumlah_anggota, kategori, alamat, rt, rw, kelurahan, keterangan, lat, lng)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [nama_kk.trim(), Number(jumlah_anggota) || 1, kategori, alamat || null, rt || null, rw || null, kelurahan || null, keterangan || null, Number(lat), Number(lng)],
+ );
+ res.status(201).json({ success: true, id: result.insertId, message: "Keluarga berhasil ditambahkan." });
+ } catch (error) {
+ res.status(500).json({ success: false, message: error.message });
+ }
+});
+
+router.put("/keluarga/:id", async (req, res) => {
+ const { nama_kk, jumlah_anggota, kategori, alamat, rt, rw, kelurahan, keterangan, lat, lng } = req.body;
+ if (!nama_kk || !KATEGORI_KEMISKINAN.includes(kategori) || !validateCoordinate(lat, lng)) {
+ return res.status(400).json({ success: false, message: "Nama KK, kategori, lat, dan lng wajib valid." });
+ }
+
+ try {
+ const [result] = await db.execute(
+ `UPDATE keluarga_miskin
+ SET nama_kk=?, jumlah_anggota=?, kategori=?, alamat=?, rt=?, rw=?, kelurahan=?, keterangan=?, lat=?, lng=?
+ WHERE id=?`,
+ [nama_kk.trim(), Number(jumlah_anggota) || 1, kategori, alamat || null, rt || null, rw || null, kelurahan || null, keterangan || null, Number(lat), Number(lng), req.params.id],
+ );
+ if (!result.affectedRows) return res.status(404).json({ success: false, message: "Keluarga tidak ditemukan." });
+ res.json({ success: true, message: "Keluarga berhasil diperbarui." });
+ } catch (error) {
+ res.status(500).json({ success: false, message: error.message });
+ }
+});
+
+router.delete("/keluarga/:id", async (req, res) => {
+ try {
+ const [result] = await db.execute("DELETE FROM keluarga_miskin WHERE id = ?", [req.params.id]);
+ if (!result.affectedRows) return res.status(404).json({ success: false, message: "Keluarga tidak ditemukan." });
+ res.json({ success: true, message: "Keluarga berhasil dihapus." });
+ } catch (error) {
+ res.status(500).json({ success: false, message: error.message });
+ }
+});
+
+router.get("/export", async (req, res) => {
+ const ibadahId = Number(req.query.ibadah_id);
+ const radius = Number(req.query.radius || 500);
+
+ if (!Number.isInteger(ibadahId) || !Number.isFinite(radius) || radius <= 0) {
+ return res.status(400).json({ success: false, message: "ibadah_id dan radius wajib valid." });
+ }
+
+ try {
+ const [[ibadah]] = await db.execute("SELECT id, nama, lat, lng FROM tempat_ibadah WHERE id = ?", [ibadahId]);
+ if (!ibadah) return res.status(404).json({ success: false, message: "Tempat ibadah tidak ditemukan." });
+
+ const [rows] = await db.execute(
+ `SELECT
+ k.id, k.nama_kk, k.jumlah_anggota, k.kategori, k.alamat, k.rt, k.rw, k.kelurahan, k.keterangan, k.lat, k.lng,
+ ST_Distance_Sphere(POINT(k.lng, k.lat), POINT(?, ?)) AS jarak_meter
+ FROM keluarga_miskin k
+ WHERE ST_Distance_Sphere(POINT(k.lng, k.lat), POINT(?, ?)) <= ?
+ ORDER BY jarak_meter ASC`,
+ [ibadah.lng, ibadah.lat, ibadah.lng, ibadah.lat, radius],
+ );
+
+ const header = ["nama_kk", "jumlah_anggota", "kategori", "kelurahan", "alamat", "rt", "rw", "jarak_meter"];
+ const lines = [
+ `Tempat Ibadah,${escapeCsv(ibadah.nama)}`,
+ `Radius Meter,${escapeCsv(radius)}`,
+ header.join(","),
+ ...rows.map((row) => header.map((key) => escapeCsv(key === "jarak_meter" ? Math.round(row.jarak_meter) : row[key])).join(",")),
+ ];
+
+ res.setHeader("Content-Type", "text/csv; charset=utf-8");
+ res.setHeader("Content-Disposition", 'attachment; filename="laporan-keluarga-radius.csv"');
+ res.send(lines.join("\n"));
+ } catch (error) {
+ res.status(500).json({ success: false, message: error.message });
+ }
+});
+
+module.exports = router;
diff --git a/backend/server.js b/backend/server.js
index 1563b9a..45901b5 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -3,6 +3,7 @@ const express = require("express");
const cors = require("cors");
const path = require("path");
const db = require("./db");
+const povertyRoutes = require("./routes/poverty");
require("dotenv").config();
const app = express();
@@ -11,7 +12,7 @@ const PORT = process.env.PORT || 3000;
// ─── Middleware ───────────────────────────────────────────────────────────────
app.use(cors());
app.use(express.json());
-
+app.use("/api/poverty", povertyRoutes);
// Sajikan folder frontend secara statis
app.use(express.static(path.join(__dirname, "../frontend")));
@@ -284,12 +285,10 @@ app.post("/api/spbu", async (req, res) => {
.json({ success: false, message: "Semua field wajib diisi." });
}
if (!Array.isArray(jenis_bbm) || jenis_bbm.length === 0) {
- return res
- .status(400)
- .json({
- success: false,
- message: "Minimal satu jenis BBM harus dipilih.",
- });
+ return res.status(400).json({
+ success: false,
+ message: "Minimal satu jenis BBM harus dipilih.",
+ });
}
try {
const [result] = await db.execute(
@@ -307,21 +306,17 @@ app.post("/api/spbu", async (req, res) => {
manual_override || "auto",
],
);
- res
- .status(201)
- .json({
- success: true,
- message: "SPBU berhasil ditambahkan.",
- id: result.insertId,
- });
+ res.status(201).json({
+ success: true,
+ message: "SPBU berhasil ditambahkan.",
+ id: result.insertId,
+ });
} catch (err) {
if (err.code === "ER_DUP_ENTRY") {
- return res
- .status(409)
- .json({
- success: false,
- message: `Kode SPBU '${kode}' sudah digunakan.`,
- });
+ return res.status(409).json({
+ success: false,
+ message: `Kode SPBU '${kode}' sudah digunakan.`,
+ });
}
res.status(500).json({ success: false, message: err.message });
}
@@ -460,12 +455,10 @@ app.get("/api/jalan/:id", async (req, res) => {
app.post("/api/jalan", async (req, res) => {
const { nama_jalan, jenis_jalan, koordinat } = req.body;
if (!nama_jalan || !jenis_jalan || !koordinat) {
- return res
- .status(400)
- .json({
- success: false,
- message: "nama_jalan, jenis_jalan, dan koordinat wajib diisi.",
- });
+ return res.status(400).json({
+ success: false,
+ message: "nama_jalan, jenis_jalan, dan koordinat wajib diisi.",
+ });
}
const jenisValid = ["nasional", "provinsi", "kota_kabupaten"];
if (!jenisValid.includes(jenis_jalan)) {
@@ -480,13 +473,11 @@ app.post("/api/jalan", async (req, res) => {
"INSERT INTO jalan (nama_jalan, jenis_jalan, koordinat) VALUES (?, ?, ?)",
[nama_jalan.trim(), jenis_jalan, koordinatStr],
);
- res
- .status(201)
- .json({
- success: true,
- message: "Jalan berhasil ditambahkan.",
- id: result.insertId,
- });
+ res.status(201).json({
+ success: true,
+ message: "Jalan berhasil ditambahkan.",
+ id: result.insertId,
+ });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
@@ -496,12 +487,10 @@ app.post("/api/jalan", async (req, res) => {
app.put("/api/jalan/:id", async (req, res) => {
const { nama_jalan, jenis_jalan, koordinat } = req.body;
if (!nama_jalan || !jenis_jalan || !koordinat) {
- return res
- .status(400)
- .json({
- success: false,
- message: "nama_jalan, jenis_jalan, dan koordinat wajib diisi.",
- });
+ return res.status(400).json({
+ success: false,
+ message: "nama_jalan, jenis_jalan, dan koordinat wajib diisi.",
+ });
}
try {
const koordinatStr =
@@ -573,12 +562,10 @@ app.get("/api/bangunan/:id", async (req, res) => {
app.post("/api/bangunan", async (req, res) => {
const { nama_bangunan, jenis_bangunan, nama_jalan, koordinat } = req.body;
if (!nama_bangunan || !jenis_bangunan || !koordinat) {
- return res
- .status(400)
- .json({
- success: false,
- message: "nama_bangunan, jenis_bangunan, dan koordinat wajib diisi.",
- });
+ return res.status(400).json({
+ success: false,
+ message: "nama_bangunan, jenis_bangunan, dan koordinat wajib diisi.",
+ });
}
const jenisValid = ["SHM", "HGB", "HGU", "HP", "HPL"];
if (!jenisValid.includes(jenis_bangunan)) {
@@ -598,13 +585,11 @@ app.post("/api/bangunan", async (req, res) => {
koordinatStr,
],
);
- res
- .status(201)
- .json({
- success: true,
- message: "Bangunan berhasil ditambahkan.",
- id: result.insertId,
- });
+ res.status(201).json({
+ success: true,
+ message: "Bangunan berhasil ditambahkan.",
+ id: result.insertId,
+ });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
@@ -614,12 +599,10 @@ app.post("/api/bangunan", async (req, res) => {
app.put("/api/bangunan/:id", async (req, res) => {
const { nama_bangunan, jenis_bangunan, nama_jalan, koordinat } = req.body;
if (!nama_bangunan || !jenis_bangunan || !koordinat) {
- return res
- .status(400)
- .json({
- success: false,
- message: "nama_bangunan, jenis_bangunan, dan koordinat wajib diisi.",
- });
+ return res.status(400).json({
+ success: false,
+ message: "nama_bangunan, jenis_bangunan, dan koordinat wajib diisi.",
+ });
}
try {
const koordinatStr =
diff --git a/frontend/admin-poverty.html b/frontend/admin-poverty.html
new file mode 100644
index 0000000..082e49d
--- /dev/null
+++ b/frontend/admin-poverty.html
@@ -0,0 +1,885 @@
+
+
+
+
+
+ Admin Poverty Mapping
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Nama |
+ Jenis |
+ Kelurahan |
+ Koordinat |
+ Aksi |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Nama KK |
+ Anggota |
+ Kategori |
+ Kelurahan |
+ Koordinat |
+ Aksi |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Preview menampilkan seluruh tempat ibadah dan keluarga. Export memakai tempat ibadah dan radius yang dipilih.
+
+
+
+
+
+
+
+
+
diff --git a/frontend/poverty-map.html b/frontend/poverty-map.html
new file mode 100644
index 0000000..8930917
--- /dev/null
+++ b/frontend/poverty-map.html
@@ -0,0 +1,683 @@
+
+
+
+
+
+ Poverty Mapping Pontianak
+
+
+
+
+
+
+
+
+
+
+
+
+ Dashboard Ringkas
+
+
+ Terdampak
+ 0
+
+
+ Cakupan
+ 0%
+
+
+ Sangat Miskin
+ 0
+
+
+ Miskin
+ 0
+
+
+ Rentan Miskin
+ 0
+
+
+ Tempat Dipilih
+ -
+
+
+
+
+
+
+
+
+
+
diff --git a/schema.sql b/schema.sql
index 882904c..d6dec01 100644
--- a/schema.sql
+++ b/schema.sql
@@ -50,4 +50,51 @@ CREATE TABLE IF NOT EXISTS bangunan (
aktif TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1=aktif, 0=dihapus (soft delete)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
\ No newline at end of file
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ── Tabel Poverty Mapping ───────────────────────────────────────────────
+-- Catatan: project ini memakai MySQL/MariaDB. Kolom geom dibuat kompatibel
+-- dengan MySQL spatial dan query radius memakai ST_Distance_Sphere.
+
+CREATE TABLE IF NOT EXISTS tempat_ibadah (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ nama VARCHAR(255) NOT NULL,
+ jenis ENUM('masjid','gereja','pura','vihara','kelenteng','lainnya') NOT NULL,
+ alamat TEXT,
+ kelurahan VARCHAR(100),
+ lat DOUBLE NOT NULL,
+ lng DOUBLE NOT NULL,
+ geom POINT GENERATED ALWAYS AS (ST_SRID(POINT(lng, lat), 4326)) STORED,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ SPATIAL INDEX idx_tempat_ibadah_geom (geom)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS keluarga_miskin (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ nama_kk VARCHAR(255) NOT NULL COMMENT 'Nama Kepala Keluarga',
+ jumlah_anggota INT NOT NULL DEFAULT 1,
+ kategori ENUM('sangat_miskin','miskin','rentan_miskin') NOT NULL,
+ alamat TEXT,
+ rt VARCHAR(5),
+ rw VARCHAR(5),
+ kelurahan VARCHAR(100),
+ keterangan TEXT,
+ lat DOUBLE NOT NULL,
+ lng DOUBLE NOT NULL,
+ geom POINT GENERATED ALWAYS AS (ST_SRID(POINT(lng, lat), 4326)) STORED,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ SPATIAL INDEX idx_keluarga_geom (geom)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+INSERT INTO tempat_ibadah (nama, jenis, alamat, kelurahan, lat, lng) VALUES
+('Masjid Jami Sultan Syarif Abdurrahman', 'masjid', 'Jl. Tanjungpura', 'Dalam Bugis', -0.0226, 109.3316),
+('Gereja Bethel Indonesia', 'gereja', 'Jl. Gajahmada', 'Sungai Bangkong', -0.0334, 109.3253),
+('Vihara Bodhisattva', 'vihara', 'Jl. Diponegoro', 'Mariana', -0.0289, 109.3401);
+
+INSERT INTO keluarga_miskin (nama_kk, jumlah_anggota, kategori, kelurahan, lat, lng) VALUES
+('Ahmad Fauzi', 5, 'sangat_miskin', 'Dalam Bugis', -0.0231, 109.3321),
+('Siti Rahmah', 3, 'miskin', 'Dalam Bugis', -0.0219, 109.3308),
+('Budi Santoso', 6, 'rentan_miskin', 'Sungai Bangkong', -0.0341, 109.3261),
+('Maria Goretti', 4, 'miskin', 'Mariana', -0.0295, 109.3412),
+('Lim Ah Kow', 2, 'sangat_miskin', 'Mariana', -0.0501, 109.3600);