add poverty

This commit is contained in:
tistopandita
2026-06-11 13:56:18 +07:00
parent 7ef7620d2a
commit 5386300498
4 changed files with 1421 additions and 227 deletions
+152 -4
View File
@@ -12,6 +12,8 @@ const createTempatIbadah = `
kelurahan VARCHAR(100) NOT NULL, kelurahan VARCHAR(100) NOT NULL,
lat DOUBLE NOT NULL, lat DOUBLE NOT NULL,
lng DOUBLE NOT NULL, lng DOUBLE NOT NULL,
kapasitas_bantuan INT NOT NULL DEFAULT 0,
keterangan TEXT,
aktif TINYINT(1) NOT NULL DEFAULT 1, aktif TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -26,6 +28,12 @@ async function initTempatIbadahTable() {
if (!columns.includes("created_at")) { if (!columns.includes("created_at")) {
await db.execute("ALTER TABLE tempat_ibadah ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP"); await db.execute("ALTER TABLE tempat_ibadah ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP");
} }
if (!columns.includes("kapasitas_bantuan")) {
await db.execute("ALTER TABLE tempat_ibadah ADD COLUMN kapasitas_bantuan INT NOT NULL DEFAULT 0");
}
if (!columns.includes("keterangan")) {
await db.execute("ALTER TABLE tempat_ibadah ADD COLUMN keterangan TEXT");
}
await db.execute(` await db.execute(`
UPDATE tempat_ibadah UPDATE tempat_ibadah
SET jenis = 'Gereja Katolik' SET jenis = 'Gereja Katolik'
@@ -291,6 +299,24 @@ async function ensureTables() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`); `);
await db.execute(`
CREATE TABLE IF NOT EXISTS pengajuan_bantuan (
id_pengajuan INT AUTO_INCREMENT PRIMARY KEY,
nama_pemohon VARCHAR(160) NOT NULL,
nik VARCHAR(20) NOT NULL,
alamat TEXT NOT NULL,
wilayah VARCHAR(120) NOT NULL DEFAULT 'Belum Dipetakan',
alasan TEXT NOT NULL,
status ENUM('menunggu','diverifikasi','disetujui','ditolak','selesai') NOT NULL DEFAULT 'menunggu',
catatan_admin TEXT,
id_keluarga INT,
tanggal_pengajuan DATE NOT NULL,
tanggal_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_pengajuan_keluarga FOREIGN KEY (id_keluarga) REFERENCES keluarga(id_keluarga) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await seedDefaults(); await seedDefaults();
await calculateSaw(); await calculateSaw();
tablesReady = true; tablesReady = true;
@@ -560,9 +586,33 @@ function validateIbadah(body) {
if (!Number.isFinite(Number(body.lat)) || !Number.isFinite(Number(body.lng))) { if (!Number.isFinite(Number(body.lat)) || !Number.isFinite(Number(body.lng))) {
return "Latitude dan longitude wajib valid."; return "Latitude dan longitude wajib valid.";
} }
if (body.kapasitas_bantuan !== undefined && !Number.isFinite(Number(body.kapasitas_bantuan))) {
return "Kapasitas bantuan harus berupa angka.";
}
return null; return null;
} }
function validatePengajuan(body) {
const required = ["nama_pemohon", "nik", "alamat", "alasan", "tanggal_pengajuan"];
for (const key of required) {
if (!body[key] || String(body[key]).trim() === "") {
return `${key} wajib diisi.`;
}
}
return null;
}
// Haversine distance in meters
function haversineMeters(lat1, lng1, lat2, lng2) {
const R = 6371000;
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLng = ((lng2 - lng1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
router.use(async (req, res, next) => { router.use(async (req, res, next) => {
try { try {
await ensureTables(); await ensureTables();
@@ -1000,8 +1050,8 @@ router.post("/ibadah", async (req, res) => {
const body = req.body; const body = req.body;
try { try {
const [result] = await db.execute( const [result] = await db.execute(
"INSERT INTO tempat_ibadah (nama, jenis, alamat, kelurahan, lat, lng) VALUES (?, ?, ?, ?, ?, ?)", "INSERT INTO tempat_ibadah (nama, jenis, alamat, kelurahan, lat, lng, kapasitas_bantuan, keterangan) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
[body.nama, body.jenis, body.alamat, body.kelurahan, Number(body.lat), Number(body.lng)], [body.nama, body.jenis, body.alamat, body.kelurahan, Number(body.lat), Number(body.lng), toNumber(body.kapasitas_bantuan), body.keterangan || null],
); );
res.status(201).json({ success: true, id: result.insertId, message: "Tempat ibadah berhasil ditambahkan." }); res.status(201).json({ success: true, id: result.insertId, message: "Tempat ibadah berhasil ditambahkan." });
} catch (err) { } catch (err) {
@@ -1015,8 +1065,8 @@ router.put("/ibadah/:id", async (req, res) => {
const body = req.body; const body = req.body;
try { try {
const [result] = await db.execute( const [result] = await db.execute(
"UPDATE tempat_ibadah SET nama=?, jenis=?, alamat=?, kelurahan=?, lat=?, lng=? WHERE id=? AND aktif = 1", "UPDATE tempat_ibadah SET nama=?, jenis=?, alamat=?, kelurahan=?, lat=?, lng=?, kapasitas_bantuan=?, keterangan=? WHERE id=? AND aktif = 1",
[body.nama, body.jenis, body.alamat, body.kelurahan, Number(body.lat), Number(body.lng), req.params.id], [body.nama, body.jenis, body.alamat, body.kelurahan, Number(body.lat), Number(body.lng), toNumber(body.kapasitas_bantuan), body.keterangan || null, req.params.id],
); );
if (!result.affectedRows) return res.status(404).json({ success: false, message: "Tempat ibadah tidak ditemukan." }); if (!result.affectedRows) return res.status(404).json({ success: false, message: "Tempat ibadah tidak ditemukan." });
res.json({ success: true, message: "Tempat ibadah berhasil diperbarui." }); res.json({ success: true, message: "Tempat ibadah berhasil diperbarui." });
@@ -1025,6 +1075,104 @@ router.put("/ibadah/:id", async (req, res) => {
} }
}); });
// ─── Nearest Church ─────────────────────────────────────────────────────────
router.get("/nearest-church", async (req, res) => {
try {
const lat = Number(req.query.lat);
const lng = Number(req.query.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
return res.status(400).json({ success: false, message: "lat dan lng wajib valid." });
}
const [rows] = await db.execute("SELECT * FROM tempat_ibadah WHERE aktif = 1 ORDER BY nama ASC");
if (!rows.length) return res.json({ success: true, data: null });
let nearest = null;
let minDist = Infinity;
for (const item of rows) {
const dist = haversineMeters(lat, lng, Number(item.lat), Number(item.lng));
if (dist < minDist) {
minDist = dist;
nearest = { ...item, jarak_meter: Math.round(dist) };
}
}
// Also return all churches sorted by distance
const sorted = rows
.map((item) => ({ ...item, jarak_meter: Math.round(haversineMeters(lat, lng, Number(item.lat), Number(item.lng))) }))
.sort((a, b) => a.jarak_meter - b.jarak_meter);
res.json({ success: true, data: { nearest, all: sorted } });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
});
// ─── Pengajuan Bantuan ───────────────────────────────────────────────────────
router.get("/pengajuan", async (req, res) => {
try {
const status = req.query.status || "";
const sql = status
? "SELECT * FROM pengajuan_bantuan WHERE status = ? ORDER BY created_at DESC"
: "SELECT * FROM pengajuan_bantuan ORDER BY created_at DESC";
const params = status ? [status] : [];
const [rows] = await db.execute(sql, params);
res.json({ success: true, data: rows });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
});
router.get("/pengajuan/:id", async (req, res) => {
try {
const [rows] = await db.execute("SELECT * FROM pengajuan_bantuan WHERE id_pengajuan = ?", [req.params.id]);
if (!rows.length) return res.status(404).json({ success: false, message: "Pengajuan tidak ditemukan." });
res.json({ success: true, data: rows[0] });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
});
router.post("/pengajuan", async (req, res) => {
const error = validatePengajuan(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 pengajuan_bantuan (nama_pemohon, nik, alamat, wilayah, alasan, tanggal_pengajuan)
VALUES (?, ?, ?, ?, ?, ?)`,
[body.nama_pemohon.trim(), body.nik.trim(), body.alamat.trim(), body.wilayah || "Belum Dipetakan", body.alasan.trim(), body.tanggal_pengajuan],
);
res.status(201).json({ success: true, id: result.insertId, message: "Pengajuan berhasil dikirim. Kami akan segera menghubungi Anda." });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
router.put("/pengajuan/:id/status", async (req, res) => {
const { status, catatan_admin, id_keluarga } = req.body;
const validStatuses = ["menunggu", "diverifikasi", "disetujui", "ditolak", "selesai"];
if (!validStatuses.includes(status)) {
return res.status(400).json({ success: false, message: "Status tidak valid." });
}
try {
const [result] = await db.execute(
"UPDATE pengajuan_bantuan SET status=?, catatan_admin=?, id_keluarga=? WHERE id_pengajuan=?",
[status, catatan_admin || null, id_keluarga || null, req.params.id],
);
if (!result.affectedRows) return res.status(404).json({ success: false, message: "Pengajuan tidak ditemukan." });
res.json({ success: true, message: `Status pengajuan diperbarui menjadi '${status}'.` });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
});
router.delete("/pengajuan/:id", async (req, res) => {
try {
const [result] = await db.execute("DELETE FROM pengajuan_bantuan WHERE id_pengajuan = ?", [req.params.id]);
if (!result.affectedRows) return res.status(404).json({ success: false, message: "Pengajuan tidak ditemukan." });
res.json({ success: true, message: "Pengajuan berhasil dihapus." });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
});
router.delete("/ibadah/:id", async (req, res) => { router.delete("/ibadah/:id", async (req, res) => {
try { try {
const [result] = await db.execute("UPDATE tempat_ibadah SET aktif = 0 WHERE id = ?", [req.params.id]); const [result] = await db.execute("UPDATE tempat_ibadah SET aktif = 0 WHERE id = ?", [req.params.id]);
+162 -7
View File
@@ -137,6 +137,12 @@
.butuh { background: #ffedd5; color: #7c2d12; } .butuh { background: #ffedd5; color: #7c2d12; }
.rentan { background: #fef9c3; color: #713f12; } .rentan { background: #fef9c3; color: #713f12; }
.mampu { background: #dcfce7; color: #14532d; } .mampu { background: #dcfce7; color: #14532d; }
/* Status pengajuan */
.status-menunggu { background: #fef3c7; color: #92400e; }
.status-diverifikasi { background: #dbeafe; color: #1e40af; }
.status-disetujui { background: #dcfce7; color: #14532d; }
.status-ditolak { background: #fee2e2; color: #7f1d1d; }
.status-selesai { background: #e0e7ff; color: #3730a3; }
.bar-row { display: grid; grid-template-columns: 150px 1fr 48px; gap: 10px; align-items: center; margin: 10px 0; font-size: .86rem; } .bar-row { display: grid; grid-template-columns: 150px 1fr 48px; gap: 10px; align-items: center; margin: 10px 0; font-size: .86rem; }
.bar-track { height: 12px; border-radius: 99px; background: #e2e8f0; overflow: hidden; } .bar-track { height: 12px; border-radius: 99px; background: #e2e8f0; overflow: hidden; }
.bar-fill { height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--accent), var(--accent-2)); } .bar-fill { height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--accent), var(--accent-2)); }
@@ -203,8 +209,10 @@
<button data-section="bantuan">Data Bantuan</button> <button data-section="bantuan">Data Bantuan</button>
<button data-section="kriteria">Kriteria SAW</button> <button data-section="kriteria">Kriteria SAW</button>
<button data-section="ibadah">⛪ Tempat Ibadah</button> <button data-section="ibadah">⛪ Tempat Ibadah</button>
<button data-section="pengajuan">📋 Pengajuan Bantuan</button>
<button data-section="peta">Preview Peta</button> <button data-section="peta">Preview Peta</button>
<a href="/poverty-map.html">Buka Peta Poverty</a> <a href="/poverty-map.html">Buka Peta Poverty</a>
<a href="/pengajuan.html">Form Pengajuan Publik</a>
<a href="/admin.html">Admin SPBU</a> <a href="/admin.html">Admin SPBU</a>
</nav> </nav>
</aside> </aside>
@@ -343,6 +351,9 @@
<label>Jenis<select id="ibadah_jenis" required></select></label> <label>Jenis<select id="ibadah_jenis" required></select></label>
<label>Alamat<input id="ibadah_alamat" required /></label> <label>Alamat<input id="ibadah_alamat" required /></label>
<label>Kelurahan<input id="ibadah_kelurahan" required /></label> <label>Kelurahan<input id="ibadah_kelurahan" required /></label>
<label>Kapasitas Bantuan (KK)<input id="ibadah_kapasitas" type="number" min="0" value="0" /></label>
<div></div>
<label class="full">Keterangan / Catatan<textarea id="ibadah_keterangan" placeholder="Jadwal bantuan, kontak, dll."></textarea></label>
<div class="full"> <div class="full">
<label>Koordinat dari Peta</label> <label>Koordinat dari Peta</label>
<div id="ibadahMap" class="ibadah-map"></div> <div id="ibadahMap" class="ibadah-map"></div>
@@ -365,13 +376,73 @@
<div class="card-head"><h3>Daftar Tempat Ibadah</h3><span id="ibadahCount">0 data</span></div> <div class="card-head"><h3>Daftar Tempat Ibadah</h3><span id="ibadahCount">0 data</span></div>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead><tr><th>#</th><th>Nama</th><th>Jenis</th><th>Alamat</th><th>Kelurahan</th><th>Koordinat</th><th>Aksi</th></tr></thead> <thead><tr><th>#</th><th>Nama</th><th>Jenis</th><th>Alamat</th><th>Kelurahan</th><th>Koordinat</th><th>Kapasitas</th><th>Aksi</th></tr></thead>
<tbody id="ibadahBody"></tbody> <tbody id="ibadahBody"></tbody>
</table> </table>
</div> </div>
</article> </article>
</section> </section>
<!-- ═══════════════════════ PENGAJUAN BANTUAN ═══════════════════════ -->
<section class="section" id="pengajuan">
<div class="stats" style="grid-template-columns:repeat(3,minmax(0,1fr))">
<article class="stat"><span>Total Pengajuan</span><strong id="statPengajuanTotal">0</strong></article>
<article class="stat"><span>Menunggu</span><strong id="statPengajuanMenunggu">0</strong></article>
<article class="stat"><span>Disetujui</span><strong id="statPengajuanDisetujui">0</strong></article>
</div>
<article class="card" style="margin-top:18px">
<div class="card-head">
<h3>Daftar Pengajuan Bantuan</h3>
<select id="filterPengajuanStatus" style="min-width:160px;border-radius:10px;padding:7px 10px;border:1px solid #dbe2ea;font-size:.82rem">
<option value="">Semua Status</option>
<option value="menunggu">Menunggu</option>
<option value="diverifikasi">Diverifikasi</option>
<option value="disetujui">Disetujui</option>
<option value="ditolak">Ditolak</option>
<option value="selesai">Selesai</option>
</select>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>#</th><th>Pemohon</th><th>NIK</th><th>Wilayah</th><th>Alasan</th><th>Tgl Pengajuan</th><th>Status</th><th>Aksi</th></tr></thead>
<tbody id="pengajuanBody"></tbody>
</table>
</div>
</article>
<!-- Detail / Update Status Modal (inline) -->
<article class="card" id="pengajuanDetailCard" style="display:none;margin-top:18px">
<div class="card-head">
<h3>Update Status Pengajuan</h3>
<button class="btn secondary small" onclick="el('pengajuanDetailCard').style.display='none'">Tutup</button>
</div>
<div class="card-body">
<p id="pengajuanDetailInfo" style="color:#475569;margin:0 0 14px"></p>
<div class="form-grid">
<label>Status Baru
<select id="pengajuanNewStatus">
<option value="menunggu">Menunggu</option>
<option value="diverifikasi">Diverifikasi</option>
<option value="disetujui">Disetujui</option>
<option value="ditolak">Ditolak</option>
<option value="selesai">Selesai</option>
</select>
</label>
<label>Hubungkan ke Keluarga (opsional)
<select id="pengajuanKeluarga"><option value="">— Tidak dihubungkan —</option></select>
</label>
<label class="full">Catatan Admin
<textarea id="pengajuanCatatan" placeholder="Alasan penolakan, catatan verifikasi, dll."></textarea>
</label>
</div>
<input type="hidden" id="pengajuanEditId" />
<div class="actions">
<button class="btn" id="savePengajuanStatus">Simpan Status</button>
</div>
</div>
</article>
</section>
<section class="section" id="peta"> <section class="section" id="peta">
<article class="card"> <article class="card">
<div class="card-head"><h3>Preview Peta Keluarga Prioritas</h3><span>Klik peta untuk mengisi koordinat form keluarga.</span></div> <div class="card-head"><h3>Preview Peta Keluarga Prioritas</h3><span>Klik peta untuk mengisi koordinat form keluarga.</span></div>
@@ -384,7 +455,7 @@
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script> <script>
const API = "/api/poverty"; const API = "/api/poverty";
const state = { keluarga: [], bantuan: [], riwayat: [], kriteria: [], ibadah: [], dashboard: null, analytics: null }; const state = { keluarga: [], bantuan: [], riwayat: [], kriteria: [], ibadah: [], pengajuan: [], dashboard: null, analytics: null };
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
const DEFAULT_CENTER = [-0.0263, 109.3425]; const DEFAULT_CENTER = [-0.0263, 109.3425];
const IBADAH_TYPES = ["Gereja Katolik", "GPIB", "GKI", "HKBP", "Pentakosta"]; const IBADAH_TYPES = ["Gereja Katolik", "GPIB", "GKI", "HKBP", "Pentakosta"];
@@ -469,6 +540,12 @@
function refreshSelects() { function refreshSelects() {
el("riwayat_keluarga").innerHTML = state.keluarga.map((item) => `<option value="${item.id_keluarga}">${esc(item.nama_kepala_keluarga)} - ${esc(item.kategori || "")}</option>`).join(""); el("riwayat_keluarga").innerHTML = state.keluarga.map((item) => `<option value="${item.id_keluarga}">${esc(item.nama_kepala_keluarga)} - ${esc(item.kategori || "")}</option>`).join("");
el("riwayat_bantuan").innerHTML = state.bantuan.map((item) => `<option value="${item.id_bantuan}">${esc(item.jenis_bantuan)} (${rp(item.nilai_bantuan)})</option>`).join(""); el("riwayat_bantuan").innerHTML = state.bantuan.map((item) => `<option value="${item.id_bantuan}">${esc(item.jenis_bantuan)} (${rp(item.nilai_bantuan)})</option>`).join("");
// Populate pengajuan keluarga select (with leading empty option)
const pengajuanSel = el("pengajuanKeluarga");
if (pengajuanSel) {
pengajuanSel.innerHTML = `<option value="">— Tidak dihubungkan —</option>` +
state.keluarga.map((item) => `<option value="${item.id_keluarga}">#${item.ranking||'-'} ${esc(item.nama_kepala_keluarga)} (${esc(item.wilayah)})</option>`).join("");
}
} }
function renderBantuan() { function renderBantuan() {
@@ -497,9 +574,37 @@
<td>${esc(item.alamat)}</td> <td>${esc(item.alamat)}</td>
<td>${esc(item.kelurahan)}</td> <td>${esc(item.kelurahan)}</td>
<td>${Number(item.lat).toFixed(6)}, ${Number(item.lng).toFixed(6)}</td> <td>${Number(item.lat).toFixed(6)}, ${Number(item.lng).toFixed(6)}</td>
<td>${item.kapasitas_bantuan || 0} KK</td>
<td><button class="btn secondary small" onclick="editIbadah(${item.id})">Edit</button> <button class="btn danger small" onclick="hapus('ibadah', ${item.id})">Hapus</button></td> <td><button class="btn secondary small" onclick="editIbadah(${item.id})">Edit</button> <button class="btn danger small" onclick="hapus('ibadah', ${item.id})">Hapus</button></td>
</tr> </tr>
`).join("") || `<tr><td colspan="7">Belum ada data tempat ibadah.</td></tr>`; `).join("") || `<tr><td colspan="8">Belum ada data tempat ibadah.</td></tr>`;
}
function pengajuanBadgeClass(status) {
return `status-${status || 'menunggu'}`;
}
function renderPengajuan() {
const rows = state.pengajuan;
el("statPengajuanTotal").textContent = rows.length;
el("statPengajuanMenunggu").textContent = rows.filter(r => r.status === "menunggu").length;
el("statPengajuanDisetujui").textContent = rows.filter(r => r.status === "disetujui").length;
el("pengajuanBody").innerHTML = rows.map((item, idx) => `
<tr>
<td>${idx + 1}</td>
<td><strong>${esc(item.nama_pemohon)}</strong></td>
<td>${esc(item.nik)}</td>
<td>${esc(item.wilayah)}</td>
<td style="max-width:200px;white-space:normal;font-size:.82rem">${esc(item.alasan)}</td>
<td>${fmt(item.tanggal_pengajuan)}</td>
<td><span class="badge ${pengajuanBadgeClass(item.status)}">${esc(item.status)}</span></td>
<td>
<button class="btn secondary small" onclick="openPengajuanEdit(${item.id_pengajuan})">Review</button>
<button class="btn danger small" onclick="hapusPengajuan(${item.id_pengajuan})">Hapus</button>
</td>
</tr>
`).join("") || `<tr><td colspan="8">Belum ada pengajuan.</td></tr>`;
} }
let ibadahMap, ibadahMarker, ibadahManual = false; let ibadahMap, ibadahMarker, ibadahManual = false;
@@ -565,6 +670,8 @@
kelurahan: el("ibadah_kelurahan").value.trim(), kelurahan: el("ibadah_kelurahan").value.trim(),
lat: Number(el("ibadah_lat").value), lat: Number(el("ibadah_lat").value),
lng: Number(el("ibadah_lng").value), lng: Number(el("ibadah_lng").value),
kapasitas_bantuan: Number(el("ibadah_kapasitas").value) || 0,
keterangan: el("ibadah_keterangan").value.trim() || null,
}; };
} }
@@ -594,11 +701,12 @@
} }
async function loadAll() { async function loadAll() {
const [dashboard, analytics, keluarga, bantuan, riwayat, kriteria, ibadah] = await Promise.all([ const [dashboard, analytics, keluarga, bantuan, riwayat, kriteria, ibadah, pengajuan] = await Promise.all([
api("/dashboard"), api("/analytics"), api("/keluarga"), api("/bantuan"), api("/riwayat-bantuan"), api("/kriteria"), api("/ibadah") api("/dashboard"), api("/analytics"), api("/keluarga"), api("/bantuan"),
api("/riwayat-bantuan"), api("/kriteria"), api("/ibadah"), api("/pengajuan")
]); ]);
Object.assign(state, { dashboard, analytics, keluarga, bantuan, riwayat, kriteria, ibadah }); Object.assign(state, { dashboard, analytics, keluarga, bantuan, riwayat, kriteria, ibadah, pengajuan });
renderDashboard(); renderKeluarga(); renderBantuan(); renderKriteria(); renderIbadah(); renderDashboard(); renderKeluarga(); renderBantuan(); renderKriteria(); renderIbadah(); renderPengajuan();
} }
function familyPayload() { function familyPayload() {
@@ -634,6 +742,8 @@
function resetIbadah() { function resetIbadah() {
el("ibadahForm").reset(); el("ibadahForm").reset();
el("id_ibadah").value = ""; el("id_ibadah").value = "";
el("ibadah_kapasitas").value = 0;
el("ibadah_keterangan").value = "";
el("ibadah_lat").readOnly = true; el("ibadah_lat").readOnly = true;
el("ibadah_lng").readOnly = true; el("ibadah_lng").readOnly = true;
ibadahManual = false; ibadahManual = false;
@@ -675,6 +785,8 @@
el("ibadah_jenis").value = item.jenis; el("ibadah_jenis").value = item.jenis;
el("ibadah_alamat").value = item.alamat; el("ibadah_alamat").value = item.alamat;
el("ibadah_kelurahan").value = item.kelurahan; el("ibadah_kelurahan").value = item.kelurahan;
el("ibadah_kapasitas").value = item.kapasitas_bantuan || 0;
el("ibadah_keterangan").value = item.keterangan || "";
el("ibadahSubmit").textContent = "Update Tempat Ibadah"; el("ibadahSubmit").textContent = "Update Tempat Ibadah";
el("cancelIbadahEdit").style.display = "inline-flex"; el("cancelIbadahEdit").style.display = "inline-flex";
setTimeout(() => { setTimeout(() => {
@@ -682,6 +794,27 @@
el("ibadah").scrollIntoView({ behavior: "smooth", block: "start" }); el("ibadah").scrollIntoView({ behavior: "smooth", block: "start" });
}, 100); }, 100);
}; };
window.openPengajuanEdit = (id) => {
const item = state.pengajuan.find(r => r.id_pengajuan === id);
if (!item) return;
document.querySelector('[data-section="pengajuan"]').click();
el("pengajuanEditId").value = id;
el("pengajuanNewStatus").value = item.status;
el("pengajuanCatatan").value = item.catatan_admin || "";
el("pengajuanKeluarga").value = item.id_keluarga || "";
el("pengajuanDetailInfo").textContent =
`Pemohon: ${item.nama_pemohon} | NIK: ${item.nik} | Wilayah: ${item.wilayah} | Tgl: ${String(item.tanggal_pengajuan||'').slice(0,10)}`;
el("pengajuanDetailCard").style.display = "block";
el("pengajuanDetailCard").scrollIntoView({ behavior: "smooth" });
};
window.hapusPengajuan = async (id) => {
if (!confirm("Hapus pengajuan ini?")) return;
await api(`/pengajuan/${id}`, { method: "DELETE" });
toast("Pengajuan berhasil dihapus.");
await loadAll();
};
window.hapus = async (resource, id) => { window.hapus = async (resource, id) => {
if (!confirm("Hapus/nonaktifkan data ini?")) return; if (!confirm("Hapus/nonaktifkan data ini?")) return;
await api(`/${resource}/${id}`, { method: "DELETE" }); await api(`/${resource}/${id}`, { method: "DELETE" });
@@ -757,6 +890,28 @@
el("familySearch").addEventListener("input", renderKeluarga); el("familySearch").addEventListener("input", renderKeluarga);
el("recalculateBtn").addEventListener("click", async () => { await api("/recalculate", { method: "POST" }); toast("SAW berhasil dihitung ulang."); await loadAll(); }); el("recalculateBtn").addEventListener("click", async () => { await api("/recalculate", { method: "POST" }); toast("SAW berhasil dihitung ulang."); await loadAll(); });
el("filterPengajuanStatus").addEventListener("change", async () => {
const status = el("filterPengajuanStatus").value;
state.pengajuan = await api(`/pengajuan${status ? '?status='+status : ''}`);
renderPengajuan();
});
el("savePengajuanStatus").addEventListener("click", async () => {
const id = el("pengajuanEditId").value;
if (!id) return;
await api(`/pengajuan/${id}/status`, {
method: "PUT",
body: JSON.stringify({
status: el("pengajuanNewStatus").value,
catatan_admin: el("pengajuanCatatan").value.trim(),
id_keluarga: el("pengajuanKeluarga").value || null,
}),
});
toast("Status pengajuan berhasil diperbarui.");
el("pengajuanDetailCard").style.display = "none";
await loadAll();
});
fillOptions("pekerjaan", labels.pekerjaan); fillOptions("pekerjaan", labels.pekerjaan);
fillOptions("pendidikan", labels.pendidikan); fillOptions("pendidikan", labels.pendidikan);
fillOptions("kondisi_rumah", labels.kondisi_rumah); fillOptions("kondisi_rumah", labels.kondisi_rumah);
+457
View File
@@ -0,0 +1,457 @@
<!doctype html>
<html lang="id">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ajukan Bantuan Sosial WebGIS Gereja Peduli</title>
<meta name="description" content="Form pengajuan bantuan sosial dari gereja. Isi data diri Anda dan tim kami akan segera memproses permohonan Anda." />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet" />
<style>
:root {
--accent: #7c3aed;
--accent2: #06b6d4;
--dark: #0f172a;
--panel: #111827;
--border: rgba(148,163,184,.22);
--shadow: 0 24px 70px rgba(15,23,42,.24);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: "Inter", system-ui, sans-serif;
background:
radial-gradient(circle at top left, rgba(124,58,237,.32), transparent 36%),
linear-gradient(135deg, #130f2e 0%, #111827 50%, #312e81 100%);
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 16px 64px;
}
a { color: inherit; text-decoration: none; }
button, input, select, textarea { font: inherit; }
/* ── Hero ── */
.hero {
text-align: center;
color: white;
max-width: 600px;
margin-bottom: 36px;
}
.hero small {
display: block;
font-size: .78rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: .1em;
color: #a5b4fc;
margin-bottom: 12px;
}
.hero h1 {
margin: 0 0 14px;
font-size: clamp(1.8rem, 5vw, 2.8rem);
line-height: 1.1;
background: linear-gradient(135deg, #e0e7ff, #c4b5fd, #93c5fd);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.hero p {
margin: 0;
color: #cbd5e1;
line-height: 1.6;
font-size: .95rem;
}
/* ── Status badges ── */
.status-flow {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
margin: 22px 0 0;
}
.flow-step {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,.15);
background: rgba(255,255,255,.07);
color: #e5e7eb;
font-size: .78rem;
font-weight: 700;
}
/* ── Card ── */
.card {
width: 100%;
max-width: 680px;
background: rgba(255,255,255,.96);
border-radius: 28px;
box-shadow: var(--shadow);
overflow: hidden;
}
.card-head {
padding: 24px 28px 20px;
border-bottom: 1px solid #e5e7eb;
background: linear-gradient(135deg, #f8fafc, #f0f4ff);
}
.card-head h2 {
margin: 0 0 4px;
font-size: 1.22rem;
color: #1e1b4b;
}
.card-head p {
margin: 0;
color: #64748b;
font-size: .86rem;
line-height: 1.5;
}
.card-body { padding: 26px 28px; }
/* ── Form ── */
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.full { grid-column: 1 / -1; }
label {
display: grid;
gap: 6px;
color: #334155;
font-size: .82rem;
font-weight: 800;
}
input[type="text"],
input[type="date"],
input[type="tel"],
select,
textarea {
width: 100%;
border: 1px solid #dbe2ea;
border-radius: 14px;
padding: 11px 13px;
outline: none;
color: #1e293b;
background: #f8fafc;
transition: border-color .15s, box-shadow .15s;
font-size: .9rem;
}
input:focus, select:focus, textarea:focus {
border-color: #a78bfa;
box-shadow: 0 0 0 4px rgba(124,58,237,.1);
background: #fff;
}
textarea { min-height: 100px; resize: vertical; }
/* ── Required mark ── */
.req { color: #dc2626; }
/* ── Divider ── */
.divider {
grid-column: 1/-1;
border: 0;
border-top: 1px solid #e2e8f0;
margin: 6px 0;
}
/* ── Info box ── */
.info-box {
grid-column: 1/-1;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 14px;
padding: 14px 16px;
color: #1e3a8a;
font-size: .84rem;
line-height: 1.5;
}
.info-box strong { display: block; margin-bottom: 4px; }
/* ── Submit ── */
.btn-submit {
width: 100%;
margin-top: 18px;
border: 0;
border-radius: 16px;
padding: 15px;
background: linear-gradient(135deg, var(--accent), var(--accent2));
color: white;
font-weight: 800;
font-size: 1rem;
cursor: pointer;
box-shadow: 0 14px 32px rgba(124,58,237,.3);
transition: transform .15s, box-shadow .15s;
}
.btn-submit:hover { transform: translateY(-2px); box-shadow: 0 18px 38px rgba(124,58,237,.4); }
.btn-submit:disabled { opacity: .6; cursor: not-allowed; transform: none; }
/* ── Success ── */
.success-screen {
display: none;
text-align: center;
padding: 40px 28px;
animation: fadeUp .4s ease forwards;
}
.success-icon {
font-size: 4rem;
margin-bottom: 18px;
display: block;
}
.success-screen h2 { margin: 0 0 10px; color: #14532d; font-size: 1.5rem; }
.success-screen p { margin: 0 0 8px; color: #475569; line-height: 1.5; }
.ref-num { font-weight: 800; color: var(--accent); font-size: 1.1rem; }
.btn-back {
display: inline-block;
margin-top: 20px;
padding: 11px 22px;
border-radius: 14px;
background: var(--accent);
color: white;
font-weight: 700;
font-size: .88rem;
cursor: pointer;
border: 0;
transition: .15s;
}
.btn-back:hover { background: #6d28d9; }
/* ── Toast ── */
.toast {
position: fixed;
bottom: 22px;
right: 22px;
background: #0f172a;
color: white;
padding: 12px 16px;
border-radius: 13px;
box-shadow: var(--shadow);
opacity: 0;
transform: translateY(10px);
pointer-events: none;
transition: .22s ease;
z-index: 1000;
max-width: 320px;
font-size: .88rem;
}
.toast.show { opacity: 1; transform: translateY(0); }
/* ── Footer links ── */
.footer-links {
display: flex;
gap: 14px;
justify-content: center;
flex-wrap: wrap;
margin-top: 28px;
}
.footer-links a {
color: rgba(255,255,255,.6);
font-size: .82rem;
font-weight: 600;
transition: color .15s;
}
.footer-links a:hover { color: #fff; }
@keyframes fadeUp {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 580px) {
.form-grid { grid-template-columns: 1fr; }
.card-head, .card-body { padding: 20px; }
}
</style>
</head>
<body>
<div class="toast" id="toast"></div>
<!-- Hero -->
<div class="hero">
<small>⛪ Gereja Peduli — Program Bantuan Sosial</small>
<h1>Ajukan Bantuan Sosial</h1>
<p>Isi formulir berikut untuk mengajukan permohonan bantuan. Tim kami akan memverifikasi dan menghubungi Anda secepatnya.</p>
<div class="status-flow">
<div class="flow-step">📋 Ajukan</div>
<div class="flow-step">🔍 Verifikasi</div>
<div class="flow-step">✅ Disetujui</div>
<div class="flow-step">🤝 Tersalurkan</div>
</div>
</div>
<!-- Form Card -->
<div class="card" id="formCard">
<div class="card-head">
<h2>Formulir Pengajuan Bantuan</h2>
<p>Semua data akan dijaga kerahasiaannya dan digunakan hanya untuk keperluan verifikasi bantuan.</p>
</div>
<div class="card-body">
<form id="pengajuanForm">
<div class="form-grid">
<div class="info-box">
<strong>📌 Petunjuk Pengisian</strong>
Isi semua kolom bertanda <span class="req">*</span> dengan data yang benar dan sesuai KTP.
Pengajuan akan diproses dalam 37 hari kerja.
</div>
<hr class="divider" />
<!-- Data Diri -->
<label class="full">Nama Lengkap <span class="req">*</span>
<input type="text" id="nama_pemohon" placeholder="Sesuai Kartu Tanda Penduduk" required />
</label>
<label>NIK (16 digit) <span class="req">*</span>
<input type="tel" id="nik" placeholder="1234567890123456" maxlength="16" pattern="[0-9]{16}" required />
</label>
<label>Tanggal Pengajuan <span class="req">*</span>
<input type="date" id="tanggal_pengajuan" required />
</label>
<label class="full">Alamat Lengkap <span class="req">*</span>
<textarea id="alamat" placeholder="Nama jalan, nomor rumah, RT/RW…" required></textarea>
</label>
<label>Wilayah / Paroki <span class="req">*</span>
<input type="text" id="wilayah" placeholder="Contoh: Paroki Katedral" required />
</label>
<label>No. Telepon / WhatsApp
<input type="tel" id="telepon" placeholder="08xxxxxxxxxx" />
</label>
<hr class="divider" />
<!-- Kondisi -->
<label class="full">Alasan & Kondisi Ekonomi Saat Ini <span class="req">*</span>
<textarea id="alasan" rows="5"
placeholder="Jelaskan kondisi ekonomi Anda saat ini, jenis bantuan yang dibutuhkan (pangan, pendidikan, kesehatan, dll.), dan alasan mengajukan permohonan ini…"
required></textarea>
</label>
<label>Pekerjaan Saat Ini
<select id="pekerjaan">
<option value="">— Pilih Pekerjaan —</option>
<option value="pengangguran">Tidak Bekerja / Pengangguran</option>
<option value="buruh_harian">Buruh Harian</option>
<option value="petani">Petani</option>
<option value="nelayan">Nelayan</option>
<option value="pedagang_kecil">Pedagang Kecil</option>
<option value="karyawan">Karyawan</option>
<option value="wiraswasta">Wiraswasta</option>
<option value="lainnya">Lainnya</option>
</select>
</label>
<label>Jumlah Anggota Keluarga
<input type="tel" id="jumlah_anggota" placeholder="Contoh: 5" pattern="[0-9]+" />
</label>
<div class="info-box full" style="background:#f0fdf4;border-color:#bbf7d0;color:#14532d">
<strong>🔒 Privasi &amp; Keamanan Data</strong>
Data Anda hanya digunakan untuk keperluan verifikasi bantuan sosial.
Kami tidak akan membagikan informasi Anda kepada pihak lain tanpa persetujuan.
</div>
<label class="full" style="flex-direction:row;align-items:flex-start;gap:10px;cursor:pointer">
<input type="checkbox" id="setuju" required style="width:auto;margin-top:2px" />
<span style="font-weight:600;color:#334155">
Saya menyatakan bahwa data yang saya isi adalah benar dan saya bertanggung jawab atas kebenaran informasi tersebut. <span class="req">*</span>
</span>
</label>
</div>
<button type="submit" class="btn-submit" id="submitBtn">
📋 Kirim Permohonan Bantuan
</button>
</form>
<!-- Success Screen -->
<div class="success-screen" id="successScreen">
<span class="success-icon">🎉</span>
<h2>Permohonan Terkirim!</h2>
<p>Nomor referensi pengajuan Anda:</p>
<p class="ref-num" id="refNum">#000</p>
<p style="margin-top:12px">Tim kami akan segera memverifikasi permohonan Anda dan menghubungi Anda melalui nomor telepon yang tercantum.</p>
<p style="margin-top:8px;color:#94a3b8;font-size:.82rem">Proses verifikasi biasanya memakan waktu 37 hari kerja.</p>
<button class="btn-back" onclick="resetForm()">📋 Ajukan Lagi</button>
</div>
</div>
</div>
<!-- Footer links -->
<div class="footer-links">
<a href="/poverty-map.html">🗺️ Lihat Peta Distribusi</a>
<a href="/admin-poverty.html">⚙️ Admin</a>
<a href="/">⛽ WebGIS SPBU</a>
</div>
<script>
const API = "/api/poverty";
const el = (id) => document.getElementById(id);
// Set today's date as default
el("tanggal_pengajuan").value = new Date().toISOString().slice(0, 10);
let toastTimer;
function toast(msg, dur = 3000) {
el("toast").textContent = msg;
el("toast").classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el("toast").classList.remove("show"), dur);
}
function resetForm() {
el("pengajuanForm").reset();
el("tanggal_pengajuan").value = new Date().toISOString().slice(0, 10);
el("pengajuanForm").style.display = "block";
el("successScreen").style.display = "none";
el("submitBtn").style.display = "block";
}
el("pengajuanForm").addEventListener("submit", async (e) => {
e.preventDefault();
const btn = el("submitBtn");
btn.disabled = true;
btn.textContent = "⏳ Mengirim…";
const payload = {
nama_pemohon: el("nama_pemohon").value.trim(),
nik: el("nik").value.trim(),
alamat: el("alamat").value.trim(),
wilayah: el("wilayah").value.trim() || "Belum Dipetakan",
alasan: el("alasan").value.trim(),
tanggal_pengajuan: el("tanggal_pengajuan").value,
};
// Basic NIK validation
if (!/^\d{16}$/.test(payload.nik)) {
toast("⚠️ NIK harus 16 digit angka.");
btn.disabled = false;
btn.textContent = "📋 Kirim Permohonan Bantuan";
return;
}
try {
const res = await fetch(`${API}/pengajuan`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok || data.success === false) throw new Error(data.message || "Pengajuan gagal dikirim.");
// Show success
el("refNum").textContent = `#${String(data.id).padStart(4, "0")}`;
el("pengajuanForm").style.display = "none";
el("submitBtn").style.display = "none";
el("successScreen").style.display = "block";
} catch (err) {
toast(`${err.message}`);
btn.disabled = false;
btn.textContent = "📋 Kirim Permohonan Bantuan";
}
});
</script>
</body>
</html>
+626 -192
View File
File diff suppressed because it is too large Load Diff