From 9d22253e400c2e9f79ec349415d55ac0bb88a9ee Mon Sep 17 00:00:00 2001 From: tistopandita Date: Thu, 11 Jun 2026 00:41:49 +0700 Subject: [PATCH] update_poverty --- backend/routes/poverty.js | 1030 ++++++++++++++++++------ frontend/admin-poverty.html | 1321 ++++++++++++------------------ frontend/poverty-map.html | 1502 ++++++++--------------------------- schema.sql | 232 ++++-- 4 files changed, 1783 insertions(+), 2302 deletions(-) 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 - - + + - +
+
+ -
- - - +
+
+
+

Dashboard Bantuan Gereja

+

SAW otomatis: normalisasi, nilai terbobot, kategori, dan ranking prioritas.

+
+ +
+ +
+
+
Total keluarga0
+
Prioritas tinggi0
+
Bantuan tersalurkanRp0
+
Kriteria aktif0
+
+
+

Grafik Kategori Ekonomi

+

Bantuan Tersalurkan

+

Distribusi Wilayah

+
+
+

Ranking 10 Prioritas

RankKeluargaSkorKategori
+

Tren Kondisi Ekonomi

+
+
+ +
+
+
+

Form Data Keluarga

+
+
+ +
+ + + + + + + + + + + + + + + +
+
+
+
+
+
+

Daftar Keluarga

+
RankingKeluargaEkonomiSkorAksi
+
+
+
+ +
+
+
+

Form Bantuan

+
+
+ +
+ + + + +
+
+
+
+
+
+

Catat Penerima Bantuan

+
+
+ +
+ + + + + +
+
+
+
+
+
+
+

Data Bantuan

JenisTanggalNilaiAksi
+

Riwayat Bantuan

PenerimaBantuanTanggalJumlahAksi
+
+
+ +
+
+
+

Form Kriteria SAW

+
+
+ +
+ + + + +
+
+
+
+
+

Data Kriteria

KriteriaBobotAtributStatusAksi
+
+
+ +
+
+

Preview Peta Keluarga Prioritas

Klik peta untuk mengisi koordinat form keluarga.
+
+
+
+
-
-
-
-
-
-

Form Tempat Ibadah

-
-
-
- - - - - -
- - -
-
-

Klik peta untuk mengisi koordinat tempat ibadah otomatis.

-
- - -
-
-
-
- -
-
-

Daftar Tempat Ibadah

-
-
- - - - - - - - - - - -
NamaJenisKelurahanKoordinatAksi
-
-
-
-
- -
-
-
-
-

Form Keluarga Miskin

-
-
-
- - -
- - -
- -
- - -
- - -
- - -
-
-

Klik peta untuk mengisi koordinat keluarga otomatis.

-
- - -
-
-
-
- -
-
-

Daftar Keluarga

-
-
- - - -
-
- - - - - - - - - - - - -
Nama KKAnggotaKategoriKelurahanKoordinatAksi
-
-
-
-
- -
-
-
-

Peta Preview & Export

-
-
- - - - -
-
-
-

Preview menampilkan seluruh tempat ibadah dan keluarga. Export memakai tempat ibadah dan radius yang dipilih.

-
-
-
-
- - + diff --git a/frontend/poverty-map.html b/frontend/poverty-map.html index 72eb36f..377d0b4 100644 --- a/frontend/poverty-map.html +++ b/frontend/poverty-map.html @@ -3,1290 +3,428 @@ - WebGIS Poverty Mapping – Pontianak + WebGIS Poverty Mapping Gereja - - + + - +
- -
+
-
-
-
⏳ Memuat data peta kemiskinan...
+
+
+
Ranking tertinggi-
+
Rata-rata skor0.000
-
-
- -
-

Dashboard Ringkas

-
-
Terdampak0
-
Cakupan0%
-
Sangat Miskin0
-
Miskin0
-
Rentan0
-
DipilihSemua
-
-
- - - -
-
-

- -
-
+
+
- + diff --git a/schema.sql b/schema.sql index d6dec01..198ebf3 100644 --- a/schema.sql +++ b/schema.sql @@ -1,8 +1,5 @@ --- ============================================================ --- Schema Database WebGIS SPBU Pontianak --- Jalankan sekali untuk membuat database & tabel secara manual --- (Opsional — server.js sudah auto-create tabel saat start) --- ============================================================ +-- Schema Database WebGIS SPBU dan Poverty Mapping Gereja +-- Stack: Node.js, Express.js, MySQL, HTML5, CSS3, Vanilla JavaScript, Leaflet.js CREATE DATABASE IF NOT EXISTS spbu_pontianak CHARACTER SET utf8mb4 @@ -10,91 +7,168 @@ CREATE DATABASE IF NOT EXISTS spbu_pontianak USE spbu_pontianak; --- ── Tabel SPBU ────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS spbu ( - id INT AUTO_INCREMENT PRIMARY KEY, - kode VARCHAR(20) NOT NULL UNIQUE COMMENT 'Kode unik SPBU, mis: 64.781.01', - nama VARCHAR(100) NOT NULL, - alamat TEXT NOT NULL, - lat DOUBLE NOT NULL COMMENT 'Latitude (desimal)', - lng DOUBLE NOT NULL COMMENT 'Longitude (desimal)', - jam_buka TIME NOT NULL DEFAULT '06:00' COMMENT 'Format HH:MM', - jam_tutup TIME NOT NULL DEFAULT '22:00' COMMENT 'Format HH:MM', - jenis_bbm JSON NOT NULL COMMENT 'Array jenis BBM, contoh: ["Pertalite","Pertamax"]', - manual_override ENUM('auto','open','closed') NOT NULL DEFAULT 'auto', - aktif TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1=aktif, 0=dihapus (soft delete)', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + id INT AUTO_INCREMENT PRIMARY KEY, + kode VARCHAR(20) NOT NULL UNIQUE, + nama VARCHAR(100) NOT NULL, + alamat TEXT NOT NULL, + lat DOUBLE NOT NULL, + lng DOUBLE NOT NULL, + jam_buka TIME NOT NULL DEFAULT '06:00:00', + jam_tutup TIME NOT NULL DEFAULT '22:00:00', + jenis_bbm JSON NOT NULL, + manual_override ENUM('auto','open','closed') NOT NULL DEFAULT 'auto', + 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; --- ── Tabel Jalan ───────────────────────────────────────────── CREATE TABLE IF NOT EXISTS jalan ( - id INT AUTO_INCREMENT PRIMARY KEY, - nama_jalan VARCHAR(150) NOT NULL COMMENT 'Nama jalan, mis: Jl. Ahmad Yani', - jenis_jalan ENUM('nasional','provinsi','kota_kabupaten') NOT NULL DEFAULT 'kota_kabupaten' - COMMENT 'Klasifikasi jalan', - koordinat LONGTEXT NOT NULL COMMENT 'GeoJSON LineString koordinat polyline', - aktif TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1=aktif, 0=dihapus (soft delete)', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + id INT AUTO_INCREMENT PRIMARY KEY, + nama_jalan VARCHAR(150) NOT NULL, + jenis_jalan ENUM('nasional','provinsi','kota_kabupaten') NOT NULL DEFAULT 'kota_kabupaten', + koordinat LONGTEXT 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; --- ── Tabel Bangunan ────────────────────────────────────────── CREATE TABLE IF NOT EXISTS bangunan ( - id INT AUTO_INCREMENT PRIMARY KEY, - nama_bangunan VARCHAR(150) NOT NULL COMMENT 'Nama bangunan atau kavling', - jenis_bangunan ENUM('SHM','HGB','HGU','HP','HPL') NOT NULL DEFAULT 'SHM' - COMMENT 'SHM=Sertifikat Hak Milik, HGB=Hak Guna Bangunan, HGU=Hak Guna Usaha, HP=Hak Pakai, HPL=Hak Pengelolaan', - nama_jalan VARCHAR(150) COMMENT 'Nama jalan lokasi bangunan', - koordinat LONGTEXT NOT NULL COMMENT 'GeoJSON Polygon koordinat area bangunan', - aktif TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1=aktif, 0=dihapus (soft delete)', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + id INT AUTO_INCREMENT PRIMARY KEY, + nama_bangunan VARCHAR(150) NOT NULL, + jenis_bangunan ENUM('SHM','HGB','HGU','HP','HPL') NOT NULL DEFAULT 'SHM', + nama_jalan VARCHAR(150), + koordinat LONGTEXT 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; --- ── 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) +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; -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) +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, + 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, + INDEX idx_keluarga_lokasi (latitude, longitude), + INDEX idx_keluarga_wilayah (wilayah), + INDEX idx_keluarga_verifikasi (status_verifikasi) ) 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); +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; -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); +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, + 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; + +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; + +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; + +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; + +INSERT IGNORE INTO users (nama, username, password, role) VALUES +('Administrator', 'admin', 'admin123', 'admin'), +('Pengurus Gereja', 'pengurus', 'pengurus123', 'pengurus'); + +INSERT INTO kriteria (kode, nama_kriteria, bobot, atribut) VALUES +('pendapatan', 'Pendapatan', 0.3500, 'cost'), +('jumlah_tanggungan', 'Jumlah Tanggungan', 0.2000, 'benefit'), +('kondisi_rumah', 'Kondisi Rumah', 0.1500, 'benefit'), +('pekerjaan', 'Pekerjaan', 0.1500, 'benefit'), +('pendidikan', 'Pendidikan Kepala Keluarga', 0.1000, 'benefit'), +('kondisi_kesehatan', 'Kondisi Kesehatan', 0.0500, 'benefit') +ON DUPLICATE KEY UPDATE + nama_kriteria = VALUES(nama_kriteria), + bobot = VALUES(bobot), + atribut = VALUES(atribut), + aktif = 1; + +INSERT IGNORE 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 +('6171010101010001', 'Maria Goretti', 'Jl. Gajah Mada Gang Damai', 'Paroki Katedral', -0.0339, 109.3367, 5, 4, 1250000, 'buruh_harian', 'smp', 'buruk', 'sakit_sering', 'terverifikasi', CURDATE()), +('6171010101010002', 'Yohanes Rudi', 'Jl. Tanjung Raya II', 'Paroki Santo Yosef', -0.0278, 109.3695, 6, 5, 900000, 'pengangguran', 'sd', 'sangat_buruk', 'kronis', 'terverifikasi', CURDATE()), +('6171010101010003', 'Agnes Lestari', 'Jl. Husein Hamzah', 'Paroki Santo Hieronymus', -0.0252, 109.3062, 4, 3, 1750000, 'pedagang_kecil', 'sma', 'semi_permanen', 'sehat', 'terverifikasi', CURDATE()), +('6171010101010004', 'Petrus Antoni', 'Jl. Imam Bonjol', 'Paroki Keluarga Kudus', -0.0266, 109.3444, 3, 2, 2800000, 'karyawan', 'sma', 'sederhana', 'sehat', 'terverifikasi', CURDATE()), +('6171010101010005', 'Theresia Ani', 'Jl. Khatulistiwa', 'Paroki Santo Agustinus', 0.0023, 109.3237, 7, 6, 1150000, 'buruh_harian', 'sd', 'buruk', 'disabilitas', 'terverifikasi', CURDATE()), +('6171010101010006', 'Fransiskus Deni', 'Jl. Dr. Wahidin', 'Paroki Santo Paulus', -0.0364, 109.3151, 2, 1, 4200000, 'wiraswasta', 'diploma', 'layak', 'sehat', 'terverifikasi', CURDATE()); + +INSERT INTO bantuan (jenis_bantuan, tanggal_bantuan, nilai_bantuan, keterangan) +SELECT 'Paket Sembako', CURDATE(), 350000, 'Bantuan pangan bulanan' +WHERE NOT EXISTS (SELECT 1 FROM bantuan WHERE jenis_bantuan = 'Paket Sembako'); + +INSERT INTO bantuan (jenis_bantuan, tanggal_bantuan, nilai_bantuan, keterangan) +SELECT 'Bantuan Pendidikan', CURDATE(), 500000, 'Dukungan biaya sekolah' +WHERE NOT EXISTS (SELECT 1 FROM bantuan WHERE jenis_bantuan = 'Bantuan Pendidikan');