const express = require("express"); const db = require("../db"); const router = express.Router(); let tablesReady = false; 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 { "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 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 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; 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; `); await migrateLegacyKeluarga(); 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, 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; `); 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, 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 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 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)); } 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 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) => { try { await ensureTables(); next(); } catch (error) { res.status(500).json({ success: false, message: error.message }); } }); router.get("/dashboard", async (req, res) => { try { 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.get("/analytics", async (req, res) => { try { 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.get("/map", async (req, res) => { try { 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.get("/ranking", async (req, res) => { try { 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 { res.json({ success: true, data: await rankedFamilies() }); } catch (error) { res.status(500).json({ success: false, message: error.message }); } }); 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 (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, ], ); 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 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) => { 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("/kriteria", async (req, res) => { const [rows] = await db.execute("SELECT * FROM kriteria ORDER BY id_kriteria ASC"); res.json({ success: true, data: rows }); }); 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." }); }); 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;