diff --git a/backend/routes/poverty.js b/backend/routes/poverty.js
index c30b69e..5f2a214 100644
--- a/backend/routes/poverty.js
+++ b/backend/routes/poverty.js
@@ -3,158 +3,493 @@ 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) {
+const DEFAULT_KRITERIA = [
+ { kode: "pendapatan", nama_kriteria: "Pendapatan", bobot: 0.35, atribut: "cost" },
+ { kode: "jumlah_tanggungan", nama_kriteria: "Jumlah Tanggungan", bobot: 0.2, atribut: "benefit" },
+ { kode: "kondisi_rumah", nama_kriteria: "Kondisi Rumah", bobot: 0.15, atribut: "benefit" },
+ { kode: "pekerjaan", nama_kriteria: "Pekerjaan", bobot: 0.15, atribut: "benefit" },
+ { kode: "pendidikan", nama_kriteria: "Pendidikan Kepala Keluarga", bobot: 0.1, atribut: "benefit" },
+ { kode: "kondisi_kesehatan", nama_kriteria: "Kondisi Kesehatan", bobot: 0.05, atribut: "benefit" },
+];
+
+const HOUSE_SCORE = {
+ sangat_buruk: 5,
+ buruk: 4,
+ semi_permanen: 3,
+ sederhana: 2,
+ layak: 1,
+};
+
+const JOB_SCORE = {
+ pengangguran: 5,
+ buruh_harian: 4,
+ petani: 3,
+ nelayan: 3,
+ pedagang_kecil: 3,
+ karyawan: 2,
+ wiraswasta: 2,
+ pns: 1,
+ pensiunan: 2,
+ lainnya: 3,
+};
+
+const EDUCATION_SCORE = {
+ tidak_sekolah: 5,
+ sd: 4,
+ smp: 3,
+ sma: 2,
+ diploma: 1.5,
+ sarjana: 1,
+};
+
+const HEALTH_SCORE = {
+ disabilitas: 5,
+ kronis: 5,
+ lansia: 4,
+ sakit_sering: 4,
+ sakit_ringan: 3,
+ sehat: 1,
+};
+
+function toNumber(value, fallback = 0) {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : fallback;
+}
+
+function sawCategory(score) {
+ if (score >= 0.8) return "Sangat Membutuhkan";
+ if (score >= 0.6) return "Membutuhkan";
+ if (score >= 0.4) return "Rentan";
+ return "Relatif Mampu";
+}
+
+function markerColor(kategori) {
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),
- },
- };
- }),
+ "Sangat Membutuhkan": "#dc2626",
+ Membutuhkan: "#f97316",
+ Rentan: "#facc15",
+ "Relatif Mampu": "#22c55e",
+ }[kategori] || "#64748b";
+}
+
+function rawCriterionValue(keluarga, kode) {
+ if (kode === "pendapatan") return Math.max(toNumber(keluarga.pendapatan_bulanan, 1), 1);
+ if (kode === "jumlah_tanggungan") return Math.max(toNumber(keluarga.jumlah_tanggungan), 0);
+ if (kode === "kondisi_rumah") return HOUSE_SCORE[keluarga.kondisi_rumah] || 1;
+ if (kode === "pekerjaan") return JOB_SCORE[keluarga.pekerjaan] || JOB_SCORE.lainnya;
+ if (kode === "pendidikan") return EDUCATION_SCORE[keluarga.pendidikan] || 2;
+ if (kode === "kondisi_kesehatan") return HEALTH_SCORE[keluarga.kondisi_kesehatan] || 1;
+ return 0;
+}
+
+function maskedName(item) {
+ return `Keluarga ${String(item.id_keluarga).padStart(3, "0")}`;
+}
+
+function privacyOffset(id) {
+ const seed = Number(id) || 1;
+ return {
+ lat: (((seed * 37) % 13) - 6) * 0.000035,
+ lng: (((seed * 53) % 17) - 8) * 0.000035,
};
}
-function validateCoordinate(lat, lng) {
- return Number.isFinite(Number(lat)) && Number.isFinite(Number(lng));
+function applyPrivacy(item, role = "admin", privacyMode = false) {
+ const hidden = role === "pengurus" || privacyMode;
+ const offset = hidden ? privacyOffset(item.id_keluarga) : { lat: 0, lng: 0 };
+ return {
+ ...item,
+ nama_kepala_keluarga: hidden ? maskedName(item) : item.nama_kepala_keluarga,
+ latitude: toNumber(item.latitude) + offset.lat,
+ longitude: toNumber(item.longitude) + offset.lng,
+ pendapatan_bulanan: hidden ? null : item.pendapatan_bulanan,
+ pendapatan_label: hidden ? "Disembunyikan" : item.pendapatan_bulanan,
+ nomor_kartu_keluarga: hidden ? null : item.nomor_kartu_keluarga,
+ is_private: hidden,
+ };
}
-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);
- }
+function buildFeature(item) {
+ return {
+ type: "Feature",
+ geometry: {
+ type: "Point",
+ coordinates: [toNumber(item.longitude), toNumber(item.latitude)],
+ },
+ properties: {
+ ...item,
+ color: markerColor(item.kategori),
+ },
+ };
}
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)
+ await db.execute(`
+ CREATE TABLE IF NOT EXISTS users (
+ id_user INT AUTO_INCREMENT PRIMARY KEY,
+ nama VARCHAR(120) NOT NULL,
+ username VARCHAR(80) NOT NULL UNIQUE,
+ password VARCHAR(255) NOT NULL,
+ role ENUM('admin','pengurus') NOT NULL DEFAULT 'pengurus',
+ aktif TINYINT(1) NOT NULL DEFAULT 1,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) 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;
- `;
+ await migrateLegacyKeluarga();
- const keluargaWithGeom = `
- CREATE TABLE IF NOT EXISTS keluarga_miskin (
- id INT AUTO_INCREMENT PRIMARY KEY,
- nama_kk VARCHAR(255) NOT NULL,
+ await db.execute(`
+ CREATE TABLE IF NOT EXISTS keluarga (
+ id_keluarga INT AUTO_INCREMENT PRIMARY KEY,
+ nomor_kartu_keluarga VARCHAR(40) NOT NULL UNIQUE,
+ nama_kepala_keluarga VARCHAR(160) NOT NULL,
+ alamat TEXT NOT NULL,
+ wilayah VARCHAR(120) NOT NULL DEFAULT 'Belum Dipetakan',
+ latitude DOUBLE NOT NULL,
+ longitude DOUBLE 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,
+ jumlah_tanggungan INT NOT NULL DEFAULT 0,
+ pendapatan_bulanan DECIMAL(14,2) NOT NULL DEFAULT 0,
+ pekerjaan VARCHAR(60) NOT NULL DEFAULT 'lainnya',
+ pendidikan VARCHAR(60) NOT NULL DEFAULT 'sma',
+ kondisi_rumah VARCHAR(60) NOT NULL DEFAULT 'sederhana',
+ kondisi_kesehatan VARCHAR(60) NOT NULL DEFAULT 'sehat',
+ status_verifikasi ENUM('pending','terverifikasi','ditolak') NOT NULL DEFAULT 'pending',
+ tanggal_update DATE NOT NULL,
+ aktif TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
- SPATIAL INDEX idx_keluarga_geom (geom)
+ INDEX idx_keluarga_lokasi (latitude, longitude),
+ INDEX idx_keluarga_wilayah (wilayah),
+ INDEX idx_keluarga_verifikasi (status_verifikasi)
) 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,
+ await db.execute(`
+ CREATE TABLE IF NOT EXISTS kriteria (
+ id_kriteria INT AUTO_INCREMENT PRIMARY KEY,
+ kode VARCHAR(60) NOT NULL UNIQUE,
+ nama_kriteria VARCHAR(120) NOT NULL,
+ bobot DECIMAL(6,4) NOT NULL,
+ atribut ENUM('benefit','cost') NOT NULL,
+ aktif TINYINT(1) NOT NULL DEFAULT 1,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+ `);
+
+ await db.execute(`
+ CREATE TABLE IF NOT EXISTS penilaian (
+ id_penilaian INT AUTO_INCREMENT PRIMARY KEY,
+ id_keluarga INT NOT NULL,
+ id_kriteria INT NOT NULL,
+ nilai DECIMAL(14,4) NOT NULL DEFAULT 0,
+ nilai_normalisasi DECIMAL(14,6) NOT NULL DEFAULT 0,
+ nilai_terbobot DECIMAL(14,6) NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
- INDEX idx_keluarga_lat_lng (lat, lng)
+ UNIQUE KEY uniq_penilaian (id_keluarga, id_kriteria),
+ CONSTRAINT fk_penilaian_keluarga FOREIGN KEY (id_keluarga) REFERENCES keluarga(id_keluarga) ON DELETE CASCADE,
+ CONSTRAINT fk_penilaian_kriteria FOREIGN KEY (id_kriteria) REFERENCES kriteria(id_kriteria) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
- `;
+ `);
- await createTableWithFallback(tempatIbadahWithGeom, tempatIbadahFallback);
- await createTableWithFallback(keluargaWithGeom, keluargaFallback);
- await seedPovertyDataIfEmpty();
+ await db.execute(`
+ CREATE TABLE IF NOT EXISTS hasil_saw (
+ id_hasil INT AUTO_INCREMENT PRIMARY KEY,
+ id_keluarga INT NOT NULL UNIQUE,
+ skor_saw DECIMAL(12,6) NOT NULL DEFAULT 0,
+ kategori VARCHAR(40) NOT NULL,
+ ranking INT NOT NULL DEFAULT 0,
+ calculated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT fk_hasil_keluarga FOREIGN KEY (id_keluarga) REFERENCES keluarga(id_keluarga) ON DELETE CASCADE
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+ `);
+
+ await db.execute(`
+ CREATE TABLE IF NOT EXISTS bantuan (
+ id_bantuan INT AUTO_INCREMENT PRIMARY KEY,
+ jenis_bantuan VARCHAR(120) NOT NULL,
+ tanggal_bantuan DATE NOT NULL,
+ nilai_bantuan DECIMAL(14,2) NOT NULL DEFAULT 0,
+ keterangan TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+ `);
+
+ await db.execute(`
+ CREATE TABLE IF NOT EXISTS riwayat_bantuan (
+ id_riwayat INT AUTO_INCREMENT PRIMARY KEY,
+ id_keluarga INT NOT NULL,
+ id_bantuan INT NOT NULL,
+ tanggal_diterima DATE NOT NULL,
+ jumlah_bantuan DECIMAL(14,2) NOT NULL DEFAULT 0,
+ keterangan TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT fk_riwayat_keluarga FOREIGN KEY (id_keluarga) REFERENCES keluarga(id_keluarga) ON DELETE CASCADE,
+ CONSTRAINT fk_riwayat_bantuan FOREIGN KEY (id_bantuan) REFERENCES bantuan(id_bantuan) ON DELETE CASCADE
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+ `);
+
+ await seedDefaults();
+ await calculateSaw();
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,
- );
- }
+async function tableColumns(tableName) {
+ const [rows] = await db.execute(`SHOW COLUMNS FROM ${tableName}`);
+ return rows.map((row) => row.Field);
+}
+
+async function hasTable(tableName) {
+ const [rows] = await db.execute(
+ "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?",
+ [tableName],
+ );
+ return rows.length > 0;
+}
+
+async function addColumnIfMissing(tableName, columns, columnName, definition) {
+ if (columns.includes(columnName)) return columns;
+ await db.execute(`ALTER TABLE ${tableName} ADD COLUMN ${definition}`);
+ return [...columns, columnName];
+}
+
+async function migrateLegacyKeluarga() {
+ if (!(await hasTable("keluarga"))) return;
+ let columns = await tableColumns("keluarga");
+
+ if (!columns.includes("id_keluarga") && columns.includes("id")) {
+ await db.execute("ALTER TABLE keluarga CHANGE id id_keluarga INT NOT NULL AUTO_INCREMENT");
+ columns = columns.map((column) => (column === "id" ? "id_keluarga" : column));
}
- 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) {
+ if (!columns.includes("id_keluarga")) return;
+
+ columns = await addColumnIfMissing("keluarga", columns, "nomor_kartu_keluarga", "nomor_kartu_keluarga VARCHAR(40) NULL");
+ columns = await addColumnIfMissing("keluarga", columns, "nama_kepala_keluarga", "nama_kepala_keluarga VARCHAR(160) NULL");
+ columns = await addColumnIfMissing("keluarga", columns, "wilayah", "wilayah VARCHAR(120) NOT NULL DEFAULT 'Belum Dipetakan'");
+ columns = await addColumnIfMissing("keluarga", columns, "latitude", "latitude DOUBLE NULL");
+ columns = await addColumnIfMissing("keluarga", columns, "longitude", "longitude DOUBLE NULL");
+ columns = await addColumnIfMissing("keluarga", columns, "jumlah_tanggungan", "jumlah_tanggungan INT NOT NULL DEFAULT 0");
+ columns = await addColumnIfMissing("keluarga", columns, "pendapatan_bulanan", "pendapatan_bulanan DECIMAL(14,2) NOT NULL DEFAULT 0");
+ columns = await addColumnIfMissing("keluarga", columns, "pekerjaan", "pekerjaan VARCHAR(60) NOT NULL DEFAULT 'lainnya'");
+ columns = await addColumnIfMissing("keluarga", columns, "pendidikan", "pendidikan VARCHAR(60) NOT NULL DEFAULT 'sma'");
+ columns = await addColumnIfMissing("keluarga", columns, "kondisi_kesehatan", "kondisi_kesehatan VARCHAR(60) NOT NULL DEFAULT 'sehat'");
+ columns = await addColumnIfMissing("keluarga", columns, "status_verifikasi", "status_verifikasi ENUM('pending','terverifikasi','ditolak') NOT NULL DEFAULT 'terverifikasi'");
+ columns = await addColumnIfMissing("keluarga", columns, "tanggal_update", "tanggal_update DATE NULL");
+ columns = await addColumnIfMissing("keluarga", columns, "aktif", "aktif TINYINT(1) NOT NULL DEFAULT 1");
+
+ if (columns.includes("kondisi_rumah")) {
+ await db.execute("ALTER TABLE keluarga MODIFY kondisi_rumah VARCHAR(60) NOT NULL DEFAULT 'sederhana'");
+ } else {
+ await db.execute("ALTER TABLE keluarga ADD COLUMN kondisi_rumah VARCHAR(60) NOT NULL DEFAULT 'sederhana'");
+ columns.push("kondisi_rumah");
+ }
+
+ const refreshed = await tableColumns("keluarga");
+ if (refreshed.includes("nama_kk")) {
+ await db.execute("UPDATE keluarga SET nama_kepala_keluarga = COALESCE(nama_kepala_keluarga, nama_kk)");
+ }
+ if (refreshed.includes("lat")) {
+ await db.execute("UPDATE keluarga SET latitude = COALESCE(latitude, lat)");
+ }
+ if (refreshed.includes("lng")) {
+ await db.execute("UPDATE keluarga SET longitude = COALESCE(longitude, lng)");
+ }
+ if (refreshed.includes("penghasilan")) {
+ await db.execute("UPDATE keluarga SET pendapatan_bulanan = IF(pendapatan_bulanan = 0, penghasilan, pendapatan_bulanan)");
+ }
+ await db.execute("UPDATE keluarga SET nomor_kartu_keluarga = COALESCE(nomor_kartu_keluarga, CONCAT('LEGACY-', LPAD(id_keluarga, 6, '0')))");
+ await db.execute("UPDATE keluarga SET tanggal_update = COALESCE(tanggal_update, CURDATE())");
+ await db.execute("UPDATE keluarga SET jumlah_tanggungan = IF(jumlah_tanggungan = 0, GREATEST(jumlah_anggota - 1, 0), jumlah_tanggungan)");
+ await db.execute(`
+ UPDATE keluarga
+ SET kondisi_rumah = CASE kondisi_rumah
+ WHEN 'Tidak Permanen' THEN 'sangat_buruk'
+ WHEN 'Semi Permanen' THEN 'semi_permanen'
+ WHEN 'Permanen' THEN 'layak'
+ ELSE kondisi_rumah
+ END
+ `);
+
+ try {
+ await db.execute("ALTER TABLE keluarga ADD UNIQUE KEY uniq_keluarga_nomor_kk (nomor_kartu_keluarga)");
+ } catch (error) {
+ if (!["ER_DUP_KEYNAME", "ER_DUP_ENTRY"].includes(error.code)) throw error;
+ }
+}
+
+async function seedDefaults() {
+ await db.execute(
+ "INSERT IGNORE INTO users (nama, username, password, role) VALUES (?, ?, ?, ?), (?, ?, ?, ?)",
+ ["Administrator", "admin", "admin123", "admin", "Pengurus Gereja", "pengurus", "pengurus123", "pengurus"],
+ );
+
+ for (const item of DEFAULT_KRITERIA) {
+ await db.execute(
+ `INSERT INTO kriteria (kode, nama_kriteria, bobot, atribut)
+ VALUES (?, ?, ?, ?)
+ ON DUPLICATE KEY UPDATE nama_kriteria=VALUES(nama_kriteria), bobot=VALUES(bobot), atribut=VALUES(atribut), aktif=1`,
+ [item.kode, item.nama_kriteria, item.bobot, item.atribut],
+ );
+ }
+
+ const [[{ count }]] = await db.execute("SELECT COUNT(*) AS count FROM keluarga");
+ if (Number(count) > 0) return;
+
+ const today = new Date().toISOString().slice(0, 10);
+ const families = [
+ ["6171010101010001", "Maria Goretti", "Jl. Gajah Mada Gang Damai", "Paroki Katedral", -0.0339, 109.3367, 5, 4, 1250000, "buruh_harian", "smp", "buruk", "sakit_sering", "terverifikasi", today],
+ ["6171010101010002", "Yohanes Rudi", "Jl. Tanjung Raya II", "Paroki Santo Yosef", -0.0278, 109.3695, 6, 5, 900000, "pengangguran", "sd", "sangat_buruk", "kronis", "terverifikasi", today],
+ ["6171010101010003", "Agnes Lestari", "Jl. Husein Hamzah", "Paroki Santo Hieronymus", -0.0252, 109.3062, 4, 3, 1750000, "pedagang_kecil", "sma", "semi_permanen", "sehat", "terverifikasi", today],
+ ["6171010101010004", "Petrus Antoni", "Jl. Imam Bonjol", "Paroki Keluarga Kudus", -0.0266, 109.3444, 3, 2, 2800000, "karyawan", "sma", "sederhana", "sehat", "terverifikasi", today],
+ ["6171010101010005", "Theresia Ani", "Jl. Khatulistiwa", "Paroki Santo Agustinus", 0.0023, 109.3237, 7, 6, 1150000, "buruh_harian", "sd", "buruk", "disabilitas", "terverifikasi", today],
+ ["6171010101010006", "Fransiskus Deni", "Jl. Dr. Wahidin", "Paroki Santo Paulus", -0.0364, 109.3151, 2, 1, 4200000, "wiraswasta", "diploma", "layak", "sehat", "terverifikasi", today],
+ ];
+
+ for (const row of families) {
+ await db.execute(
+ `INSERT INTO keluarga
+ (nomor_kartu_keluarga, nama_kepala_keluarga, alamat, wilayah, latitude, longitude,
+ jumlah_anggota, jumlah_tanggungan, pendapatan_bulanan, pekerjaan, pendidikan,
+ kondisi_rumah, kondisi_kesehatan, status_verifikasi, tanggal_update)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ row,
+ );
+ }
+
+ const [bantuanResult] = await db.execute(
+ "INSERT INTO bantuan (jenis_bantuan, tanggal_bantuan, nilai_bantuan, keterangan) VALUES (?, ?, ?, ?), (?, ?, ?, ?)",
+ ["Paket Sembako", today, 350000, "Bantuan pangan bulanan", "Bantuan Pendidikan", today, 500000, "Dukungan biaya sekolah"],
+ );
+
+ await db.execute(
+ "INSERT INTO riwayat_bantuan (id_keluarga, id_bantuan, tanggal_diterima, jumlah_bantuan, keterangan) VALUES (?, ?, ?, ?, ?), (?, ?, ?, ?, ?)",
+ [1, bantuanResult.insertId, today, 350000, "Tahap awal", 2, bantuanResult.insertId + 1, today, 500000, "Untuk anak sekolah"],
+ );
+}
+
+async function calculateSaw() {
+ const [families] = await db.execute("SELECT * FROM keluarga WHERE aktif = 1");
+ const [criteria] = await db.execute("SELECT * FROM kriteria WHERE aktif = 1 ORDER BY id_kriteria ASC");
+ if (!families.length || !criteria.length) return [];
+
+ const matrix = families.map((family) => {
+ const values = {};
+ criteria.forEach((criterion) => {
+ values[criterion.kode] = rawCriterionValue(family, criterion.kode);
+ });
+ return { family, values };
+ });
+
+ const minMax = {};
+ criteria.forEach((criterion) => {
+ const values = matrix.map((row) => toNumber(row.values[criterion.kode], 0)).filter((value) => value > 0);
+ minMax[criterion.kode] = {
+ min: values.length ? Math.min(...values) : 1,
+ max: values.length ? Math.max(...values) : 1,
+ };
+ });
+
+ const results = [];
+ for (const row of matrix) {
+ let score = 0;
+ for (const criterion of criteria) {
+ const rawValue = Math.max(toNumber(row.values[criterion.kode], 0), 0);
+ const normalizer = minMax[criterion.kode];
+ const normalized =
+ criterion.atribut === "cost"
+ ? normalizer.min / Math.max(rawValue, 1)
+ : rawValue / Math.max(normalizer.max, 1);
+ const weighted = normalized * toNumber(criterion.bobot, 0);
+ score += weighted;
await db.execute(
- "INSERT INTO keluarga_miskin (nama_kk, jumlah_anggota, kategori, kelurahan, lat, lng) VALUES (?, ?, ?, ?, ?, ?)",
- row,
+ `INSERT INTO penilaian (id_keluarga, id_kriteria, nilai, nilai_normalisasi, nilai_terbobot)
+ VALUES (?, ?, ?, ?, ?)
+ ON DUPLICATE KEY UPDATE nilai=VALUES(nilai), nilai_normalisasi=VALUES(nilai_normalisasi), nilai_terbobot=VALUES(nilai_terbobot)`,
+ [row.family.id_keluarga, criterion.id_kriteria, rawValue, normalized, weighted],
);
}
+ results.push({
+ id_keluarga: row.family.id_keluarga,
+ skor_saw: Number(score.toFixed(6)),
+ kategori: sawCategory(score),
+ });
}
+
+ results.sort((a, b) => b.skor_saw - a.skor_saw);
+ for (const [index, result] of results.entries()) {
+ await db.execute(
+ `INSERT INTO hasil_saw (id_keluarga, skor_saw, kategori, ranking)
+ VALUES (?, ?, ?, ?)
+ ON DUPLICATE KEY UPDATE skor_saw=VALUES(skor_saw), kategori=VALUES(kategori), ranking=VALUES(ranking)`,
+ [result.id_keluarga, result.skor_saw, result.kategori, index + 1],
+ );
+ result.ranking = index + 1;
+ }
+ return results;
+}
+
+async function rankedFamilies() {
+ await calculateSaw();
+ const [rows] = await db.execute(`
+ SELECT k.*, h.skor_saw, h.kategori, h.ranking,
+ COALESCE(SUM(rb.jumlah_bantuan), 0) AS total_bantuan,
+ COUNT(rb.id_riwayat) AS jumlah_riwayat
+ FROM keluarga k
+ LEFT JOIN hasil_saw h ON h.id_keluarga = k.id_keluarga
+ LEFT JOIN riwayat_bantuan rb ON rb.id_keluarga = k.id_keluarga
+ WHERE k.aktif = 1
+ GROUP BY k.id_keluarga, h.skor_saw, h.kategori, h.ranking
+ ORDER BY h.ranking ASC, k.nama_kepala_keluarga ASC
+ `);
+ return rows;
+}
+
+async function familyAidHistory(id) {
+ const [rows] = await db.execute(
+ `SELECT rb.*, b.jenis_bantuan, b.nilai_bantuan
+ FROM riwayat_bantuan rb
+ JOIN bantuan b ON b.id_bantuan = rb.id_bantuan
+ WHERE rb.id_keluarga = ?
+ ORDER BY rb.tanggal_diterima DESC, rb.id_riwayat DESC`,
+ [id],
+ );
+ return rows;
+}
+
+function validateFamily(body) {
+ const required = [
+ "nomor_kartu_keluarga",
+ "nama_kepala_keluarga",
+ "alamat",
+ "latitude",
+ "longitude",
+ "jumlah_anggota",
+ "jumlah_tanggungan",
+ "pendapatan_bulanan",
+ "pekerjaan",
+ "pendidikan",
+ "kondisi_rumah",
+ "kondisi_kesehatan",
+ "status_verifikasi",
+ "tanggal_update",
+ ];
+ for (const key of required) {
+ if (body[key] === undefined || body[key] === null || body[key] === "") {
+ return `${key} wajib diisi.`;
+ }
+ }
+ if (!Number.isFinite(Number(body.latitude)) || !Number.isFinite(Number(body.longitude))) {
+ return "Latitude dan longitude wajib valid.";
+ }
+ return null;
}
router.use(async (req, res, next) => {
@@ -166,160 +501,407 @@ router.use(async (req, res, next) => {
}
});
-router.get("/tempat-ibadah", async (req, res) => {
+router.get("/dashboard", 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));
+ const rows = await rankedFamilies();
+ const [aidRows] = await db.execute("SELECT COALESCE(SUM(jumlah_bantuan), 0) AS total FROM riwayat_bantuan");
+ const [criteriaRows] = await db.execute("SELECT nama_kriteria, bobot, atribut FROM kriteria WHERE aktif = 1 ORDER BY id_kriteria");
+ const categoryStats = rows.reduce((acc, item) => {
+ acc[item.kategori] = (acc[item.kategori] || 0) + 1;
+ return acc;
+ }, {});
+ const wilayahStats = rows.reduce((acc, item) => {
+ acc[item.wilayah] = (acc[item.wilayah] || 0) + 1;
+ return acc;
+ }, {});
+ res.json({
+ success: true,
+ data: {
+ total_keluarga: rows.length,
+ prioritas_tinggi: rows.filter((item) => item.kategori === "Sangat Membutuhkan").length,
+ total_bantuan_tersalurkan: Number(aidRows[0]?.total || 0),
+ kategori_ekonomi: categoryStats,
+ wilayah: wilayahStats,
+ kriteria: criteriaRows,
+ ranking_top10: rows.slice(0, 10),
+ },
+ });
} 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." });
- }
-
+router.get("/analytics", async (req, res) => {
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." });
+ const rows = await rankedFamilies();
+ const [aidByMonth] = await db.execute(`
+ SELECT DATE_FORMAT(tanggal_diterima, '%Y-%m') AS bulan, SUM(jumlah_bantuan) AS total
+ FROM riwayat_bantuan
+ GROUP BY DATE_FORMAT(tanggal_diterima, '%Y-%m')
+ ORDER BY bulan ASC
+ `);
+ const [trendRows] = await db.execute(`
+ SELECT DATE_FORMAT(tanggal_update, '%Y-%m') AS bulan, COUNT(*) AS total, AVG(h.skor_saw) AS rata_skor
+ FROM keluarga k
+ LEFT JOIN hasil_saw h ON h.id_keluarga = k.id_keluarga
+ WHERE k.aktif = 1
+ GROUP BY DATE_FORMAT(tanggal_update, '%Y-%m')
+ ORDER BY bulan ASC
+ `);
+ res.json({
+ success: true,
+ data: {
+ kategori: rows.reduce((acc, item) => {
+ acc[item.kategori] = (acc[item.kategori] || 0) + 1;
+ return acc;
+ }, {}),
+ bantuan_bulanan: aidByMonth,
+ wilayah: rows.reduce((acc, item) => {
+ acc[item.wilayah] = (acc[item.wilayah] || 0) + 1;
+ return acc;
+ }, {}),
+ tren_ekonomi: trendRows,
+ top10: rows.slice(0, 10),
+ },
+ });
} 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." });
- }
-
+router.get("/map", async (req, res) => {
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." });
+ const role = req.query.role === "pengurus" ? "pengurus" : "admin";
+ const privacy = req.query.privacy === "1" || req.query.privacy === "true";
+ const kategori = req.query.kategori || "";
+ const search = String(req.query.search || "").toLowerCase();
+ const rows = await rankedFamilies();
+ const filtered = rows
+ .filter((item) => !kategori || item.kategori === kategori)
+ .filter((item) => !search || item.nama_kepala_keluarga.toLowerCase().includes(search) || item.alamat.toLowerCase().includes(search))
+ .map((item) => applyPrivacy(item, role, privacy));
+ const withHistory = [];
+ for (const item of filtered) {
+ withHistory.push({ ...item, riwayat_bantuan: await familyAidHistory(item.id_keluarga) });
+ }
+ res.json({
+ success: true,
+ data: withHistory,
+ geojson: {
+ type: "FeatureCollection",
+ features: withHistory.map(buildFeature),
+ },
+ });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
});
-router.delete("/tempat-ibadah/:id", async (req, res) => {
+router.get("/ranking", 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." });
+ res.json({ success: true, data: await rankedFamilies() });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
});
+router.post("/recalculate", async (req, res) => {
+ try {
+ res.json({ success: true, message: "Perhitungan SAW berhasil diperbarui.", data: await calculateSaw() });
+ } catch (error) {
+ res.status(500).json({ success: false, message: error.message });
+ }
+});
+
+router.get("/users", async (req, res) => {
+ const [rows] = await db.execute("SELECT id_user, nama, username, role, aktif, created_at FROM users ORDER BY nama ASC");
+ res.json({ success: true, data: rows });
+});
+
+router.post("/users", async (req, res) => {
+ const { nama, username, password, role } = req.body;
+ if (!nama || !username || !password || !["admin", "pengurus"].includes(role)) {
+ return res.status(400).json({ success: false, message: "Nama, username, password, dan role wajib valid." });
+ }
+ try {
+ const [result] = await db.execute("INSERT INTO users (nama, username, password, role) VALUES (?, ?, ?, ?)", [nama, username, password, role]);
+ res.status(201).json({ success: true, id: result.insertId, message: "User berhasil ditambahkan." });
+ } catch (error) {
+ res.status(500).json({ success: false, message: error.message });
+ }
+});
+
+router.put("/users/:id", async (req, res) => {
+ const { nama, username, password, role, aktif } = req.body;
+ if (!nama || !username || !["admin", "pengurus"].includes(role)) {
+ return res.status(400).json({ success: false, message: "Nama, username, dan role wajib valid." });
+ }
+ const params = password ? [nama, username, password, role, aktif ? 1 : 0, req.params.id] : [nama, username, role, aktif ? 1 : 0, req.params.id];
+ const sql = password
+ ? "UPDATE users SET nama=?, username=?, password=?, role=?, aktif=? WHERE id_user=?"
+ : "UPDATE users SET nama=?, username=?, role=?, aktif=? WHERE id_user=?";
+ const [result] = await db.execute(sql, params);
+ if (!result.affectedRows) return res.status(404).json({ success: false, message: "User tidak ditemukan." });
+ res.json({ success: true, message: "User berhasil diperbarui." });
+});
+
+router.delete("/users/:id", async (req, res) => {
+ const [result] = await db.execute("UPDATE users SET aktif = 0 WHERE id_user = ?", [req.params.id]);
+ if (!result.affectedRows) return res.status(404).json({ success: false, message: "User tidak ditemukan." });
+ res.json({ success: true, message: "User berhasil dinonaktifkan." });
+});
+
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));
+ res.json({ success: true, data: await rankedFamilies() });
} 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." });
- }
+router.get("/keluarga/:id", async (req, res) => {
+ const rows = await rankedFamilies();
+ const item = rows.find((row) => String(row.id_keluarga) === String(req.params.id));
+ if (!item) return res.status(404).json({ success: false, message: "Keluarga tidak ditemukan." });
+ res.json({ success: true, data: { ...item, riwayat_bantuan: await familyAidHistory(item.id_keluarga) } });
+});
+router.post("/keluarga", async (req, res) => {
+ const error = validateFamily(req.body);
+ if (error) return res.status(400).json({ success: false, message: error });
+ const body = req.body;
try {
const [result] = await db.execute(
- `INSERT INTO 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)],
+ `INSERT INTO keluarga
+ (nomor_kartu_keluarga, nama_kepala_keluarga, alamat, wilayah, latitude, longitude,
+ jumlah_anggota, jumlah_tanggungan, pendapatan_bulanan, pekerjaan, pendidikan,
+ kondisi_rumah, kondisi_kesehatan, status_verifikasi, tanggal_update)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ body.nomor_kartu_keluarga,
+ body.nama_kepala_keluarga,
+ body.alamat,
+ body.wilayah || "Belum Dipetakan",
+ body.latitude,
+ body.longitude,
+ body.jumlah_anggota,
+ body.jumlah_tanggungan,
+ body.pendapatan_bulanan,
+ body.pekerjaan,
+ body.pendidikan,
+ body.kondisi_rumah,
+ body.kondisi_kesehatan,
+ body.status_verifikasi,
+ body.tanggal_update,
+ ],
);
- res.status(201).json({ success: true, id: result.insertId, message: "Keluarga berhasil ditambahkan." });
- } catch (error) {
- res.status(500).json({ success: false, message: error.message });
+ await calculateSaw();
+ res.status(201).json({ success: true, id: result.insertId, message: "Data keluarga berhasil ditambahkan." });
+ } catch (err) {
+ res.status(500).json({ success: false, message: err.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 });
- }
+ const error = validateFamily(req.body);
+ if (error) return res.status(400).json({ success: false, message: error });
+ const body = req.body;
+ const [result] = await db.execute(
+ `UPDATE keluarga SET
+ nomor_kartu_keluarga=?, nama_kepala_keluarga=?, alamat=?, wilayah=?, latitude=?, longitude=?,
+ jumlah_anggota=?, jumlah_tanggungan=?, pendapatan_bulanan=?, pekerjaan=?, pendidikan=?,
+ kondisi_rumah=?, kondisi_kesehatan=?, status_verifikasi=?, tanggal_update=?
+ WHERE id_keluarga=?`,
+ [
+ body.nomor_kartu_keluarga,
+ body.nama_kepala_keluarga,
+ body.alamat,
+ body.wilayah || "Belum Dipetakan",
+ body.latitude,
+ body.longitude,
+ body.jumlah_anggota,
+ body.jumlah_tanggungan,
+ body.pendapatan_bulanan,
+ body.pekerjaan,
+ body.pendidikan,
+ body.kondisi_rumah,
+ body.kondisi_kesehatan,
+ body.status_verifikasi,
+ body.tanggal_update,
+ req.params.id,
+ ],
+ );
+ if (!result.affectedRows) return res.status(404).json({ success: false, message: "Keluarga tidak ditemukan." });
+ await calculateSaw();
+ res.json({ success: true, message: "Data keluarga berhasil diperbarui." });
});
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 });
- }
+ const [result] = await db.execute("UPDATE keluarga SET aktif = 0 WHERE id_keluarga = ?", [req.params.id]);
+ if (!result.affectedRows) return res.status(404).json({ success: false, message: "Keluarga tidak ditemukan." });
+ await calculateSaw();
+ res.json({ success: true, message: "Data keluarga berhasil dihapus." });
});
-router.get("/export", async (req, res) => {
- const ibadahId = Number(req.query.ibadah_id);
- const radius = Number(req.query.radius || 500);
+router.get("/kriteria", async (req, res) => {
+ const [rows] = await db.execute("SELECT * FROM kriteria ORDER BY id_kriteria ASC");
+ res.json({ success: true, data: rows });
+});
- if (!Number.isInteger(ibadahId) || !Number.isFinite(radius) || radius <= 0) {
- return res.status(400).json({ success: false, message: "ibadah_id dan radius wajib valid." });
+router.post("/kriteria", async (req, res) => {
+ const { kode, nama_kriteria, bobot, atribut } = req.body;
+ if (!kode || !nama_kriteria || !["benefit", "cost"].includes(atribut)) {
+ return res.status(400).json({ success: false, message: "Kode, nama kriteria, dan atribut wajib valid." });
}
+ const [result] = await db.execute(
+ "INSERT INTO kriteria (kode, nama_kriteria, bobot, atribut) VALUES (?, ?, ?, ?)",
+ [kode, nama_kriteria, toNumber(bobot), atribut],
+ );
+ await calculateSaw();
+ res.status(201).json({ success: true, id: result.insertId, message: "Kriteria berhasil ditambahkan." });
+});
- 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 });
+router.put("/kriteria/:id", async (req, res) => {
+ const { kode, nama_kriteria, bobot, atribut, aktif } = req.body;
+ if (!kode || !nama_kriteria || !["benefit", "cost"].includes(atribut)) {
+ return res.status(400).json({ success: false, message: "Kode, nama kriteria, dan atribut wajib valid." });
}
+ const [result] = await db.execute(
+ "UPDATE kriteria SET kode=?, nama_kriteria=?, bobot=?, atribut=?, aktif=? WHERE id_kriteria=?",
+ [kode, nama_kriteria, toNumber(bobot), atribut, aktif === false ? 0 : 1, req.params.id],
+ );
+ if (!result.affectedRows) return res.status(404).json({ success: false, message: "Kriteria tidak ditemukan." });
+ await calculateSaw();
+ res.json({ success: true, message: "Kriteria berhasil diperbarui." });
+});
+
+router.delete("/kriteria/:id", async (req, res) => {
+ const [result] = await db.execute("UPDATE kriteria SET aktif = 0 WHERE id_kriteria = ?", [req.params.id]);
+ if (!result.affectedRows) return res.status(404).json({ success: false, message: "Kriteria tidak ditemukan." });
+ await calculateSaw();
+ res.json({ success: true, message: "Kriteria berhasil dinonaktifkan." });
+});
+
+router.get("/penilaian", async (req, res) => {
+ const [rows] = await db.execute(`
+ SELECT p.*, k.nama_kriteria, k.kode, kl.nama_kepala_keluarga
+ FROM penilaian p
+ JOIN kriteria k ON k.id_kriteria = p.id_kriteria
+ JOIN keluarga kl ON kl.id_keluarga = p.id_keluarga
+ ORDER BY kl.nama_kepala_keluarga ASC, k.id_kriteria ASC
+ `);
+ res.json({ success: true, data: rows });
+});
+
+router.post("/penilaian", async (req, res) => {
+ const { id_keluarga, id_kriteria, nilai } = req.body;
+ if (!id_keluarga || !id_kriteria) return res.status(400).json({ success: false, message: "Keluarga dan kriteria wajib dipilih." });
+ const [result] = await db.execute(
+ `INSERT INTO penilaian (id_keluarga, id_kriteria, nilai)
+ VALUES (?, ?, ?)
+ ON DUPLICATE KEY UPDATE nilai=VALUES(nilai)`,
+ [id_keluarga, id_kriteria, toNumber(nilai)],
+ );
+ res.status(201).json({ success: true, id: result.insertId, message: "Penilaian berhasil disimpan." });
+});
+
+router.put("/penilaian/:id", async (req, res) => {
+ const { id_keluarga, id_kriteria, nilai } = req.body;
+ const [result] = await db.execute(
+ "UPDATE penilaian SET id_keluarga=?, id_kriteria=?, nilai=? WHERE id_penilaian=?",
+ [id_keluarga, id_kriteria, toNumber(nilai), req.params.id],
+ );
+ if (!result.affectedRows) return res.status(404).json({ success: false, message: "Penilaian tidak ditemukan." });
+ res.json({ success: true, message: "Penilaian berhasil diperbarui." });
+});
+
+router.delete("/penilaian/:id", async (req, res) => {
+ const [result] = await db.execute("DELETE FROM penilaian WHERE id_penilaian = ?", [req.params.id]);
+ if (!result.affectedRows) return res.status(404).json({ success: false, message: "Penilaian tidak ditemukan." });
+ res.json({ success: true, message: "Penilaian berhasil dihapus." });
+});
+
+router.get("/hasil-saw", async (req, res) => {
+ const rows = await rankedFamilies();
+ res.json({ success: true, data: rows.map((item) => ({
+ id_keluarga: item.id_keluarga,
+ nama_kepala_keluarga: item.nama_kepala_keluarga,
+ skor_saw: item.skor_saw,
+ kategori: item.kategori,
+ ranking: item.ranking,
+ })) });
+});
+
+router.get("/bantuan", async (req, res) => {
+ const [rows] = await db.execute("SELECT * FROM bantuan ORDER BY tanggal_bantuan DESC, id_bantuan DESC");
+ res.json({ success: true, data: rows });
+});
+
+router.post("/bantuan", async (req, res) => {
+ const { jenis_bantuan, tanggal_bantuan, nilai_bantuan, keterangan } = req.body;
+ if (!jenis_bantuan || !tanggal_bantuan) {
+ return res.status(400).json({ success: false, message: "Jenis dan tanggal bantuan wajib diisi." });
+ }
+ const [result] = await db.execute(
+ "INSERT INTO bantuan (jenis_bantuan, tanggal_bantuan, nilai_bantuan, keterangan) VALUES (?, ?, ?, ?)",
+ [jenis_bantuan, tanggal_bantuan, toNumber(nilai_bantuan), keterangan || null],
+ );
+ res.status(201).json({ success: true, id: result.insertId, message: "Bantuan berhasil ditambahkan." });
+});
+
+router.put("/bantuan/:id", async (req, res) => {
+ const { jenis_bantuan, tanggal_bantuan, nilai_bantuan, keterangan } = req.body;
+ const [result] = await db.execute(
+ "UPDATE bantuan SET jenis_bantuan=?, tanggal_bantuan=?, nilai_bantuan=?, keterangan=? WHERE id_bantuan=?",
+ [jenis_bantuan, tanggal_bantuan, toNumber(nilai_bantuan), keterangan || null, req.params.id],
+ );
+ if (!result.affectedRows) return res.status(404).json({ success: false, message: "Bantuan tidak ditemukan." });
+ res.json({ success: true, message: "Bantuan berhasil diperbarui." });
+});
+
+router.delete("/bantuan/:id", async (req, res) => {
+ const [result] = await db.execute("DELETE FROM bantuan WHERE id_bantuan = ?", [req.params.id]);
+ if (!result.affectedRows) return res.status(404).json({ success: false, message: "Bantuan tidak ditemukan." });
+ res.json({ success: true, message: "Bantuan berhasil dihapus." });
+});
+
+router.get("/riwayat-bantuan", async (req, res) => {
+ const [rows] = await db.execute(`
+ SELECT rb.*, k.nama_kepala_keluarga, b.jenis_bantuan, b.nilai_bantuan
+ FROM riwayat_bantuan rb
+ JOIN keluarga k ON k.id_keluarga = rb.id_keluarga
+ JOIN bantuan b ON b.id_bantuan = rb.id_bantuan
+ ORDER BY rb.tanggal_diterima DESC, rb.id_riwayat DESC
+ `);
+ res.json({ success: true, data: rows });
+});
+
+router.post("/riwayat-bantuan", async (req, res) => {
+ const { id_keluarga, id_bantuan, tanggal_diterima, jumlah_bantuan, keterangan } = req.body;
+ if (!id_keluarga || !id_bantuan || !tanggal_diterima) {
+ return res.status(400).json({ success: false, message: "Penerima, bantuan, dan tanggal diterima wajib diisi." });
+ }
+ const [result] = await db.execute(
+ "INSERT INTO riwayat_bantuan (id_keluarga, id_bantuan, tanggal_diterima, jumlah_bantuan, keterangan) VALUES (?, ?, ?, ?, ?)",
+ [id_keluarga, id_bantuan, tanggal_diterima, toNumber(jumlah_bantuan), keterangan || null],
+ );
+ res.status(201).json({ success: true, id: result.insertId, message: "Riwayat bantuan berhasil ditambahkan." });
+});
+
+router.put("/riwayat-bantuan/:id", async (req, res) => {
+ const { id_keluarga, id_bantuan, tanggal_diterima, jumlah_bantuan, keterangan } = req.body;
+ const [result] = await db.execute(
+ "UPDATE riwayat_bantuan SET id_keluarga=?, id_bantuan=?, tanggal_diterima=?, jumlah_bantuan=?, keterangan=? WHERE id_riwayat=?",
+ [id_keluarga, id_bantuan, tanggal_diterima, toNumber(jumlah_bantuan), keterangan || null, req.params.id],
+ );
+ if (!result.affectedRows) return res.status(404).json({ success: false, message: "Riwayat bantuan tidak ditemukan." });
+ res.json({ success: true, message: "Riwayat bantuan berhasil diperbarui." });
+});
+
+router.delete("/riwayat-bantuan/:id", async (req, res) => {
+ const [result] = await db.execute("DELETE FROM riwayat_bantuan WHERE id_riwayat = ?", [req.params.id]);
+ if (!result.affectedRows) return res.status(404).json({ success: false, message: "Riwayat bantuan tidak ditemukan." });
+ res.json({ success: true, message: "Riwayat bantuan berhasil dihapus." });
});
module.exports = router;
diff --git a/frontend/admin-poverty.html b/frontend/admin-poverty.html
index 082e49d..835cb34 100644
--- a/frontend/admin-poverty.html
+++ b/frontend/admin-poverty.html
@@ -3,883 +3,570 @@
- Admin Poverty Mapping
+ Admin Poverty Mapping Gereja
-
-
+
+
-
+
+
+
-
-
-
-
+
+
+
+
+
+
Total keluarga0
+
Prioritas tinggi0
+
Bantuan tersalurkanRp0
+
Kriteria aktif0
+
+
+
Grafik Kategori Ekonomi
+
Bantuan Tersalurkan
+
Distribusi Wilayah
+
+
+
Ranking 10 Prioritas
+
Tren Kondisi Ekonomi
+
+
+
+
+
+
+ Form Data Keluarga
+
+
+
+ Daftar Keluarga
+ | Ranking | Keluarga | Ekonomi | Skor | Aksi |
|---|
+
+
+
+
+
+
+
+ Form Bantuan
+
+
+
+ Catat Penerima Bantuan
+
+
+
+
+
Data Bantuan
+
Riwayat Bantuan
| Penerima | Bantuan | Tanggal | Jumlah | Aksi |
|---|
+
+
+
+
+
+
+ Form Kriteria SAW
+
+
+
Data Kriteria
| Kriteria | Bobot | Atribut | Status | Aksi |
|---|
+
+
+
+
+
+ Preview Peta Keluarga Prioritas
Klik peta untuk mengisi koordinat form keluarga.
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- | 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.
-
-
-
-
-
-
+