menambah fitur-poverty
This commit is contained in:
@@ -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;
|
||||||
+41
-58
@@ -3,6 +3,7 @@ const express = require("express");
|
|||||||
const cors = require("cors");
|
const cors = require("cors");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const db = require("./db");
|
const db = require("./db");
|
||||||
|
const povertyRoutes = require("./routes/poverty");
|
||||||
require("dotenv").config();
|
require("dotenv").config();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -11,7 +12,7 @@ const PORT = process.env.PORT || 3000;
|
|||||||
// ─── Middleware ───────────────────────────────────────────────────────────────
|
// ─── Middleware ───────────────────────────────────────────────────────────────
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
app.use("/api/poverty", povertyRoutes);
|
||||||
// Sajikan folder frontend secara statis
|
// Sajikan folder frontend secara statis
|
||||||
app.use(express.static(path.join(__dirname, "../frontend")));
|
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." });
|
.json({ success: false, message: "Semua field wajib diisi." });
|
||||||
}
|
}
|
||||||
if (!Array.isArray(jenis_bbm) || jenis_bbm.length === 0) {
|
if (!Array.isArray(jenis_bbm) || jenis_bbm.length === 0) {
|
||||||
return res
|
return res.status(400).json({
|
||||||
.status(400)
|
success: false,
|
||||||
.json({
|
message: "Minimal satu jenis BBM harus dipilih.",
|
||||||
success: false,
|
});
|
||||||
message: "Minimal satu jenis BBM harus dipilih.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const [result] = await db.execute(
|
const [result] = await db.execute(
|
||||||
@@ -307,21 +306,17 @@ app.post("/api/spbu", async (req, res) => {
|
|||||||
manual_override || "auto",
|
manual_override || "auto",
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
res
|
res.status(201).json({
|
||||||
.status(201)
|
success: true,
|
||||||
.json({
|
message: "SPBU berhasil ditambahkan.",
|
||||||
success: true,
|
id: result.insertId,
|
||||||
message: "SPBU berhasil ditambahkan.",
|
});
|
||||||
id: result.insertId,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.code === "ER_DUP_ENTRY") {
|
if (err.code === "ER_DUP_ENTRY") {
|
||||||
return res
|
return res.status(409).json({
|
||||||
.status(409)
|
success: false,
|
||||||
.json({
|
message: `Kode SPBU '${kode}' sudah digunakan.`,
|
||||||
success: false,
|
});
|
||||||
message: `Kode SPBU '${kode}' sudah digunakan.`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
res.status(500).json({ success: false, message: err.message });
|
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) => {
|
app.post("/api/jalan", async (req, res) => {
|
||||||
const { nama_jalan, jenis_jalan, koordinat } = req.body;
|
const { nama_jalan, jenis_jalan, koordinat } = req.body;
|
||||||
if (!nama_jalan || !jenis_jalan || !koordinat) {
|
if (!nama_jalan || !jenis_jalan || !koordinat) {
|
||||||
return res
|
return res.status(400).json({
|
||||||
.status(400)
|
success: false,
|
||||||
.json({
|
message: "nama_jalan, jenis_jalan, dan koordinat wajib diisi.",
|
||||||
success: false,
|
});
|
||||||
message: "nama_jalan, jenis_jalan, dan koordinat wajib diisi.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const jenisValid = ["nasional", "provinsi", "kota_kabupaten"];
|
const jenisValid = ["nasional", "provinsi", "kota_kabupaten"];
|
||||||
if (!jenisValid.includes(jenis_jalan)) {
|
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 (?, ?, ?)",
|
"INSERT INTO jalan (nama_jalan, jenis_jalan, koordinat) VALUES (?, ?, ?)",
|
||||||
[nama_jalan.trim(), jenis_jalan, koordinatStr],
|
[nama_jalan.trim(), jenis_jalan, koordinatStr],
|
||||||
);
|
);
|
||||||
res
|
res.status(201).json({
|
||||||
.status(201)
|
success: true,
|
||||||
.json({
|
message: "Jalan berhasil ditambahkan.",
|
||||||
success: true,
|
id: result.insertId,
|
||||||
message: "Jalan berhasil ditambahkan.",
|
});
|
||||||
id: result.insertId,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ success: false, message: err.message });
|
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) => {
|
app.put("/api/jalan/:id", async (req, res) => {
|
||||||
const { nama_jalan, jenis_jalan, koordinat } = req.body;
|
const { nama_jalan, jenis_jalan, koordinat } = req.body;
|
||||||
if (!nama_jalan || !jenis_jalan || !koordinat) {
|
if (!nama_jalan || !jenis_jalan || !koordinat) {
|
||||||
return res
|
return res.status(400).json({
|
||||||
.status(400)
|
success: false,
|
||||||
.json({
|
message: "nama_jalan, jenis_jalan, dan koordinat wajib diisi.",
|
||||||
success: false,
|
});
|
||||||
message: "nama_jalan, jenis_jalan, dan koordinat wajib diisi.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const koordinatStr =
|
const koordinatStr =
|
||||||
@@ -573,12 +562,10 @@ app.get("/api/bangunan/:id", async (req, res) => {
|
|||||||
app.post("/api/bangunan", async (req, res) => {
|
app.post("/api/bangunan", async (req, res) => {
|
||||||
const { nama_bangunan, jenis_bangunan, nama_jalan, koordinat } = req.body;
|
const { nama_bangunan, jenis_bangunan, nama_jalan, koordinat } = req.body;
|
||||||
if (!nama_bangunan || !jenis_bangunan || !koordinat) {
|
if (!nama_bangunan || !jenis_bangunan || !koordinat) {
|
||||||
return res
|
return res.status(400).json({
|
||||||
.status(400)
|
success: false,
|
||||||
.json({
|
message: "nama_bangunan, jenis_bangunan, dan koordinat wajib diisi.",
|
||||||
success: false,
|
});
|
||||||
message: "nama_bangunan, jenis_bangunan, dan koordinat wajib diisi.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const jenisValid = ["SHM", "HGB", "HGU", "HP", "HPL"];
|
const jenisValid = ["SHM", "HGB", "HGU", "HP", "HPL"];
|
||||||
if (!jenisValid.includes(jenis_bangunan)) {
|
if (!jenisValid.includes(jenis_bangunan)) {
|
||||||
@@ -598,13 +585,11 @@ app.post("/api/bangunan", async (req, res) => {
|
|||||||
koordinatStr,
|
koordinatStr,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
res
|
res.status(201).json({
|
||||||
.status(201)
|
success: true,
|
||||||
.json({
|
message: "Bangunan berhasil ditambahkan.",
|
||||||
success: true,
|
id: result.insertId,
|
||||||
message: "Bangunan berhasil ditambahkan.",
|
});
|
||||||
id: result.insertId,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ success: false, message: err.message });
|
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) => {
|
app.put("/api/bangunan/:id", async (req, res) => {
|
||||||
const { nama_bangunan, jenis_bangunan, nama_jalan, koordinat } = req.body;
|
const { nama_bangunan, jenis_bangunan, nama_jalan, koordinat } = req.body;
|
||||||
if (!nama_bangunan || !jenis_bangunan || !koordinat) {
|
if (!nama_bangunan || !jenis_bangunan || !koordinat) {
|
||||||
return res
|
return res.status(400).json({
|
||||||
.status(400)
|
success: false,
|
||||||
.json({
|
message: "nama_bangunan, jenis_bangunan, dan koordinat wajib diisi.",
|
||||||
success: false,
|
});
|
||||||
message: "nama_bangunan, jenis_bangunan, dan koordinat wajib diisi.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const koordinatStr =
|
const koordinatStr =
|
||||||
|
|||||||
@@ -0,0 +1,885 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Admin Poverty Mapping</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=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--accent: #1b4332;
|
||||||
|
--accent-h: #2d6a4f;
|
||||||
|
--gold: #f4a261;
|
||||||
|
--bg: #f1f5f9;
|
||||||
|
--card: #ffffff;
|
||||||
|
--border: #e2e8f0;
|
||||||
|
--text: #0f172a;
|
||||||
|
--muted: #64748b;
|
||||||
|
--danger: #dc2626;
|
||||||
|
--blue: #2563eb;
|
||||||
|
--radius: 16px;
|
||||||
|
--shadow: 0 4px 24px rgba(15, 23, 42, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Inter", system-ui, sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topnav {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 62px;
|
||||||
|
padding: 0 24px;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--text);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topnav h1 {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topnav a {
|
||||||
|
color: #fcd34d;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
max-width: 1240px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 16px 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
background: #e2e8f0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 1240px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 20px 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 380px minmax(0, 1fr);
|
||||||
|
gap: 18px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--card);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
color: var(--text);
|
||||||
|
background: #fff;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 76px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
|
border-color: var(--accent-h);
|
||||||
|
box-shadow: 0 0 0 3px rgba(27, 67, 50, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
min-height: 40px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0 14px;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: var(--accent-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.secondary {
|
||||||
|
color: var(--text);
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.danger {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.gold {
|
||||||
|
color: #422006;
|
||||||
|
background: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.sangat_miskin {
|
||||||
|
background: #7f1d1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.miskin {
|
||||||
|
background: #ea580c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.rentan_miskin {
|
||||||
|
color: #422006;
|
||||||
|
background: #eab308;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-actions button {
|
||||||
|
min-height: 32px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0 9px;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--blue);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-actions button:last-child {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
#previewMap,
|
||||||
|
#ibadahMap,
|
||||||
|
#keluargaMap {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 330px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 140px auto auto;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.grid,
|
||||||
|
.filters,
|
||||||
|
.export-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="topnav">
|
||||||
|
<h1>Admin Poverty Mapping</h1>
|
||||||
|
<a href="poverty-map.html">Peta Publik</a>
|
||||||
|
<a href="admin.html">Admin SPBU</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="tab-bar">
|
||||||
|
<button class="tab-btn active" data-tab="ibadah">Tempat Ibadah</button>
|
||||||
|
<button class="tab-btn" data-tab="keluarga">Keluarga Miskin</button>
|
||||||
|
<button class="tab-btn" data-tab="preview">Peta Preview</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="page">
|
||||||
|
<section class="tab-content active" id="tab-ibadah">
|
||||||
|
<div class="grid">
|
||||||
|
<article class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>Form Tempat Ibadah</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form class="form-grid" id="ibadahForm">
|
||||||
|
<input type="hidden" id="ibadahId" />
|
||||||
|
<label>Nama
|
||||||
|
<input id="ibadahNama" required placeholder="Masjid/Gereja/Vihara..." />
|
||||||
|
</label>
|
||||||
|
<label>Jenis
|
||||||
|
<select id="ibadahJenis" required>
|
||||||
|
<option value="masjid">Masjid</option>
|
||||||
|
<option value="gereja">Gereja</option>
|
||||||
|
<option value="pura">Pura</option>
|
||||||
|
<option value="vihara">Vihara</option>
|
||||||
|
<option value="kelenteng">Kelenteng</option>
|
||||||
|
<option value="lainnya">Lainnya</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Alamat
|
||||||
|
<textarea id="ibadahAlamat"></textarea>
|
||||||
|
</label>
|
||||||
|
<label>Kelurahan
|
||||||
|
<input id="ibadahKelurahan" />
|
||||||
|
</label>
|
||||||
|
<div class="row-2">
|
||||||
|
<label>Latitude
|
||||||
|
<input id="ibadahLat" type="number" step="any" required />
|
||||||
|
</label>
|
||||||
|
<label>Longitude
|
||||||
|
<input id="ibadahLng" type="number" step="any" required />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="ibadahMap"></div>
|
||||||
|
<p class="notice">Klik peta untuk mengisi koordinat tempat ibadah otomatis.</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn" type="submit">Simpan</button>
|
||||||
|
<button class="btn secondary" type="button" id="resetIbadah">Reset</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>Daftar Tempat Ibadah</h2>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nama</th>
|
||||||
|
<th>Jenis</th>
|
||||||
|
<th>Kelurahan</th>
|
||||||
|
<th>Koordinat</th>
|
||||||
|
<th>Aksi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="ibadahTable"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="tab-content" id="tab-keluarga">
|
||||||
|
<div class="grid">
|
||||||
|
<article class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>Form Keluarga Miskin</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form class="form-grid" id="keluargaForm">
|
||||||
|
<input type="hidden" id="keluargaId" />
|
||||||
|
<label>Nama Kepala Keluarga
|
||||||
|
<input id="keluargaNama" required />
|
||||||
|
</label>
|
||||||
|
<div class="row-2">
|
||||||
|
<label>Jumlah Anggota
|
||||||
|
<input id="keluargaAnggota" type="number" min="1" value="1" required />
|
||||||
|
</label>
|
||||||
|
<label>Kategori
|
||||||
|
<select id="keluargaKategori" required>
|
||||||
|
<option value="sangat_miskin">Sangat Miskin</option>
|
||||||
|
<option value="miskin">Miskin</option>
|
||||||
|
<option value="rentan_miskin">Rentan Miskin</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>Alamat
|
||||||
|
<textarea id="keluargaAlamat"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="row-2">
|
||||||
|
<label>RT
|
||||||
|
<input id="keluargaRt" maxlength="5" />
|
||||||
|
</label>
|
||||||
|
<label>RW
|
||||||
|
<input id="keluargaRw" maxlength="5" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>Kelurahan
|
||||||
|
<input id="keluargaKelurahan" />
|
||||||
|
</label>
|
||||||
|
<label>Keterangan
|
||||||
|
<textarea id="keluargaKeterangan"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="row-2">
|
||||||
|
<label>Latitude
|
||||||
|
<input id="keluargaLat" type="number" step="any" required />
|
||||||
|
</label>
|
||||||
|
<label>Longitude
|
||||||
|
<input id="keluargaLng" type="number" step="any" required />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="keluargaMap"></div>
|
||||||
|
<p class="notice">Klik peta untuk mengisi koordinat keluarga otomatis.</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn" type="submit">Simpan</button>
|
||||||
|
<button class="btn secondary" type="button" id="resetKeluarga">Reset</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>Daftar Keluarga</h2>
|
||||||
|
</div>
|
||||||
|
<div class="filters">
|
||||||
|
<select id="filterKategori">
|
||||||
|
<option value="">Semua Kategori</option>
|
||||||
|
<option value="sangat_miskin">Sangat Miskin</option>
|
||||||
|
<option value="miskin">Miskin</option>
|
||||||
|
<option value="rentan_miskin">Rentan Miskin</option>
|
||||||
|
</select>
|
||||||
|
<input id="filterKelurahan" placeholder="Filter kelurahan" />
|
||||||
|
<input id="searchNama" placeholder="Cari nama KK" />
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nama KK</th>
|
||||||
|
<th>Anggota</th>
|
||||||
|
<th>Kategori</th>
|
||||||
|
<th>Kelurahan</th>
|
||||||
|
<th>Koordinat</th>
|
||||||
|
<th>Aksi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="keluargaTable"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="tab-content" id="tab-preview">
|
||||||
|
<article class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>Peta Preview & Export</h2>
|
||||||
|
</div>
|
||||||
|
<div class="export-grid">
|
||||||
|
<select id="exportIbadah"></select>
|
||||||
|
<input id="exportRadius" type="number" min="100" max="2000" value="500" />
|
||||||
|
<button class="btn gold" id="exportCsv" type="button">Export CSV</button>
|
||||||
|
<button class="btn" id="printReport" type="button">Cetak</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="previewMap"></div>
|
||||||
|
<p class="notice">Preview menampilkan seluruh tempat ibadah dan keluarga. Export memakai tempat ibadah dan radius yang dipilih.</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
|
||||||
|
<script>
|
||||||
|
const API_BASE = "/api/poverty";
|
||||||
|
const kategoriLabel = {
|
||||||
|
sangat_miskin: "Sangat Miskin",
|
||||||
|
miskin: "Miskin",
|
||||||
|
rentan_miskin: "Rentan Miskin"
|
||||||
|
};
|
||||||
|
const worshipIcon = { masjid: "🕌", gereja: "⛪", pura: "🛕", vihara: "☸️", kelenteng: "🏮", lainnya: "⭐" };
|
||||||
|
let apiAvailable = true;
|
||||||
|
let tempatIbadah = [
|
||||||
|
{ id: 1, nama: "Masjid Jami Sultan Syarif Abdurrahman", jenis: "masjid", alamat: "Jl. Tanjungpura", kelurahan: "Dalam Bugis", lat: -0.0226, lng: 109.3316 },
|
||||||
|
{ id: 2, nama: "Gereja Bethel Indonesia", jenis: "gereja", alamat: "Jl. Gajahmada", kelurahan: "Sungai Bangkong", lat: -0.0334, lng: 109.3253 },
|
||||||
|
{ id: 3, nama: "Vihara Bodhisattva", jenis: "vihara", alamat: "Jl. Diponegoro", kelurahan: "Mariana", lat: -0.0289, lng: 109.3401 }
|
||||||
|
];
|
||||||
|
let keluarga = [
|
||||||
|
{ id: 1, nama_kk: "Ahmad Fauzi", jumlah_anggota: 5, kategori: "sangat_miskin", alamat: "Dalam Bugis", rt: "01", rw: "02", kelurahan: "Dalam Bugis", keterangan: "Prioritas bantuan pangan.", lat: -0.0231, lng: 109.3321 },
|
||||||
|
{ id: 2, nama_kk: "Siti Rahmah", jumlah_anggota: 3, kategori: "miskin", alamat: "Dalam Bugis", rt: "02", rw: "02", kelurahan: "Dalam Bugis", keterangan: "Membutuhkan dukungan pendidikan.", lat: -0.0219, lng: 109.3308 },
|
||||||
|
{ id: 3, nama_kk: "Budi Santoso", jumlah_anggota: 6, kategori: "rentan_miskin", alamat: "Sungai Bangkong", rt: "03", rw: "01", kelurahan: "Sungai Bangkong", keterangan: "Pendapatan tidak tetap.", lat: -0.0341, lng: 109.3261 },
|
||||||
|
{ id: 4, nama_kk: "Maria Goretti", jumlah_anggota: 4, kategori: "miskin", alamat: "Mariana", rt: "04", rw: "01", kelurahan: "Mariana", keterangan: "Butuh verifikasi lanjutan.", lat: -0.0295, lng: 109.3412 },
|
||||||
|
{ id: 5, nama_kk: "Lim Ah Kow", jumlah_anggota: 2, kategori: "sangat_miskin", alamat: "Mariana", rt: "05", rw: "03", kelurahan: "Mariana", keterangan: "Lansia tinggal sendiri.", lat: -0.0501, lng: 109.36 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const elements = {
|
||||||
|
ibadahTable: document.getElementById("ibadahTable"),
|
||||||
|
keluargaTable: document.getElementById("keluargaTable"),
|
||||||
|
exportIbadah: document.getElementById("exportIbadah"),
|
||||||
|
exportRadius: document.getElementById("exportRadius"),
|
||||||
|
filterKategori: document.getElementById("filterKategori"),
|
||||||
|
filterKelurahan: document.getElementById("filterKelurahan"),
|
||||||
|
searchNama: document.getElementById("searchNama")
|
||||||
|
};
|
||||||
|
|
||||||
|
const ibadahMap = createMap("ibadahMap");
|
||||||
|
const keluargaMap = createMap("keluargaMap");
|
||||||
|
const previewMap = createMap("previewMap");
|
||||||
|
let ibadahPickMarker;
|
||||||
|
let keluargaPickMarker;
|
||||||
|
const previewLayer = L.layerGroup().addTo(previewMap);
|
||||||
|
|
||||||
|
function createMap(id) {
|
||||||
|
const map = L.map(id).setView([-0.0263, 109.3425], 13);
|
||||||
|
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||||
|
maxZoom: 19,
|
||||||
|
attribution: '© OpenStreetMap'
|
||||||
|
}).addTo(map);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function geoJsonToRows(payload) {
|
||||||
|
if (payload?.type === "FeatureCollection") {
|
||||||
|
return payload.features.map((feature) => ({
|
||||||
|
...feature.properties,
|
||||||
|
lng: Number(feature.geometry.coordinates[0]),
|
||||||
|
lat: Number(feature.geometry.coordinates[1])
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload?.data)) return payload.data;
|
||||||
|
if (Array.isArray(payload)) return payload;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiRequest(path, options = {}) {
|
||||||
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
|
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
const contentType = response.headers.get("content-type") || "";
|
||||||
|
return contentType.includes("application/json") ? response.json() : response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
const [ibadahPayload, keluargaPayload] = await Promise.all([
|
||||||
|
apiRequest("/tempat-ibadah"),
|
||||||
|
apiRequest("/keluarga")
|
||||||
|
]);
|
||||||
|
tempatIbadah = geoJsonToRows(ibadahPayload);
|
||||||
|
keluarga = geoJsonToRows(keluargaPayload);
|
||||||
|
apiAvailable = true;
|
||||||
|
} catch {
|
||||||
|
apiAvailable = false;
|
||||||
|
}
|
||||||
|
renderAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function localCreate(collection, payload) {
|
||||||
|
const nextId = Math.max(0, ...collection.map((item) => Number(item.id))) + 1;
|
||||||
|
collection.push({ ...payload, id: nextId });
|
||||||
|
}
|
||||||
|
|
||||||
|
function localUpdate(collection, id, payload) {
|
||||||
|
const index = collection.findIndex((item) => String(item.id) === String(id));
|
||||||
|
if (index >= 0) collection[index] = { ...collection[index], ...payload, id: collection[index].id };
|
||||||
|
}
|
||||||
|
|
||||||
|
function localDelete(collection, id) {
|
||||||
|
const index = collection.findIndex((item) => String(item.id) === String(id));
|
||||||
|
if (index >= 0) collection.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIbadahForm() {
|
||||||
|
return {
|
||||||
|
nama: document.getElementById("ibadahNama").value.trim(),
|
||||||
|
jenis: document.getElementById("ibadahJenis").value,
|
||||||
|
alamat: document.getElementById("ibadahAlamat").value.trim(),
|
||||||
|
kelurahan: document.getElementById("ibadahKelurahan").value.trim(),
|
||||||
|
lat: Number(document.getElementById("ibadahLat").value),
|
||||||
|
lng: Number(document.getElementById("ibadahLng").value)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getKeluargaForm() {
|
||||||
|
return {
|
||||||
|
nama_kk: document.getElementById("keluargaNama").value.trim(),
|
||||||
|
jumlah_anggota: Number(document.getElementById("keluargaAnggota").value),
|
||||||
|
kategori: document.getElementById("keluargaKategori").value,
|
||||||
|
alamat: document.getElementById("keluargaAlamat").value.trim(),
|
||||||
|
rt: document.getElementById("keluargaRt").value.trim(),
|
||||||
|
rw: document.getElementById("keluargaRw").value.trim(),
|
||||||
|
kelurahan: document.getElementById("keluargaKelurahan").value.trim(),
|
||||||
|
keterangan: document.getElementById("keluargaKeterangan").value.trim(),
|
||||||
|
lat: Number(document.getElementById("keluargaLat").value),
|
||||||
|
lng: Number(document.getElementById("keluargaLng").value)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setIbadahForm(item) {
|
||||||
|
document.getElementById("ibadahId").value = item?.id || "";
|
||||||
|
document.getElementById("ibadahNama").value = item?.nama || "";
|
||||||
|
document.getElementById("ibadahJenis").value = item?.jenis || "masjid";
|
||||||
|
document.getElementById("ibadahAlamat").value = item?.alamat || "";
|
||||||
|
document.getElementById("ibadahKelurahan").value = item?.kelurahan || "";
|
||||||
|
document.getElementById("ibadahLat").value = item?.lat ?? "";
|
||||||
|
document.getElementById("ibadahLng").value = item?.lng ?? "";
|
||||||
|
if (item?.lat && item?.lng) setPickMarker(ibadahMap, "ibadah", item.lat, item.lng);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setKeluargaForm(item) {
|
||||||
|
document.getElementById("keluargaId").value = item?.id || "";
|
||||||
|
document.getElementById("keluargaNama").value = item?.nama_kk || "";
|
||||||
|
document.getElementById("keluargaAnggota").value = item?.jumlah_anggota || 1;
|
||||||
|
document.getElementById("keluargaKategori").value = item?.kategori || "miskin";
|
||||||
|
document.getElementById("keluargaAlamat").value = item?.alamat || "";
|
||||||
|
document.getElementById("keluargaRt").value = item?.rt || "";
|
||||||
|
document.getElementById("keluargaRw").value = item?.rw || "";
|
||||||
|
document.getElementById("keluargaKelurahan").value = item?.kelurahan || "";
|
||||||
|
document.getElementById("keluargaKeterangan").value = item?.keterangan || "";
|
||||||
|
document.getElementById("keluargaLat").value = item?.lat ?? "";
|
||||||
|
document.getElementById("keluargaLng").value = item?.lng ?? "";
|
||||||
|
if (item?.lat && item?.lng) setPickMarker(keluargaMap, "keluarga", item.lat, item.lng);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPickMarker(map, type, lat, lng) {
|
||||||
|
const markerRef = type === "ibadah" ? "ibadahPickMarker" : "keluargaPickMarker";
|
||||||
|
if (markerRef === "ibadahPickMarker" && ibadahPickMarker) ibadahPickMarker.remove();
|
||||||
|
if (markerRef === "keluargaPickMarker" && keluargaPickMarker) keluargaPickMarker.remove();
|
||||||
|
const marker = L.marker([lat, lng]).addTo(map);
|
||||||
|
map.setView([lat, lng], 16);
|
||||||
|
if (markerRef === "ibadahPickMarker") ibadahPickMarker = marker;
|
||||||
|
if (markerRef === "keluargaPickMarker") keluargaPickMarker = marker;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIbadahTable() {
|
||||||
|
elements.ibadahTable.innerHTML = tempatIbadah.map((item) => `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${item.nama}</strong><br><small>${item.alamat || "-"}</small></td>
|
||||||
|
<td>${worshipIcon[item.jenis] || "⭐"} ${item.jenis}</td>
|
||||||
|
<td>${item.kelurahan || "-"}</td>
|
||||||
|
<td>${Number(item.lat).toFixed(5)}, ${Number(item.lng).toFixed(5)}</td>
|
||||||
|
<td><div class="mini-actions"><button type="button" onclick="editIbadah(${item.id})">Edit</button><button type="button" onclick="deleteIbadah(${item.id})">Hapus</button></div></td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredKeluarga() {
|
||||||
|
const kategori = elements.filterKategori.value;
|
||||||
|
const kelurahan = elements.filterKelurahan.value.toLowerCase().trim();
|
||||||
|
const search = elements.searchNama.value.toLowerCase().trim();
|
||||||
|
return keluarga.filter((item) => {
|
||||||
|
if (kategori && item.kategori !== kategori) return false;
|
||||||
|
if (kelurahan && !(item.kelurahan || "").toLowerCase().includes(kelurahan)) return false;
|
||||||
|
if (search && !(item.nama_kk || "").toLowerCase().includes(search)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderKeluargaTable() {
|
||||||
|
elements.keluargaTable.innerHTML = filteredKeluarga().map((item) => `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${item.nama_kk}</strong><br><small>${item.alamat || "-"}</small></td>
|
||||||
|
<td>${item.jumlah_anggota}</td>
|
||||||
|
<td><span class="badge ${item.kategori}">${kategoriLabel[item.kategori]}</span></td>
|
||||||
|
<td>${item.kelurahan || "-"}</td>
|
||||||
|
<td>${Number(item.lat).toFixed(5)}, ${Number(item.lng).toFixed(5)}</td>
|
||||||
|
<td><div class="mini-actions"><button type="button" onclick="editKeluarga(${item.id})">Edit</button><button type="button" onclick="deleteKeluarga(${item.id})">Hapus</button></div></td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeWorshipMarker(item) {
|
||||||
|
return L.divIcon({
|
||||||
|
className: "",
|
||||||
|
html: `<div style="display:grid;width:34px;height:34px;place-items:center;border:2px solid #fff;border-radius:50%;background:#f4a261;box-shadow:0 8px 18px #0002">${worshipIcon[item.jenis] || "⭐"}</div>`,
|
||||||
|
iconSize: [34, 34],
|
||||||
|
iconAnchor: [17, 17]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPreview() {
|
||||||
|
previewLayer.clearLayers();
|
||||||
|
tempatIbadah.forEach((item) => L.marker([item.lat, item.lng], { icon: makeWorshipMarker(item) }).bindPopup(`<strong>${item.nama}</strong>`).addTo(previewLayer));
|
||||||
|
keluarga.forEach((item) => L.circleMarker([item.lat, item.lng], { radius: 7, color: "#2563eb", fillColor: "#2563eb", fillOpacity: 0.8 }).bindPopup(`<strong>${item.nama_kk}</strong>`).addTo(previewLayer));
|
||||||
|
elements.exportIbadah.innerHTML = tempatIbadah.map((item) => `<option value="${item.id}">${item.nama}</option>`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAll() {
|
||||||
|
renderIbadahTable();
|
||||||
|
renderKeluargaTable();
|
||||||
|
renderPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function familiesInExportRadius() {
|
||||||
|
const ibadah = tempatIbadah.find((item) => String(item.id) === String(elements.exportIbadah.value));
|
||||||
|
const radius = Number(elements.exportRadius.value || 500);
|
||||||
|
if (!ibadah) return [];
|
||||||
|
return keluarga
|
||||||
|
.map((item) => ({ ...item, distance: L.latLng(item.lat, item.lng).distanceTo(L.latLng(ibadah.lat, ibadah.lng)) }))
|
||||||
|
.filter((item) => item.distance <= radius)
|
||||||
|
.sort((a, b) => a.distance - b.distance);
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadCsv(rows) {
|
||||||
|
const header = ["nama_kk", "jumlah_anggota", "kategori", "kelurahan", "alamat", "rt", "rw", "jarak_meter"];
|
||||||
|
const body = rows.map((item) => header.map((key) => `"${String(key === "jarak_meter" ? Math.round(item.distance || 0) : item[key] || "").replaceAll('"', '""')}"`).join(","));
|
||||||
|
const blob = new Blob([[header.join(","), ...body].join("\n")], { type: "text/csv;charset=utf-8" });
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = URL.createObjectURL(blob);
|
||||||
|
link.download = "laporan-keluarga-radius.csv";
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(link.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printRows(rows) {
|
||||||
|
const ibadah = tempatIbadah.find((item) => String(item.id) === String(elements.exportIbadah.value));
|
||||||
|
const html = `
|
||||||
|
<title>Laporan Keluarga Dalam Radius</title>
|
||||||
|
<style>body{font-family:Inter,Arial,sans-serif;padding:24px}table{width:100%;border-collapse:collapse}td,th{border:1px solid #ddd;padding:8px;text-align:left}th{background:#f3f4f6}</style>
|
||||||
|
<h1>Laporan Keluarga Dalam Radius</h1>
|
||||||
|
<p>Tempat ibadah: <strong>${ibadah?.nama || "-"}</strong> • Radius: ${elements.exportRadius.value} meter</p>
|
||||||
|
<table><thead><tr><th>Nama KK</th><th>Anggota</th><th>Kategori</th><th>Kelurahan</th><th>Jarak</th></tr></thead><tbody>
|
||||||
|
${rows.map((item) => `<tr><td>${item.nama_kk}</td><td>${item.jumlah_anggota}</td><td>${kategoriLabel[item.kategori]}</td><td>${item.kelurahan || "-"}</td><td>${Math.round(item.distance)} m</td></tr>`).join("")}
|
||||||
|
</tbody></table>
|
||||||
|
`;
|
||||||
|
const win = window.open("", "_blank");
|
||||||
|
win.document.write(html);
|
||||||
|
win.document.close();
|
||||||
|
win.print();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.editIbadah = (id) => setIbadahForm(tempatIbadah.find((item) => String(item.id) === String(id)));
|
||||||
|
window.editKeluarga = (id) => setKeluargaForm(keluarga.find((item) => String(item.id) === String(id)));
|
||||||
|
window.deleteIbadah = async (id) => {
|
||||||
|
if (!confirm("Hapus tempat ibadah ini?")) return;
|
||||||
|
try {
|
||||||
|
if (apiAvailable) await apiRequest(`/tempat-ibadah/${id}`, { method: "DELETE" });
|
||||||
|
localDelete(tempatIbadah, id);
|
||||||
|
renderAll();
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Gagal menghapus: ${error.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.deleteKeluarga = async (id) => {
|
||||||
|
if (!confirm("Hapus keluarga ini?")) return;
|
||||||
|
try {
|
||||||
|
if (apiAvailable) await apiRequest(`/keluarga/${id}`, { method: "DELETE" });
|
||||||
|
localDelete(keluarga, id);
|
||||||
|
renderAll();
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Gagal menghapus: ${error.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("ibadahForm").addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const id = document.getElementById("ibadahId").value;
|
||||||
|
const payload = getIbadahForm();
|
||||||
|
try {
|
||||||
|
if (apiAvailable) await apiRequest(id ? `/tempat-ibadah/${id}` : "/tempat-ibadah", { method: id ? "PUT" : "POST", body: JSON.stringify(payload) });
|
||||||
|
id ? localUpdate(tempatIbadah, id, payload) : localCreate(tempatIbadah, payload);
|
||||||
|
setIbadahForm(null);
|
||||||
|
renderAll();
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Gagal menyimpan: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("keluargaForm").addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const id = document.getElementById("keluargaId").value;
|
||||||
|
const payload = getKeluargaForm();
|
||||||
|
try {
|
||||||
|
if (apiAvailable) await apiRequest(id ? `/keluarga/${id}` : "/keluarga", { method: id ? "PUT" : "POST", body: JSON.stringify(payload) });
|
||||||
|
id ? localUpdate(keluarga, id, payload) : localCreate(keluarga, payload);
|
||||||
|
setKeluargaForm(null);
|
||||||
|
renderAll();
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Gagal menyimpan: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("resetIbadah").addEventListener("click", () => setIbadahForm(null));
|
||||||
|
document.getElementById("resetKeluarga").addEventListener("click", () => setKeluargaForm(null));
|
||||||
|
[elements.filterKategori, elements.filterKelurahan, elements.searchNama].forEach((item) => item.addEventListener("input", renderKeluargaTable));
|
||||||
|
|
||||||
|
document.getElementById("exportCsv").addEventListener("click", async () => {
|
||||||
|
const ibadahId = elements.exportIbadah.value;
|
||||||
|
const radius = elements.exportRadius.value || 500;
|
||||||
|
if (apiAvailable) {
|
||||||
|
window.location.href = `${API_BASE}/export?ibadah_id=${encodeURIComponent(ibadahId)}&radius=${encodeURIComponent(radius)}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
downloadCsv(familiesInExportRadius());
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("printReport").addEventListener("click", () => printRows(familiesInExportRadius()));
|
||||||
|
|
||||||
|
document.querySelectorAll(".tab-btn").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
document.querySelectorAll(".tab-btn").forEach((item) => item.classList.remove("active"));
|
||||||
|
document.querySelectorAll(".tab-content").forEach((item) => item.classList.remove("active"));
|
||||||
|
button.classList.add("active");
|
||||||
|
document.getElementById(`tab-${button.dataset.tab}`).classList.add("active");
|
||||||
|
setTimeout(() => {
|
||||||
|
ibadahMap.invalidateSize();
|
||||||
|
keluargaMap.invalidateSize();
|
||||||
|
previewMap.invalidateSize();
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ibadahMap.on("click", (event) => {
|
||||||
|
document.getElementById("ibadahLat").value = event.latlng.lat.toFixed(7);
|
||||||
|
document.getElementById("ibadahLng").value = event.latlng.lng.toFixed(7);
|
||||||
|
setPickMarker(ibadahMap, "ibadah", event.latlng.lat, event.latlng.lng);
|
||||||
|
});
|
||||||
|
|
||||||
|
keluargaMap.on("click", (event) => {
|
||||||
|
document.getElementById("keluargaLat").value = event.latlng.lat.toFixed(7);
|
||||||
|
document.getElementById("keluargaLng").value = event.latlng.lng.toFixed(7);
|
||||||
|
setPickMarker(keluargaMap, "keluarga", event.latlng.lat, event.latlng.lng);
|
||||||
|
});
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,683 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Poverty Mapping Pontianak</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=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--green: #1b4332;
|
||||||
|
--green-2: #2d6a4f;
|
||||||
|
--gold: #f4a261;
|
||||||
|
--gold-soft: rgba(244, 162, 97, 0.2);
|
||||||
|
--blue: #2563eb;
|
||||||
|
--red: #dc2626;
|
||||||
|
--dark-red: #7f1d1d;
|
||||||
|
--orange: #ea580c;
|
||||||
|
--yellow: #eab308;
|
||||||
|
--text: #0f172a;
|
||||||
|
--muted: #64748b;
|
||||||
|
--panel: rgba(255, 255, 255, 0.9);
|
||||||
|
--border: rgba(15, 23, 42, 0.12);
|
||||||
|
--shadow: 0 18px 45px rgba(15, 23, 42, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: "Inter", system-ui, sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background: #eef2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
#map {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 900;
|
||||||
|
top: 14px;
|
||||||
|
left: 14px;
|
||||||
|
right: 14px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(280px, 420px);
|
||||||
|
gap: 14px;
|
||||||
|
align-items: stretch;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand,
|
||||||
|
.radius-panel,
|
||||||
|
.stats-panel,
|
||||||
|
.family-panel {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 74px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, rgba(27, 67, 50, 0.96), rgba(45, 106, 79, 0.92));
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-icon {
|
||||||
|
display: grid;
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(244, 162, 97, 0.22);
|
||||||
|
font-size: 1.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: rgba(255, 255, 255, 0.78);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radius-panel {
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radius-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radius-row strong {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"] {
|
||||||
|
width: 100%;
|
||||||
|
accent-color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-panel {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 850;
|
||||||
|
top: 112px;
|
||||||
|
left: 14px;
|
||||||
|
width: 340px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-panel h2,
|
||||||
|
.family-panel h2 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--green);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 15px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card span {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 5px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.family-panel {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 920;
|
||||||
|
top: 112px;
|
||||||
|
right: 14px;
|
||||||
|
width: min(410px, calc(100vw - 28px));
|
||||||
|
max-height: calc(100vh - 132px);
|
||||||
|
padding: 16px;
|
||||||
|
overflow: auto;
|
||||||
|
transform: translateX(calc(100% + 24px));
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.family-panel.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 11px;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--green);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.family-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.family-item {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.family-item strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.family-item small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.sangat_miskin {
|
||||||
|
background: var(--dark-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.miskin {
|
||||||
|
background: var(--orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.rentan_miskin {
|
||||||
|
color: #422006;
|
||||||
|
background: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-btn {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 36px;
|
||||||
|
margin-top: 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 11px;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--green);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worship-marker {
|
||||||
|
display: grid;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
place-items: center;
|
||||||
|
border: 3px solid #fff;
|
||||||
|
border-radius: 50% 50% 50% 12px;
|
||||||
|
background: var(--gold);
|
||||||
|
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.24);
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.worship-marker span {
|
||||||
|
transform: rotate(45deg);
|
||||||
|
font-size: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.family-marker {
|
||||||
|
display: grid;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
place-items: center;
|
||||||
|
border: 3px solid #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.2);
|
||||||
|
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.family-marker.inside {
|
||||||
|
background: var(--red);
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.family-marker.outside {
|
||||||
|
background: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-popup-content-wrapper {
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-popup-content h3 {
|
||||||
|
margin: 0 0 5px;
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-popup-content p {
|
||||||
|
margin: 0 0 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.topbar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
min-height: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-panel {
|
||||||
|
top: 190px;
|
||||||
|
width: calc(100vw - 28px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.family-panel {
|
||||||
|
top: auto;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
left: 10px;
|
||||||
|
width: auto;
|
||||||
|
max-height: 55vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="map"></div>
|
||||||
|
|
||||||
|
<header class="topbar">
|
||||||
|
<section class="brand">
|
||||||
|
<div class="brand-icon">🤝</div>
|
||||||
|
<div>
|
||||||
|
<h1>Poverty Mapping Pontianak</h1>
|
||||||
|
<p>Pemetaan keluarga miskin di sekitar tempat ibadah untuk dukungan komunitas.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="radius-panel">
|
||||||
|
<div class="radius-row">
|
||||||
|
<span>Radius analisis</span>
|
||||||
|
<strong id="radiusLabel">500 meter</strong>
|
||||||
|
</div>
|
||||||
|
<input id="radiusSlider" type="range" min="100" max="2000" step="50" value="500" />
|
||||||
|
</section>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="stats-panel">
|
||||||
|
<h2>Dashboard Ringkas</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<span>Terdampak</span>
|
||||||
|
<strong id="totalAffected">0</strong>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span>Cakupan</span>
|
||||||
|
<strong id="coveragePercent">0%</strong>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span>Sangat Miskin</span>
|
||||||
|
<strong id="veryPoorCount">0</strong>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span>Miskin</span>
|
||||||
|
<strong id="poorCount">0</strong>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span>Rentan Miskin</span>
|
||||||
|
<strong id="vulnerableCount">0</strong>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span>Tempat Dipilih</span>
|
||||||
|
<strong id="selectedName">-</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="family-panel" id="familyPanel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<h2>Daftar Keluarga</h2>
|
||||||
|
<p class="muted" id="familyPanelSubtitle">Pilih tempat ibadah untuk melihat daftar.</p>
|
||||||
|
</div>
|
||||||
|
<button class="close-btn" id="closePanel" type="button">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="family-list" id="familyList"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
|
||||||
|
<script>
|
||||||
|
const API_BASE = "/api/poverty";
|
||||||
|
const fallbackTempatIbadah = [
|
||||||
|
{ id: 1, nama: "Masjid Jami Sultan Syarif Abdurrahman", jenis: "masjid", alamat: "Jl. Tanjungpura", kelurahan: "Dalam Bugis", lat: -0.0226, lng: 109.3316 },
|
||||||
|
{ id: 2, nama: "Gereja Bethel Indonesia", jenis: "gereja", alamat: "Jl. Gajahmada", kelurahan: "Sungai Bangkong", lat: -0.0334, lng: 109.3253 },
|
||||||
|
{ id: 3, nama: "Vihara Bodhisattva", jenis: "vihara", alamat: "Jl. Diponegoro", kelurahan: "Mariana", lat: -0.0289, lng: 109.3401 }
|
||||||
|
];
|
||||||
|
const fallbackKeluarga = [
|
||||||
|
{ id: 1, nama_kk: "Ahmad Fauzi", jumlah_anggota: 5, kategori: "sangat_miskin", kelurahan: "Dalam Bugis", keterangan: "Prioritas bantuan pangan.", lat: -0.0231, lng: 109.3321 },
|
||||||
|
{ id: 2, nama_kk: "Siti Rahmah", jumlah_anggota: 3, kategori: "miskin", kelurahan: "Dalam Bugis", keterangan: "Membutuhkan dukungan pendidikan.", lat: -0.0219, lng: 109.3308 },
|
||||||
|
{ id: 3, nama_kk: "Budi Santoso", jumlah_anggota: 6, kategori: "rentan_miskin", kelurahan: "Sungai Bangkong", keterangan: "Pendapatan tidak tetap.", lat: -0.0341, lng: 109.3261 },
|
||||||
|
{ id: 4, nama_kk: "Maria Goretti", jumlah_anggota: 4, kategori: "miskin", kelurahan: "Mariana", keterangan: "Butuh verifikasi lanjutan.", lat: -0.0295, lng: 109.3412 },
|
||||||
|
{ id: 5, nama_kk: "Lim Ah Kow", jumlah_anggota: 2, kategori: "sangat_miskin", kelurahan: "Mariana", keterangan: "Lansia tinggal sendiri.", lat: -0.0501, lng: 109.36 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const kategoriLabel = {
|
||||||
|
sangat_miskin: "Sangat Miskin",
|
||||||
|
miskin: "Miskin",
|
||||||
|
rentan_miskin: "Rentan Miskin"
|
||||||
|
};
|
||||||
|
|
||||||
|
const worshipIcon = {
|
||||||
|
masjid: "🕌",
|
||||||
|
gereja: "⛪",
|
||||||
|
pura: "🛕",
|
||||||
|
vihara: "☸️",
|
||||||
|
kelenteng: "🏮",
|
||||||
|
lainnya: "⭐"
|
||||||
|
};
|
||||||
|
|
||||||
|
const map = L.map("map").setView([-0.0263, 109.3425], 13);
|
||||||
|
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||||
|
maxZoom: 19,
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
const worshipLayer = L.layerGroup().addTo(map);
|
||||||
|
const familyLayer = L.layerGroup().addTo(map);
|
||||||
|
let radiusCircle = null;
|
||||||
|
let selectedIbadah = null;
|
||||||
|
let currentRadius = 500;
|
||||||
|
let tempatIbadah = [];
|
||||||
|
let keluarga = [];
|
||||||
|
const familyMarkers = new Map();
|
||||||
|
const worshipMarkers = new Map();
|
||||||
|
|
||||||
|
const elements = {
|
||||||
|
radiusSlider: document.getElementById("radiusSlider"),
|
||||||
|
radiusLabel: document.getElementById("radiusLabel"),
|
||||||
|
totalAffected: document.getElementById("totalAffected"),
|
||||||
|
coveragePercent: document.getElementById("coveragePercent"),
|
||||||
|
veryPoorCount: document.getElementById("veryPoorCount"),
|
||||||
|
poorCount: document.getElementById("poorCount"),
|
||||||
|
vulnerableCount: document.getElementById("vulnerableCount"),
|
||||||
|
selectedName: document.getElementById("selectedName"),
|
||||||
|
familyPanel: document.getElementById("familyPanel"),
|
||||||
|
familyPanelSubtitle: document.getElementById("familyPanelSubtitle"),
|
||||||
|
familyList: document.getElementById("familyList"),
|
||||||
|
closePanel: document.getElementById("closePanel")
|
||||||
|
};
|
||||||
|
|
||||||
|
function geoJsonToRows(payload) {
|
||||||
|
if (payload?.type === "FeatureCollection") {
|
||||||
|
return payload.features.map((feature) => ({
|
||||||
|
...feature.properties,
|
||||||
|
lng: Number(feature.geometry.coordinates[0]),
|
||||||
|
lat: Number(feature.geometry.coordinates[1])
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload?.data)) return payload.data;
|
||||||
|
if (Array.isArray(payload)) return payload;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRows(url, fallback) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
const rows = geoJsonToRows(await response.json());
|
||||||
|
return rows.length ? rows : fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeWorshipIcon(jenis) {
|
||||||
|
return L.divIcon({
|
||||||
|
className: "",
|
||||||
|
html: `<div class="worship-marker"><span>${worshipIcon[jenis] || worshipIcon.lainnya}</span></div>`,
|
||||||
|
iconSize: [42, 42],
|
||||||
|
iconAnchor: [21, 40],
|
||||||
|
popupAnchor: [0, -36]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFamilyIcon(isInside) {
|
||||||
|
return L.divIcon({
|
||||||
|
className: "",
|
||||||
|
html: `<div class="family-marker ${isInside ? "inside" : "outside"}">👥</div>`,
|
||||||
|
iconSize: [28, 28],
|
||||||
|
iconAnchor: [14, 14],
|
||||||
|
popupAnchor: [0, -12]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMeters(value) {
|
||||||
|
if (value == null || Number.isNaN(value)) return "-";
|
||||||
|
return `${Math.round(value).toLocaleString("id-ID")} m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function distanceBetween(a, b) {
|
||||||
|
return L.latLng(a.lat, a.lng).distanceTo(L.latLng(b.lat, b.lng));
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestWorshipDistance(family) {
|
||||||
|
if (!tempatIbadah.length) return null;
|
||||||
|
return Math.min(...tempatIbadah.map((ibadah) => distanceBetween(family, ibadah)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function familiesInRadius() {
|
||||||
|
if (!selectedIbadah) return [];
|
||||||
|
return keluarga
|
||||||
|
.map((item) => ({ ...item, distance: distanceBetween(item, selectedIbadah) }))
|
||||||
|
.filter((item) => item.distance <= currentRadius)
|
||||||
|
.sort((a, b) => a.distance - b.distance);
|
||||||
|
}
|
||||||
|
|
||||||
|
function familyPopup(item) {
|
||||||
|
const nearest = nearestWorshipDistance(item);
|
||||||
|
return `
|
||||||
|
<h3>${item.nama_kk}</h3>
|
||||||
|
<p><strong>Anggota:</strong> ${item.jumlah_anggota} orang</p>
|
||||||
|
<p><strong>Kategori:</strong> <span class="badge ${item.kategori}">${kategoriLabel[item.kategori]}</span></p>
|
||||||
|
<p><strong>Catatan:</strong> ${item.keterangan || "-"}</p>
|
||||||
|
<p><strong>Jarak tempat ibadah terdekat:</strong> ${formatMeters(nearest)}</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function worshipPopup(item) {
|
||||||
|
const count = selectedIbadah?.id === item.id ? familiesInRadius().length : keluarga.filter((family) => distanceBetween(family, item) <= currentRadius).length;
|
||||||
|
return `
|
||||||
|
<h3>${item.nama}</h3>
|
||||||
|
<p><strong>Jenis:</strong> ${item.jenis}</p>
|
||||||
|
<p><strong>Alamat:</strong> ${item.alamat || "-"}</p>
|
||||||
|
<p><strong>Keluarga dalam radius:</strong> ${count}</p>
|
||||||
|
<button class="popup-btn" onclick="window.openFamilyPanel()">Lihat Daftar Keluarga</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectIbadah(item) {
|
||||||
|
selectedIbadah = item;
|
||||||
|
updateCoverage();
|
||||||
|
elements.familyPanel.classList.remove("open");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkers() {
|
||||||
|
worshipLayer.clearLayers();
|
||||||
|
familyLayer.clearLayers();
|
||||||
|
worshipMarkers.clear();
|
||||||
|
familyMarkers.clear();
|
||||||
|
|
||||||
|
tempatIbadah.forEach((item) => {
|
||||||
|
const marker = L.marker([item.lat, item.lng], { icon: makeWorshipIcon(item.jenis), title: item.nama })
|
||||||
|
.bindPopup(() => worshipPopup(item))
|
||||||
|
.on("click", () => selectIbadah(item));
|
||||||
|
marker.addTo(worshipLayer);
|
||||||
|
worshipMarkers.set(item.id, marker);
|
||||||
|
});
|
||||||
|
|
||||||
|
keluarga.forEach((item) => {
|
||||||
|
const marker = L.marker([item.lat, item.lng], { icon: makeFamilyIcon(false), title: item.nama_kk })
|
||||||
|
.bindPopup(() => familyPopup(item));
|
||||||
|
marker.addTo(familyLayer);
|
||||||
|
familyMarkers.set(item.id, marker);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFamilyPanel() {
|
||||||
|
const rows = familiesInRadius();
|
||||||
|
elements.familyPanelSubtitle.textContent = selectedIbadah
|
||||||
|
? `${selectedIbadah.nama} • radius ${currentRadius} meter`
|
||||||
|
: "Pilih tempat ibadah untuk melihat daftar.";
|
||||||
|
elements.familyList.innerHTML = rows.length
|
||||||
|
? rows.map((item) => `
|
||||||
|
<article class="family-item">
|
||||||
|
<strong>${item.nama_kk}</strong>
|
||||||
|
<span class="badge ${item.kategori}">${kategoriLabel[item.kategori]}</span>
|
||||||
|
<p><small>Jarak ${formatMeters(item.distance)} • ${item.jumlah_anggota} anggota • ${item.kelurahan || "-"}</small></p>
|
||||||
|
</article>
|
||||||
|
`).join("")
|
||||||
|
: `<p class="muted">Tidak ada keluarga dalam radius saat ini.</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStats(rows) {
|
||||||
|
const total = rows.length;
|
||||||
|
const counts = rows.reduce((acc, item) => {
|
||||||
|
acc[item.kategori] = (acc[item.kategori] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
elements.totalAffected.textContent = total;
|
||||||
|
elements.coveragePercent.textContent = keluarga.length ? `${Math.round((total / keluarga.length) * 100)}%` : "0%";
|
||||||
|
elements.veryPoorCount.textContent = counts.sangat_miskin || 0;
|
||||||
|
elements.poorCount.textContent = counts.miskin || 0;
|
||||||
|
elements.vulnerableCount.textContent = counts.rentan_miskin || 0;
|
||||||
|
elements.selectedName.textContent = selectedIbadah ? selectedIbadah.nama.split(" ").slice(0, 2).join(" ") : "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCoverage() {
|
||||||
|
elements.radiusLabel.textContent = `${currentRadius} meter`;
|
||||||
|
|
||||||
|
if (radiusCircle) {
|
||||||
|
radiusCircle.remove();
|
||||||
|
radiusCircle = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = familiesInRadius();
|
||||||
|
const insideIds = new Set(rows.map((item) => item.id));
|
||||||
|
|
||||||
|
if (selectedIbadah) {
|
||||||
|
radiusCircle = L.circle([selectedIbadah.lat, selectedIbadah.lng], {
|
||||||
|
radius: currentRadius,
|
||||||
|
color: "#f4a261",
|
||||||
|
weight: 2,
|
||||||
|
fillColor: "#f4a261",
|
||||||
|
fillOpacity: 0.18
|
||||||
|
}).addTo(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
keluarga.forEach((item) => {
|
||||||
|
const marker = familyMarkers.get(item.id);
|
||||||
|
if (!marker) return;
|
||||||
|
marker.setIcon(makeFamilyIcon(insideIds.has(item.id)));
|
||||||
|
if (marker.isPopupOpen()) marker.setPopupContent(familyPopup(item));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selectedIbadah) {
|
||||||
|
const marker = worshipMarkers.get(selectedIbadah.id);
|
||||||
|
if (marker?.isPopupOpen()) marker.setPopupContent(worshipPopup(selectedIbadah));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStats(rows);
|
||||||
|
renderFamilyPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.openFamilyPanel = function () {
|
||||||
|
renderFamilyPanel();
|
||||||
|
elements.familyPanel.classList.add("open");
|
||||||
|
};
|
||||||
|
|
||||||
|
elements.radiusSlider.addEventListener("input", (event) => {
|
||||||
|
currentRadius = Number(event.target.value);
|
||||||
|
updateCoverage();
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.closePanel.addEventListener("click", () => {
|
||||||
|
elements.familyPanel.classList.remove("open");
|
||||||
|
});
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
[tempatIbadah, keluarga] = await Promise.all([
|
||||||
|
fetchRows(`${API_BASE}/tempat-ibadah`, fallbackTempatIbadah),
|
||||||
|
fetchRows(`${API_BASE}/keluarga`, fallbackKeluarga)
|
||||||
|
]);
|
||||||
|
renderMarkers();
|
||||||
|
selectedIbadah = tempatIbadah[0] || null;
|
||||||
|
updateCoverage();
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+48
-1
@@ -50,4 +50,51 @@ CREATE TABLE IF NOT EXISTS bangunan (
|
|||||||
aktif TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1=aktif, 0=dihapus (soft delete)',
|
aktif TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1=aktif, 0=dihapus (soft delete)',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) 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);
|
||||||
|
|||||||
Reference in New Issue
Block a user