update besar besaran

This commit is contained in:
TigerStable
2026-06-12 16:36:31 +07:00
parent 515c830342
commit 017fe392bc
63 changed files with 4219 additions and 75 deletions
+71
View File
@@ -0,0 +1,71 @@
/* ═══════════════════════════════════════════════════════════
14. APP ROOT
═══════════════════════════════════════════════════════════ */
const App = {
state:{ activeLayer:'spbu' },
_groups:{},
async init(){
// Map
this._map=L.map('map',{zoomControl:false}).setView([-0.056031,109.348641],13);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{
attribution:'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',maxZoom:19
}).addTo(this._map);
L.control.zoom({position:'topright'}).addTo(this._map);
['spbu','jalan','parsil'].forEach(k=>{
this._groups[k]=L.layerGroup().addTo(this._map);
});
// Expose map for Draw
App.map={
instance: this._map,
zoomTo:(ln,id)=>{
const r=Store.find(ln,id); if(!r) return;
if(r.lat!=null) this._map.setView([r.lat,r.lng],17);
else if(r.coords?.length) try{this._map.fitBounds(L.latLngBounds(r.coords),{padding:[40,40]})}catch{}
highlightCard(id);
}
};
Draw.init(this._map);
// Nav
document.getElementById('nav-pills').addEventListener('click',e=>{
const pill=e.target.closest('.nav-pill'); if(!pill) return;
Draw.cancel();
this.state.activeLayer=pill.dataset.layer;
document.querySelectorAll('.nav-pill').forEach(p=>p.classList.remove('active'));
pill.classList.add('active');
renderSidebar(this.state.activeLayer);
renderLegend(this.state.activeLayer);
});
// Add btn
document.getElementById('btn-add').addEventListener('click',()=>Draw.start(this.state.activeLayer));
// Modal
document.getElementById('modal-save').addEventListener('click', Modal.submit);
document.getElementById('modal-cancel').addEventListener('click', Modal.close);
document.getElementById('modal-close').addEventListener('click', Modal.close);
document.getElementById('modal-ov').addEventListener('click',e=>{ if(e.target===e.currentTarget) Modal.close(); });
await Store.load();
this.refresh();
},
crud:{
edit(ln,id){ App.state.activeLayer=ln; Modal.openEdit(ln,id); },
async delete(ln,id){
if(!confirm('Yakin hapus data ini?')) return;
await Store.remove(ln,id); App._map.closePopup();
App.refresh(); Toast.info('Data dihapus');
},
},
refresh(){
renderMap(); renderSidebar(this.state.activeLayer);
renderLegend(this.state.activeLayer); updateBadges();
},
};
document.addEventListener('DOMContentLoaded',()=>App.init());
+63
View File
@@ -0,0 +1,63 @@
/* ═══════════════════════════════════════════════════════════
7. CARD BUILDERS (sidebar)
═══════════════════════════════════════════════════════════ */
const Card = {
_acts:(ln,id)=>`<div class="dcard-acts">
<button class="bsm bsm-zoom" onclick="App.map.zoomTo('${ln}','${id}')">Zoom</button>
<button class="bsm bsm-edit" onclick="App.crud.edit('${ln}','${id}')">Edit</button>
<button class="bsm bsm-del" onclick="App.crud.delete('${ln}','${id}')">Hapus</button></div>`,
spbu(r){ const clr=CFG.spbu.color(r),bdg=CFG.spbu.badge(r);
return `<div class="dcard" id="card-${r.id}">
<div class="dcard-head" onclick="App.map.zoomTo('spbu','${r.id}')">
<span class="dcard-sym circle" style="background:${clr}"></span>
<span class="dcard-name">${H.esc(r.nama)}</span><span class="badge ${bdg.c}">${bdg.t}</span></div>
<div class="dcard-attrs">
<div class="attr"><span class="attr-k">WA</span><span class="attr-v">${H.esc(r.wa||'-')}</span></div>
<div class="attr"><span class="attr-k">BBM</span><span class="attr-v">${H.esc(r.jenis_bbm||'-')}</span></div>
</div>${this._acts('spbu',r.id)}</div>`; },
jalan(r){ const clr=CFG.jalan.color(r),bdg=CFG.jalan.badge(r);
return `<div class="dcard" id="card-${r.id}">
<div class="dcard-head" onclick="App.map.zoomTo('jalan','${r.id}')">
<span class="dcard-sym line" style="background:${clr}"></span>
<span class="dcard-name">${H.esc(r.nama)}</span><span class="badge ${bdg.c}">${bdg.t}</span></div>
<div class="dcard-attrs">
<div class="attr"><span class="attr-k">Panjang</span><span class="attr-v">${H.fmDist(r.panjang)}</span></div>
<div class="attr"><span class="attr-k">Kondisi</span><span class="attr-v">${H.esc(r.kondisi||'-')}</span></div>
</div>${this._acts('jalan',r.id)}</div>`; },
parsil(r){ const clr=CFG.parsil.color(r),bdg=CFG.parsil.badge(r);
return `<div class="dcard" id="card-${r.id}">
<div class="dcard-head" onclick="App.map.zoomTo('parsil','${r.id}')">
<span class="dcard-sym poly" style="color:${clr};height:9px"></span>
<span class="dcard-name">${H.esc(r.nama)}</span><span class="badge ${bdg.c}">${bdg.t}</span></div>
<div class="dcard-attrs">
<div class="attr"><span class="attr-k">Luas</span><span class="attr-v">${H.fmArea(r.luas)}</span></div>
<div class="attr"><span class="attr-k">Sengketa</span><span class="attr-v">${H.esc(r.sengketa||'-')}</span></div>
</div>${this._acts('parsil',r.id)}</div>`; },
warga(r){ const clr=CFG.warga.color(r),bdg=CFG.warga.badge(r);
const pendapatan=r.pendapatan?'Rp '+parseInt(r.pendapatan).toLocaleString('id-ID'):'-';
const histCount=r.history_bansos?.length||0;
return `<div class="dcard" id="card-${r.id}">
<div class="dcard-head" onclick="App.map.zoomTo('warga','${r.id}')">
<span class="dcard-sym circle" style="background:${clr}"></span>
<span class="dcard-name">${H.esc(r.nama)}</span><span class="badge ${bdg.c}">${bdg.t}</span></div>
<div class="dcard-attrs">
<div class="attr"><span class="attr-k">Anggota</span><span class="attr-v">${r.jml_anggota||'-'} jiwa</span></div>
<div class="attr"><span class="attr-k">Pendapatan</span><span class="attr-v">${pendapatan}</span></div>
<div class="attr"><span class="attr-k">Bansos</span><span class="attr-v">${H.esc(r.bansos||'-')}</span></div>
<div class="attr"><span class="attr-k">Riwayat</span><span class="attr-v">${histCount} program</span></div>
</div>${this._acts('warga',r.id)}</div>`; },
rumahIbadah(r){ const clr=CFG.rumahIbadah.color(r),bdg=CFG.rumahIbadah.badge(r);
return `<div class="dcard" id="card-${r.id}">
<div class="dcard-head" onclick="App.map.zoomTo('rumahIbadah','${r.id}')">
<span class="dcard-sym circle" style="background:${clr}"></span>
<span class="dcard-name">${H.esc(r.nama)}</span><span class="badge ${bdg.c}">${bdg.t}</span></div>
<div class="dcard-attrs">
<div class="attr"><span class="attr-k">Ketua</span><span class="attr-v">${H.esc(r.ketua||'-')}</span></div>
<div class="attr"><span class="attr-k">Binaan</span><span class="attr-v">${r.jml_binaan||'-'} KK</span></div>
</div>${this._acts('rumahIbadah',r.id)}</div>`; },
};
+361
View File
@@ -0,0 +1,361 @@
/* ═══════════════════════════════════════════════════════════
5. FORM BUILDERS
═══════════════════════════════════════════════════════════ */
const Forms = {
spbu(rec, latlng){
const lat=latlng?latlng.lat.toFixed(6):(rec?.lat?.toFixed(6)||'');
const lng=latlng?latlng.lng.toFixed(6):(rec?.lng?.toFixed(6)||'');
const v=f=>rec?H.esc(rec[f]||''):'';
return `
<div class="info-box">📍 Koordinat: <strong>${lat}, ${lng}</strong></div>
<div class="form-sect">Identitas & Operasional</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Nama SPBU<span class="req">*</span></label>
<input class="finput" id="f-nama" placeholder="cth: SPBU 64.754.01" value="${v('nama')}"/></div>
<div class="fg"><label class="flabel">No. WhatsApp</label>
<input class="finput" id="f-wa" placeholder="081234..." value="${v('wa')}"/></div>
</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Jenis BBM Tersedia</label>
<select class="finput" id="f-jenis_bbm">
${['Pertalite & Pertamax','Pertalite Saja','Pertamax Series','Solar & Pertamina Dex','Semua Jenis'].map(j=>`<option ${v('jenis_bbm')===j?'selected':''}>${j}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Buka 24 Jam?</label>
<div class="rtoggle" id="rtog">
<label class="ropt ${rec?.buka24!==false?'yes':''}" data-v="true">
<input type="radio" name="buka24" value="true" ${rec?.buka24!==false?'checked':''}/>✓ Ya
</label>
<label class="ropt ${rec?.buka24===false?'no':''}" data-v="false">
<input type="radio" name="buka24" value="false" ${rec?.buka24===false?'checked':''}/>✗ Tidak
</label>
</div></div>
</div>
<div class="form-sect">Aksesibilitas & Sosial</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Pelayanan Disabilitas</label>
<select class="finput" id="f-disabilitas">
${['Ada','Tidak Ada','Dalam Proses'].map(j=>`<option ${v('disabilitas')===j?'selected':''}>${j}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Harga BBM Subsidi</label>
<select class="finput" id="f-harga_bbm">
${['Sesuai HET','Di Atas HET','Tidak Menjual Subsidi'].map(j=>`<option ${v('harga_bbm')===j?'selected':''}>${j}</option>`).join('')}
</select></div>
</div>
<div class="form-sect">Catatan Lapangan</div>
<div class="fg"><label class="flabel">Catatan / Keterangan Tambahan</label>
<textarea class="finput" id="f-catatan" placeholder="Kondisi antrean, ketersediaan stok, dll...">${v('catatan')}</textarea></div>`;
},
jalan(rec, len){
const pj=len??rec?.panjang??0; const v=f=>rec?H.esc(rec[f]||''):'';
return `
<div class="info-box">📏 Panjang terukur otomatis: <strong>${H.fmDist(pj)}</strong></div>
<input type="hidden" id="f-panjang" value="${pj}"/>
<div class="form-sect">Identitas Ruas Jalan</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Nama Ruas Jalan<span class="req">*</span></label>
<input class="finput" id="f-nama" placeholder="cth: Jl. H. Rais A Rahman" value="${v('nama')}"/></div>
<div class="fg"><label class="flabel">Status Jalan<span class="req">*</span></label>
<select class="finput" id="f-status">
${CFG.jalan.statuses.map(s=>`<option ${v('status')===s?'selected':''}>${s}</option>`).join('')}
</select></div>
</div>
<div class="form-sect">Kondisi & Infrastruktur</div>
<div class="form-row cols3">
<div class="fg"><label class="flabel">Kondisi Jalan</label>
<select class="finput" id="f-kondisi">
${CFG.jalan.kondisi.map(k=>`<option ${v('kondisi')===k?'selected':''}>${k}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Tahun Perbaikan Terakhir</label>
<input class="finput" id="f-tahun_perbaikan" type="number" min="2000" max="2030" placeholder="2023" value="${v('tahun_perbaikan')}"/></div>
<div class="fg"><label class="flabel">Lebar Jalan (meter)</label>
<input class="finput" id="f-lebar" type="number" min="1" max="50" placeholder="4" value="${v('lebar')}"/></div>
</div>
<div class="form-sect">Dampak Sosial-Ekonomi</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Dilalui Angkutan Umum</label>
<select class="finput" id="f-angkutan">
${['Ya','Tidak'].map(k=>`<option ${v('angkutan')===k?'selected':''}>${k}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Akses ke Fasilitas Publik</label>
<select class="finput" id="f-akses_publik">
${['Pasar','RS/Puskesmas','Sekolah','Kantor Pemerintah','Tidak Ada'].map(k=>`<option ${v('akses_publik')===k?'selected':''}>${k}</option>`).join('')}
</select></div>
</div>
<div class="fg"><label class="flabel">Catatan Kondisi</label>
<textarea class="finput" id="f-catatan" placeholder="Kondisi berlubang, banjir saat hujan, dll...">${v('catatan')}</textarea></div>`;
},
parsil(rec, area){
const ls=area??rec?.luas??0; const v=f=>rec?H.esc(rec[f]||''):'';
return `
<div class="info-box">📐 Luas terukur otomatis: <strong>${H.fmArea(ls)}</strong></div>
<input type="hidden" id="f-luas" value="${ls}"/>
<div class="form-sect">Identitas Parsil</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Nama / ID Parsil<span class="req">*</span></label>
<input class="finput" id="f-nama" placeholder="cth: Kavling A-01" value="${v('nama')}"/></div>
<div class="fg"><label class="flabel">Nomor Induk Bidang (NIB)</label>
<input class="finput" id="f-nib" placeholder="14.02.01.001" value="${v('nib')}"/></div>
</div>
<div class="form-sect">Status Kepemilikan</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Status Sertifikat<span class="req">*</span></label>
<select class="finput" id="f-status">
${CFG.parsil.statuses.map(s=>`<option ${v('status')===s?'selected':''}>${s}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Sengketa / Masalah Hukum</label>
<select class="finput" id="f-sengketa">
${['Tidak Ada','Ada Sengketa','Dalam Proses PTSL','Klaim Pihak Lain'].map(k=>`<option ${v('sengketa')===k?'selected':''}>${k}</option>`).join('')}
</select></div>
</div>
<div class="form-sect">Penilaian & Pemanfaatan</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">NJOP (Rp/m²)</label>
<input class="finput" id="f-njop" type="number" placeholder="500000" value="${v('njop')}"/></div>
<div class="fg"><label class="flabel">Penggunaan Lahan</label>
<select class="finput" id="f-penggunaan">
${['Rumah Tinggal','Lahan Kosong','Pertanian','Usaha/Warung','Fasilitas Umum','Lainnya'].map(k=>`<option ${v('penggunaan')===k?'selected':''}>${k}</option>`).join('')}
</select></div>
</div>
<div class="fg"><label class="flabel">Catatan Tambahan</label>
<textarea class="finput" id="f-catatan" placeholder="Kondisi, penghuni, riwayat...">${v('catatan')}</textarea></div>`;
},
warga(rec, latlng){
const lat=latlng?latlng.lat.toFixed(6):(rec?.lat?.toFixed(6)||'');
const lng=latlng?latlng.lng.toFixed(6):(rec?.lng?.toFixed(6)||'');
const v=f=>rec?H.esc(rec[f]||''):'';
const c=v=>rec?.penyakit?.includes(v)?'checked':'';
const hist=rec?.history_bansos||[];
const ri = Store.all('rumahIbadah');
const hubOpts = ['Belum ditugaskan', ...ri.map(r=>r.nama)].map(opt=>`<option ${v('hub_ri')===opt?'selected':''}>${opt}</option>`).join('');
return `
<div class="info-box warn">⚠️ Data warga bersifat sensitif. Pastikan survei akurat.</div>
<div class="info-box">📍 Koordinat: <strong>${lat}, ${lng}</strong></div>
<div class="form-tabs">
<div class="form-tab active" data-panel="kk">Data KK</div>
<div class="form-tab" data-panel="eko">Ekonomi</div>
<div class="form-tab" data-panel="pddk">Pddk</div>
<div class="form-tab" data-panel="kes">Kes</div>
<div class="form-tab" data-panel="hist">Bansos</div>
</div>
<!-- TAB: Data KK -->
<div class="form-section-panel active" id="panel-kk">
<div class="form-row cols2">
<div class="fg"><label class="flabel">Nama KK<span class="req">*</span></label>
<input class="finput" id="f-nama" placeholder="Nama KTP" value="${v('nama')}"/></div>
<div class="fg"><label class="flabel">NIK</label>
<input class="finput" id="f-nik" placeholder="3471..." maxlength="16" value="${v('nik')}"/></div>
</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">No. KK</label>
<input class="finput" id="f-no_kk" value="${v('no_kk')}"/></div>
<div class="fg"><label class="flabel">WA/HP</label>
<input class="finput" id="f-wa" value="${v('wa')}"/></div>
</div>
<div class="form-row cols3">
<div class="fg"><label class="flabel">Tingkat Kemiskinan</label>
<select class="finput" id="f-tingkat_kemiskinan">
${CFG.warga.tingkat.map(t=>`<option ${v('tingkat_kemiskinan')===t?'selected':''}>${t}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Jml Anggota</label>
<input class="finput" id="f-jml_anggota" type="number" placeholder="4" value="${v('jml_anggota')}"/></div>
<div class="fg"><label class="flabel">Jml Lansia</label>
<input class="finput" id="f-jml_lansia" type="number" placeholder="0" value="${v('jml_lansia')}"/></div>
</div>
<div class="fg"><label class="flabel">Alamat</label>
<textarea class="finput" id="f-alamat" style="min-height:40px">${v('alamat')}</textarea></div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Penerima Bansos</label>
<select class="finput" id="f-bansos">
${CFG.warga.bantuan.map(b=>`<option ${v('bansos')===b?'selected':''}>${b}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Hub Rumah Ibadah</label>
<select class="finput" id="f-hub_ri">${hubOpts}</select></div>
</div>
</div>
<!-- TAB: Ekonomi -->
<div class="form-section-panel" id="panel-eko">
<div class="form-row cols2">
<div class="fg"><label class="flabel">Pendapatan (Rp/Bln)</label>
<input class="finput" id="f-pendapatan" type="number" value="${v('pendapatan')}"/></div>
<div class="fg"><label class="flabel">Pekerjaan KK</label>
<select class="finput" id="f-pekerjaan">
${CFG.warga.pekerjaan.map(p=>`<option ${v('pekerjaan')===p?'selected':''}>${p}</option>`).join('')}
</select></div>
</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Kondisi Rumah</label>
<select class="finput" id="f-kondisi_rumah">
${CFG.warga.kondisiRumah.map(k=>`<option ${v('kondisi_rumah')===k?'selected':''}>${k}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Aset (Tanah/Kendaraan)</label>
<select class="finput" id="f-kepemilikan_aset">
${['Tidak Ada','Motor Tua','Motor Baru','Tanah','Mobil'].map(j=>`<option ${v('kepemilikan_aset')===j?'selected':''}>${j}</option>`).join('')}
</select></div>
</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Air Bersih</label>
<select class="finput" id="f-air_bersih">
${['PDAM','Sumur','Sungai/Tadah Hujan'].map(j=>`<option ${v('air_bersih')===j?'selected':''}>${j}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Listrik</label>
<select class="finput" id="f-listrik">
${['Sendiri (>900W)','Subsidi (450/900W)','Numpang/Tidak Ada'].map(j=>`<option ${v('listrik')===j?'selected':''}>${j}</option>`).join('')}
</select></div>
</div>
</div>
<!-- TAB: Pendidikan -->
<div class="form-section-panel" id="panel-pddk">
<div class="form-row cols2">
<div class="fg"><label class="flabel">Pendidikan Terakhir KK</label>
<select class="finput" id="f-pendidikan_kk">
${CFG.warga.tingkatPendidikan.map(j=>`<option ${v('pendidikan_kk')===j?'selected':''}>${j}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Anak Bersekolah</label>
<input class="finput" id="f-jml_anak" type="number" placeholder="1" value="${v('jml_anak')}"/></div>
</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Anak Putus Sekolah</label>
<input class="finput" id="f-anak_putus_sekolah" type="number" placeholder="0" value="${v('anak_putus_sekolah')}"/></div>
<div class="fg"><label class="flabel">Penerima KIP/Beasiswa?</label>
<select class="finput" id="f-penerima_beasiswa">
${['Ya','Tidak'].map(j=>`<option ${v('penerima_beasiswa')===j?'selected':''}>${j}</option>`).join('')}
</select></div>
</div>
</div>
<!-- TAB: Kesehatan -->
<div class="form-section-panel" id="panel-kes">
<div class="form-row cols2">
<div class="fg"><label class="flabel">Kepesertaan BPJS</label>
<select class="finput" id="f-bpjs">
${['PBI (Gratis)','Mandiri','Perusahaan','Tidak Punya BPJS'].map(j=>`<option ${v('bpjs')===j?'selected':''}>${j}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Anggota Difabel/Kronis</label>
<input class="finput" id="f-jml_sakit_kronis" type="number" value="${v('jml_sakit_kronis')}"/></div>
</div>
<div class="fg"><label class="flabel">Jenis Penyakit Khusus</label>
<div class="penyakit-grid">
${CFG.warga.jenisPenyakit.map(p=>`<label class="penyakit-check ${c(p)}"><input type="checkbox" name="penyakit" value="${p}" ${c(p)}/>${p}</label>`).join('')}
</div>
</div>
</div>
<!-- TAB: Riwayat Bansos -->
<div class="form-section-panel" id="panel-hist">
<table class="history-table">
<thead><tr><th>Program</th><th style="width:60px">Thn</th><th>Status</th><th style="width:28px"></th></tr></thead>
<tbody id="hist-tbody">
${hist.map(h=>`
<tr>
<td><input class="finput hist-program" style="padding:3px" value="${H.esc(h.program)}"/></td>
<td><input class="finput hist-tahun" style="padding:3px" type="number" value="${h.tahun}"/></td>
<td><select class="finput hist-status" style="padding:3px"><option ${h.status==='Aktif'?'selected':''}>Aktif</option><option ${h.status==='Berhenti'?'selected':''}>Berhenti</option></select></td>
<td><button class="btn-del-row" type="button" onclick="this.closest('tr').remove()">✕</button></td>
</tr>`).join('')}
</tbody>
</table>
<button class="btn-add-row" type="button" id="btn-add-hist">+ Tambah</button>
<div class="fg" style="margin-top:10px"><label class="flabel">Catatan / Rekomendasi</label>
<textarea class="finput" id="f-catatan" style="min-height:50px">${v('catatan')}</textarea></div>
</div>`;
},
rumahIbadah(rec, latlng){
const lat=latlng?latlng.lat.toFixed(6):(rec?.lat?.toFixed(6)||'');
const lng=latlng?latlng.lng.toFixed(6):(rec?.lng?.toFixed(6)||'');
const v=f=>rec?H.esc(rec[f]||''):'';
return `
<div class="info-box">📍 Koordinat: <strong>${lat}, ${lng}</strong></div>
<div class="form-sect">Identitas Rumah Ibadah</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Nama Rumah Ibadah<span class="req">*</span></label>
<input class="finput" id="f-nama" placeholder="cth: Masjid Al-Ikhlas" value="${v('nama')}"/></div>
<div class="fg"><label class="flabel">Jenis<span class="req">*</span></label>
<select class="finput" id="f-jenis">
${CFG.rumahIbadah.jenis.map(j=>`<option ${v('jenis')===j?'selected':''}>${j}</option>`).join('')}
</select></div>
</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Nama Ketua / Imam / Pastor</label>
<input class="finput" id="f-ketua" placeholder="Nama lengkap" value="${v('ketua')}"/></div>
<div class="fg"><label class="flabel">No. WhatsApp Pengurus</label>
<input class="finput" id="f-wa" placeholder="081..." value="${v('wa')}"/></div>
</div>
<div class="form-sect">Kapasitas & Jangkauan</div>
<div class="form-row cols3">
<div class="fg"><label class="flabel">Kapasitas Jamaah</label>
<input class="finput" id="f-kapasitas" type="number" placeholder="200" value="${v('kapasitas')}"/></div>
<div class="fg"><label class="flabel">Radius Wilayah Binaan (m)</label>
<input class="finput" id="f-radius" type="number" placeholder="300" value="${v('radius')||300}"/></div>
<div class="fg"><label class="flabel">Jml Warga Binaan</label>
<input class="finput" id="f-jml_binaan" type="number" placeholder="150" value="${v('jml_binaan')}"/></div>
</div>
<div class="form-sect">Program Sosial</div>
<div class="form-row cols2">
<div class="fg"><label class="flabel">Program Aktif</label>
<select class="finput" id="f-program">
${['Baznas/Zakat','Bantuan Sembako','Santunan Anak Yatim','Pemberdayaan UMKM','Beasiswa','Belum Ada'].map(k=>`<option ${v('program')===k?'selected':''}>${k}</option>`).join('')}
</select></div>
<div class="fg"><label class="flabel">Dana Sosial/Bulan (Rp)</label>
<input class="finput" id="f-dana_sosial" type="number" placeholder="2000000" value="${v('dana_sosial')}"/></div>
</div>
<div class="fg"><label class="flabel">Catatan / Kebutuhan Dukungan</label>
<textarea class="finput" id="f-catatan" placeholder="Kebutuhan data, pelatihan, dll...">${v('catatan')}</textarea></div>`;
},
readAll(ln){
const g = id => document.getElementById(id)?.value?.trim();
if(ln==='spbu'){
const nm=g('f-nama'); if(!nm){Toast.error('Nama SPBU wajib diisi!');return null;}
const buka=document.querySelector('input[name="buka24"]:checked')?.value!=='false';
return {nama:nm,wa:g('f-wa'),buka24:buka,jenis_bbm:g('f-jenis_bbm'),disabilitas:g('f-disabilitas'),harga_bbm:g('f-harga_bbm'),catatan:g('f-catatan')};
}
if(ln==='jalan'){
const nm=g('f-nama'); if(!nm){Toast.error('Nama jalan wajib diisi!');return null;}
return {nama:nm,status:g('f-status'),panjang:parseFloat(g('f-panjang'))||0,kondisi:g('f-kondisi'),tahun_perbaikan:g('f-tahun_perbaikan'),lebar:g('f-lebar'),angkutan:g('f-angkutan'),akses_publik:g('f-akses_publik'),catatan:g('f-catatan')};
}
if(ln==='parsil'){
const nm=g('f-nama'); if(!nm){Toast.error('Nama parsil wajib diisi!');return null;}
return {nama:nm,status:g('f-status'),luas:parseFloat(g('f-luas'))||0,nib:g('f-nib'),sengketa:g('f-sengketa'),njop:g('f-njop'),penggunaan:g('f-penggunaan'),catatan:g('f-catatan')};
}
if(ln==='warga'){
const nm=g('f-nama'); if(!nm){Toast.error('Nama KK wajib diisi!');return null;}
const penyakit=[...document.querySelectorAll('input[name="penyakit"]:checked')].map(el=>el.value);
const history_bansos=[];
document.querySelectorAll('#hist-tbody tr').forEach(tr=>{
const prog=tr.querySelector('.hist-program')?.value?.trim();
const thn=tr.querySelector('.hist-tahun')?.value?.trim();
const sts=tr.querySelector('.hist-status')?.value;
if(prog) history_bansos.push({program:prog,tahun:parseInt(thn)||new Date().getFullYear(),status:sts});
});
return {
nama: nm, nik: g('f-nik'), no_kk: g('f-no_kk'), wa: g('f-wa'), tingkat_kemiskinan: g('f-tingkat_kemiskinan'),
jml_anggota: parseInt(g('f-jml_anggota'))||0, jml_lansia: parseInt(g('f-jml_lansia'))||0,
alamat: g('f-alamat'), bansos: g('f-bansos'), hub_ri: g('f-hub_ri'),
pendapatan: g('f-pendapatan'), pekerjaan: g('f-pekerjaan'),
kondisi_rumah: g('f-kondisi_rumah'), kepemilikan_aset: g('f-kepemilikan_aset'),
air_bersih: g('f-air_bersih'), listrik: g('f-listrik'),
pendidikan_kk: g('f-pendidikan_kk'), jml_anak: parseInt(g('f-jml_anak'))||0,
anak_putus_sekolah: parseInt(g('f-anak_putus_sekolah'))||0, penerima_beasiswa: g('f-penerima_beasiswa'),
bpjs: g('f-bpjs'), jml_sakit_kronis: parseInt(g('f-jml_sakit_kronis'))||0,
penyakit, history_bansos, catatan: g('f-catatan'),
};
}
if(ln==='rumahIbadah'){
const nm=g('f-nama'); if(!nm){Toast.error('Nama Rumah Ibadah wajib diisi!');return null;}
return {
nama: nm, jenis: g('f-jenis'), ketua: g('f-ketua'), wa: g('f-wa'),
kapasitas: parseInt(g('f-kapasitas'))||0, radius: parseFloat(g('f-radius'))||300,
jml_binaan: parseInt(g('f-jml_binaan'))||0, program: g('f-program'),
dana_sosial: g('f-dana_sosial'), catatan: g('f-catatan')
};
}
}
};
+18
View File
@@ -0,0 +1,18 @@
/* ═══════════════════════════════════════════════════════════
10. LEGEND
═══════════════════════════════════════════════════════════ */
function renderLegend(ln){
const el=document.getElementById('legend-items');
const rows=CFG[ln].legend||[];
el.innerHTML=rows.map(r=>{
let sym='';
if(r.sym==='c') sym=`<span class="leg-c" style="background:${r.clr}"></span>`;
else if(r.sym==='l') sym=`<span class="leg-l" style="background:${r.clr}"></span>`;
else sym=`<span class="leg-p" style="border-color:${r.clr};background:${r.clr}33"></span>`;
return `<div class="legend-row">${sym}<span>${r.l}</span></div>`;
}).join('');
// Add radius note for rumahIbadah and bansos
if(ln==='rumahIbadah'||ln==='warga'){
el.innerHTML+=`<div class="legend-radius"><span class="leg-radius-sym"></span>Radius Hub 300m</div>`;
}
}
+82
View File
@@ -0,0 +1,82 @@
/* ═══════════════════════════════════════════════════════════
6. POPUP BUILDERS
═══════════════════════════════════════════════════════════ */
const Popup = {
_row:(k,v)=>`<div class="popup-row"><span class="popup-k">${k}</span><span class="popup-v">${H.esc(v||'-')}</span></div>`,
_sect:t=>`<div class="popup-section">${t}</div>`,
_acts:(ln,id)=>`<div class="popup-acts"><button class="bsm bsm-edit" onclick="App.crud.edit('${ln}','${id}')">Edit</button><button class="bsm bsm-del" onclick="App.crud.delete('${ln}','${id}')">Hapus</button></div>`,
spbu(r){ const cfg=CFG.spbu,clr=cfg.color(r),bdg=cfg.badge(r);
return `<div class="popup-wrap"><div class="popup-title"><span class="popup-sym" style="background:${clr}"></span>${H.esc(r.nama)}</div>
<span class="badge ${bdg.c}">${bdg.t}</span>
${this._sect('Operasional')}
${this._row('No. WA',r.wa)}${this._row('Jenis BBM',r.jenis_bbm)}${this._row('Harga BBM',r.harga_bbm)}${this._row('Akses Disabilitas',r.disabilitas)}
${r.catatan?this._row('Catatan',r.catatan):''}
${this._acts('spbu',r.id)}</div>`; },
jalan(r){ const cfg=CFG.jalan,bdg=cfg.badge(r);
return `<div class="popup-wrap"><div class="popup-title">🛣️ ${H.esc(r.nama)}</div>
<span class="badge ${bdg.c}">${bdg.t}</span>
${this._sect('Spesifikasi')}
${this._row('Panjang',H.fmDist(r.panjang))}${this._row('Lebar',r.lebar?(r.lebar+' m'):'-')}${this._row('Kondisi',r.kondisi)}${this._row('Perbaikan Terakhir',r.tahun_perbaikan||'-')}
${this._sect('Dampak Sosial')}
${this._row('Angkutan Umum',r.angkutan)}${this._row('Akses ke',r.akses_publik)}
${r.catatan?this._row('Catatan',r.catatan):''}
${this._acts('jalan',r.id)}</div>`; },
parsil(r){ const cfg=CFG.parsil,bdg=cfg.badge(r);
return `<div class="popup-wrap"><div class="popup-title">🏗️ ${H.esc(r.nama)}</div>
<span class="badge ${bdg.c}">${bdg.t}</span>
${this._sect('Data Hukum')}
${this._row('NIB',r.nib||'-')}${this._row('Status Sengketa',r.sengketa)}${this._row('Penggunaan',r.penggunaan)}
${this._sect('Penilaian')}
${this._row('Luas',H.fmArea(r.luas))}${this._row('NJOP/m²',r.njop?'Rp '+parseInt(r.njop).toLocaleString('id-ID'):'-')}
${r.catatan?this._row('Catatan',r.catatan):''}
${this._acts('parsil',r.id)}</div>`; },
warga(r){ const cfg=CFG.warga,clr=cfg.color(r),bdg=cfg.badge(r);
const ri=Store.all('rumahIbadah');
let nearestRI=null, nearestDist=Infinity;
ri.forEach(hub=>{
if(hub.lat==null||r.lat==null) return;
const d=L.latLng(r.lat,r.lng).distanceTo(L.latLng(hub.lat,hub.lng));
if(d<nearestDist){nearestDist=d;nearestRI=hub;}
});
const radius=nearestRI?(parseFloat(nearestRI.radius)||300):300;
const covered=nearestRI&&nearestDist<=radius;
const coverageHtml=nearestRI
?`<div class="coverage-badge ${covered?'covered':'uncovered'}">
<svg viewBox="0 0 24 24"><path d="${covered?'M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z':'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'}"/></svg>
${covered?'Terjangkau Hub: '+H.esc(nearestRI.nama)+' ('+Math.round(nearestDist)+' m)':'Tidak Terjangkau ('+Math.round(nearestDist)+' m dari RI)'}
</div>`
:'<div class="coverage-badge uncovered">Belum ada Rumah Ibadah Hub</div>';
const histHtml=r.history_bansos?.length
?r.history_bansos.map(h=>`<div style="font-size:10px;padding:3px 0;border-bottom:1px solid var(--border0);display:flex;justify-content:space-between"><span>${H.esc(h.program)} (${h.tahun||'-'})</span><span class="status-${(h.status||'').toLowerCase().replace(' ','')}" style="font-weight:700">${H.esc(h.status)}</span></div>`).join('')
:'<div style="font-size:10px;color:var(--text4)">Belum ada riwayat bansos</div>';
return `<div class="popup-wrap"><div class="popup-title"><span class="popup-sym" style="background:${clr}"></span>${H.esc(r.nama)}</div>
<span class="badge ${bdg.c}">${bdg.t}</span>
${coverageHtml}
${this._sect('Data Keluarga')}
${this._row('NIK',r.nik||'-')}${this._row('No. KK',r.no_kk||'-')}${this._row('Jml Anggota',r.jml_anggota+' jiwa')}
${this._sect('Ekonomi')}
${this._row('Pekerjaan KK',r.pekerjaan)}${this._row('Pendapatan/Bln',r.pendapatan?'Rp '+parseInt(r.pendapatan).toLocaleString('id-ID'):'-')}${this._row('Kondisi Rumah',r.kondisi_rumah)}
${this._sect('Fasilitas')}
${this._row('Air Bersih',r.air_bersih)}${this._row('Listrik',r.listrik)}
${this._sect('Bansos')}
${this._row('Bansos Saat Ini',r.bansos)}${this._row('Hub RI',r.hub_ri||'-')}
<div style="margin-top:2px">${histHtml}</div>
${r.catatan?this._row('Rekomendasi',r.catatan):''}
${this._acts('warga',r.id)}</div>`; },
rumahIbadah(r){ const cfg=CFG.rumahIbadah,clr=cfg.color(r),bdg=cfg.badge(r);
return `<div class="popup-wrap"><div class="popup-title"><span class="popup-sym" style="background:${clr}"></span>${H.esc(r.nama)}</div>
<span class="badge ${bdg.c}">${bdg.t}</span>
${this._sect('Kontak')}
${this._row('Ketua/Imam',r.ketua||'-')}${this._row('No. WA',r.wa||'-')}
${this._sect('Kapasitas & Binaan')}
${this._row('Kapasitas',r.kapasitas?r.kapasitas+' orang':'-')}${this._row('Radius Binaan',(r.radius||300)+' m')}${this._row('Warga Binaan',r.jml_binaan?r.jml_binaan+' KK':'-')}
${this._sect('Program Sosial')}
${this._row('Program Aktif',r.program)}${this._row('Dana Sosial/Bln',r.dana_sosial?'Rp '+parseInt(r.dana_sosial).toLocaleString('id-ID'):'-')}
${r.catatan?this._row('Catatan',r.catatan):''}
${this._acts('rumahIbadah',r.id)}</div>`; },
};
+57
View File
@@ -0,0 +1,57 @@
/* ═══════════════════════════════════════════════════════════
4. LAYER CONFIG (atribut + warna per layer)
── Ini adalah "kontrak data" antara programmer & client ──
═══════════════════════════════════════════════════════════ */
const CFG = {
spbu: {
label:'Data SPBU', sub:'Stasiun Bahan Bakar Umum', icon:'⛽', drawType:'point',
color: r => r.buka24?'#34d47e':'#f05c5c',
badge: r => r.buka24?{t:'24 Jam',c:'badge-green'}:{t:'Tutup',c:'badge-red'},
legend:[{l:'Buka 24 Jam',clr:'#34d47e',sym:'c'},{l:'Tidak 24 Jam',clr:'#f05c5c',sym:'c'}],
},
jalan: {
label:'Data Jalan', sub:'Ruas jalan berdasarkan status', icon:'🛣️', drawType:'polyline',
statuses:['Nasional','Provinsi','Kabupaten','Desa'],
colors:{'Nasional':'#f05c5c','Provinsi':'#f0a030','Kabupaten':'#5b9cf6','Desa':'#34d47e'},
badgeClass:{'Nasional':'badge-red','Provinsi':'badge-amber','Kabupaten':'badge-accent','Desa':'badge-green'},
kondisi:['Baik','Sedang','Rusak Ringan','Rusak Berat'],
color: r => ({'Nasional':'#f05c5c','Provinsi':'#f0a030','Kabupaten':'#5b9cf6','Desa':'#34d47e'}[r.status]||'#888'),
badge: r => ({t:r.status, c:{'Nasional':'badge-red','Provinsi':'badge-amber','Kabupaten':'badge-accent','Desa':'badge-green'}[r.status]||'badge-accent'}),
legend:[{l:'Nasional',clr:'#f05c5c',sym:'l'},{l:'Provinsi',clr:'#f0a030',sym:'l'},{l:'Kabupaten',clr:'#5b9cf6',sym:'l'},{l:'Desa',clr:'#34d47e',sym:'l'}],
},
parsil: {
label:'Data Parsil Tanah', sub:'Persil kavling berdasarkan kepemilikan', icon:'🏗️', drawType:'polygon',
statuses:['SHM','HGB','HGU','HP','Belum Bersertifikat'],
colors:{'SHM':'#34d47e','HGB':'#5b9cf6','HGU':'#f0a030','HP':'#9b7bfa','Belum Bersertifikat':'#f05c5c'},
badgeClass:{'SHM':'badge-green','HGB':'badge-accent','HGU':'badge-amber','HP':'badge-purple','Belum Bersertifikat':'badge-red'},
color: r => ({'SHM':'#34d47e','HGB':'#5b9cf6','HGU':'#f0a030','HP':'#9b7bfa','Belum Bersertifikat':'#f05c5c'}[r.status]||'#888'),
badge: r => ({t:r.status,c:{'SHM':'badge-green','HGB':'badge-accent','HGU':'badge-amber','HP':'badge-purple','Belum Bersertifikat':'badge-red'}[r.status]||'badge-accent'}),
legend:[{l:'SHM',clr:'#34d47e',sym:'p'},{l:'HGB',clr:'#5b9cf6',sym:'p'},{l:'HGU',clr:'#f0a030',sym:'p'},{l:'HP',clr:'#9b7bfa',sym:'p'},{l:'Belum Sertifikat',clr:'#f05c5c',sym:'p'}],
},
warga: {
label:'Data Warga & Bansos', sub:'Keluarga rentan & sasaran distribusi bansos', icon:'👨‍👩‍👧', drawType:'point',
tingkat:['Sangat Miskin','Miskin','Hampir Miskin','Rentan'],
colors:{'Sangat Miskin':'#f05c5c','Miskin':'#f0a030','Hampir Miskin':'#f0c040','Rentan':'#5b9cf6'},
badgeClass:{'Sangat Miskin':'badge-red','Miskin':'badge-orange','Hampir Miskin':'badge-amber','Rentan':'badge-accent'},
color: r => ({'Sangat Miskin':'#f05c5c','Miskin':'#f0a030','Hampir Miskin':'#f0c040','Rentan':'#5b9cf6'}[r.tingkat_kemiskinan]||'#888'),
badge: r => ({t:r.tingkat_kemiskinan,c:{'Sangat Miskin':'badge-red','Miskin':'badge-orange','Hampir Miskin':'badge-amber','Rentan':'badge-accent'}[r.tingkat_kemiskinan]||'badge-accent'}),
legend:[{l:'Sangat Miskin',clr:'#f05c5c',sym:'c'},{l:'Miskin',clr:'#f0a030',sym:'c'},{l:'Hampir Miskin',clr:'#f0c040',sym:'c'},{l:'Rentan',clr:'#5b9cf6',sym:'c'}],
bantuan:['PKH','BPNT/Sembako','BLT Dana Desa','BLT BBM','PIP/KIP','KIS/BPJS','Zakat/Infaq RI','Bansos Pemda','Belum Ada'],
kondisiRumah:['Permanen','Semi Permanen','Tidak Layak Huni','Menumpang'],
pekerjaan:['Buruh Harian','Petani/Nelayan','Pedagang Kecil','PNS/TNI/Polri','Pensiunan','Tidak Bekerja','Lainnya'],
statusPenerimaan:['Aktif Menerima','Belum Menerima','Proses Verifikasi','Pernah Menerima'],
sumberPendapatan:['Buruh Harian','Pedagang Kecil','Petani/Nelayan','PNS/TNI/Polri','Pensiunan','Tidak Bekerja','Lainnya'],
jenisPenyakit:['Hipertensi','Diabetes','TBC','Jantung','Stroke','Kanker','Gangguan Jiwa','Disabilitas','Tidak Ada'],
tingkatPendidikan:['Tidak Sekolah','SD/Sederajat','SMP/Sederajat','SMA/Sederajat','D3/S1','S2/S3'],
},
rumahIbadah: {
label:'Rumah Ibadah', sub:'Masjid, Gereja, Pura, Vihara, Klenteng', icon:'🕌', drawType:'point',
jenis:['Masjid','Musholla','Gereja Kristen','Gereja Katolik','Pura','Vihara','Klenteng'],
colors:{'Masjid':'#2ec4b6','Musholla':'#2ec4b6','Gereja Kristen':'#5b9cf6','Gereja Katolik':'#5b9cf6','Pura':'#f0a030','Vihara':'#9b7bfa','Klenteng':'#f07840'},
badgeClass:{'Masjid':'badge-teal','Musholla':'badge-teal','Gereja Kristen':'badge-accent','Gereja Katolik':'badge-accent','Pura':'badge-amber','Vihara':'badge-purple','Klenteng':'badge-orange'},
color: r => ({'Masjid':'#2ec4b6','Musholla':'#2ec4b6','Gereja Kristen':'#5b9cf6','Gereja Katolik':'#5b9cf6','Pura':'#f0a030','Vihara':'#9b7bfa','Klenteng':'#f07840'}[r.jenis]||'#888'),
badge: r => ({t:r.jenis,c:({'Masjid':'badge-teal','Musholla':'badge-teal','Gereja Kristen':'badge-accent','Gereja Katolik':'badge-accent','Pura':'badge-amber','Vihara':'badge-purple','Klenteng':'badge-orange'}[r.jenis]||'badge-teal')}),
legend:[{l:'Masjid/Musholla',clr:'#2ec4b6',sym:'c'},{l:'Gereja',clr:'#5b9cf6',sym:'c'},{l:'Pura',clr:'#f0a030',sym:'c'},{l:'Vihara/Klenteng',clr:'#9b7bfa',sym:'c'}],
coverageRadius: 300,
},
};
+182
View File
@@ -0,0 +1,182 @@
/* ═══════════════════════════════════════════════════════════
12. DASHBOARD ANALITIK
═══════════════════════════════════════════════════════════ */
const Dashboard = {
toggle(){
document.getElementById('dash-panel').classList.toggle('hidden');
if(!document.getElementById('dash-panel').classList.contains('hidden')) this.render();
},
render(){
const db=Store.allDb();
const warga=db.warga||[], ri=db.rumahIbadah||[], jalan=db.jalan||[], parsil=db.parsil||[];
const totalWarga=warga.reduce((s,r)=>s+(parseInt(r.jml_anggota)||1),0);
const miskinBerat=warga.filter(r=>r.tingkat_kemiskinan==='Sangat Miskin'||r.tingkat_kemiskinan==='Miskin').length;
const blmBansos=warga.filter(r=>r.bansos==='Belum Ada').length;
const blmSertif=parsil.filter(r=>r.status==='Belum Bersertifikat').length;
const jalanRusak=jalan.filter(r=>r.kondisi==='Rusak Berat'||r.kondisi==='Rusak Ringan').length;
const totalRI=ri.length;
// Radius coverage for Warga layer
let coveredKK=0, uncoveredKK=0;
const coverageHubs = ri.map(hub=>({ ...hub, count:0 }));
warga.forEach(b => {
if(!b.lat) return;
let nearestDist=Infinity, nearestHub=null;
ri.forEach(hub=>{
if(!hub.lat) return;
const d=L.latLng(b.lat,b.lng).distanceTo(L.latLng(hub.lat,hub.lng));
if(d<nearestDist){nearestDist=d;nearestHub=hub;}
});
if(nearestHub && nearestDist<=(parseFloat(nearestHub.radius)||300)){
coveredKK++;
const h = coverageHubs.find(h=>h.id===nearestHub.id);
if(h) h.count++;
} else {
uncoveredKK++;
}
});
// tingkat kemiskinan breakdown
const tkBreak={};
CFG.warga.tingkat.forEach(t=>{ tkBreak[t]=warga.filter(r=>r.tingkat_kemiskinan===t).length; });
const maxTK=Math.max(...Object.values(tkBreak),1);
// bansos breakdown
const bansosBreak={};
CFG.warga.bantuan.forEach(b=>{ const c=warga.filter(r=>r.bansos===b).length; if(c>0) bansosBreak[b]=c; });
// jalan status breakdown
const jalanBreak={};
CFG.jalan.statuses.forEach(s=>{ jalanBreak[s]=jalan.filter(r=>r.status===s).length; });
document.getElementById('dash-body').innerHTML=`
<div class="dash-tab-bar">
<div class="dash-tab active" onclick="Dashboard.switchTab('ringkasan')">Ringkasan Utama</div>
<div class="dash-tab" onclick="Dashboard.switchTab('distribusi')">Distribusi Bansos</div>
</div>
<!-- TAB RINGKASAN -->
<div id="dash-panel-ringkasan">
<div class="stat-grid">
<div class="stat-card c-red"><div class="sc-val">${H.fmNum(warga.length)}</div><div class="sc-label">KK Warga Miskin Terdata</div><div class="sc-sub">${H.fmNum(totalWarga)} jiwa total</div></div>
<div class="stat-card c-amber"><div class="sc-val">${H.fmNum(miskinBerat)}</div><div class="sc-label">Miskin & Sangat Miskin</div><div class="sc-sub">${warga.length?Math.round(miskinBerat/warga.length*100):0}% dari total KK</div></div>
<div class="stat-card c-accent"><div class="sc-val">${H.fmNum(blmBansos)}</div><div class="sc-label">KK Belum Terima Bansos</div><div class="sc-sub">Perlu intervensi segera</div></div>
<div class="stat-card c-teal"><div class="sc-val">${H.fmNum(totalRI)}</div><div class="sc-label">Rumah Ibadah Aktif</div><div class="sc-sub">Agen lapangan terdaftar</div></div>
<div class="stat-card c-purple"><div class="sc-val">${H.fmNum(blmSertif)}</div><div class="sc-label">Parsil Belum Bersertifikat</div><div class="sc-sub">Kandidat program PTSL</div></div>
<div class="stat-card c-green"><div class="sc-val">${H.fmNum(jalanRusak)}</div><div class="sc-label">Ruas Jalan Rusak</div><div class="sc-sub">Dari ${H.fmNum(jalan.length)} ruas terdata</div></div>
</div>
<div class="dash-sect">
<div class="dash-sect-title">Sebaran Tingkat Kemiskinan</div>
<table class="risk-table"><thead><tr><th>Kategori</th><th>Jumlah KK</th><th>Proporsi</th></tr></thead><tbody>
${CFG.warga.tingkat.map(t=>{
const n=tkBreak[t]||0, pct=warga.length?Math.round(n/warga.length*100):0;
const clr={'Sangat Miskin':'#f05c5c','Miskin':'#f0a030','Hampir Miskin':'#f0c040','Rentan':'#5b9cf6'}[t];
return \`<tr><td>\${t}</td><td>\${H.fmNum(n)} KK</td><td><div class="risk-bar-wrap"><div class="risk-bar"><div class="risk-bar-fill" style="width:\${Math.round(n/maxTK*100)}%;background:\${clr}"></div></div>\${pct}%</div></td></tr>\`;
}).join('')}
</tbody></table>
</div>
<div class="dash-sect">
<div class="dash-sect-title">Penerima Program Bantuan Sosial</div>
${Object.entries(bansosBreak).length?Object.entries(bansosBreak).sort((a,b)=>b[1]-a[1]).map(([b,n])=>`
<div class="prog-row"><span class="prog-name">${b}</span><span class="prog-count">${n} KK</span></div>`).join('')
:'<div style="color:var(--text4);font-size:12px">Belum ada data warga tercatat.</div>'}
</div>
<div class="dash-sect">
<div class="dash-sect-title">Status Ruas Jalan</div>
<table class="risk-table"><thead><tr><th>Status</th><th>Jumlah Ruas</th><th>Total Panjang</th></tr></thead><tbody>
${CFG.jalan.statuses.map(s=>{
const ruas=jalan.filter(r=>r.status===s);
const pj=ruas.reduce((t,r)=>t+(r.panjang||0),0);
return \`<tr><td><span class="badge \${CFG.jalan.badgeClass[s]}">\${s}</span></td><td>\${ruas.length} ruas</td><td>\${H.fmDist(pj)}</td></tr>\`;
}).join('')}
</tbody></table>
</div>
<div class="dash-sect">
<div class="dash-sect-title">Rekomendasi Intervensi Kebijakan</div>
<div class="intervensi-grid">
<div class="intervensi-card"><div class="ic-label">🏠 Prioritas Bansos</div>
<div class="ic-val">${H.fmNum(blmBansos)} KK</div>
<div class="ic-sub">Warga belum menerima bantuan apapun</div></div>
<div class="intervensi-card"><div class="ic-label">📜 Prioritas PTSL</div>
<div class="ic-val">${H.fmNum(blmSertif)} Parsil</div>
<div class="ic-sub">Lahan tanpa sertifikat, akses kredit terbatas</div></div>
<div class="intervensi-card"><div class="ic-label">🛣️ Prioritas Perbaikan Jalan</div>
<div class="ic-val">${H.fmNum(jalanRusak)} Ruas</div>
<div class="ic-sub">Jalan rusak ringan-berat perlu perbaikan</div></div>
<div class="intervensi-card"><div class="ic-label">🕌 Coverage Rumah Ibadah</div>
<div class="ic-val">${H.fmNum(totalRI)} RI Aktif</div>
<div class="ic-sub">Total dana sosial: Rp ${H.fmNum(ri.reduce((t,r)=>t+(parseInt(r.dana_sosial)||0),0))}</div></div>
</div>
</div>
</div>
<!-- TAB DISTRIBUSI BANSOS -->
<div id="dash-panel-distribusi" style="display:none">
<div class="coverage-grid">
<div class="coverage-card">
<div class="cc-icon green">✓</div>
<div class="cc-info">
<div class="cc-val">${coveredKK} KK</div>
<div class="cc-label">Terjangkau Hub (≤300m)</div>
</div>
</div>
<div class="coverage-card">
<div class="cc-icon red">✕</div>
<div class="cc-info">
<div class="cc-val">${uncoveredKK} KK</div>
<div class="cc-label">Di Luar Jangkauan Hub</div>
</div>
</div>
<div class="coverage-card" style="grid-column:1/3;padding:16px">
<div class="cc-icon teal" style="width:40px;height:40px">🕌</div>
<div class="cc-info">
<div class="cc-val">${totalRI} Rumah Ibadah</div>
<div class="cc-label">Agen Distribusi Lapangan Terdaftar</div>
</div>
</div>
</div>
<div class="dash-sect">
<div class="dash-sect-title">Beban Distribusi per Rumah Ibadah</div>
<table class="hub-table">
<thead><tr><th>Nama Hub</th><th>Kapasitas</th><th>Beban KK</th></tr></thead>
<tbody>
${coverageHubs.sort((a,b)=>b.count-a.count).map(h=>{
const clr=CFG.rumahIbadah.color(h);
const max = Math.max(...coverageHubs.map(x=>x.count), 1);
const pct = Math.round(h.count/max*100);
return \`<tr>
<td><div class="hub-name-cell"><span class="hub-dot" style="background:\${clr}"></span>\${H.esc(h.nama)}</div></td>
<td>\${h.jml_binaan||'-'} KK</td>
<td>
<div class="hub-count-bar">
<div class="hub-bar"><div class="hub-bar-fill" style="width:\${pct}%;background:\${pct>80?'var(--amber)':clr}"></div></div>
<span>\${h.count} KK</span>
</div>
</td>
</tr>\`;
}).join('')}
</tbody>
</table>
${coverageHubs.length===0?'<div style="text-align:center;color:var(--text4);font-size:11px;padding:20px">Belum ada data Rumah Ibadah.</div>':''}
</div>
</div>`;
},
switchTab(tabId){
document.querySelectorAll('.dash-tab').forEach(t=>t.classList.remove('active'));
document.querySelectorAll('[id^="dash-panel-"]').forEach(p=>p.style.display='none');
if(tabId === 'ringkasan'){
document.querySelectorAll('.dash-tab')[0].classList.add('active');
document.getElementById('dash-panel-ringkasan').style.display='block';
} else {
document.querySelectorAll('.dash-tab')[1].classList.add('active');
document.getElementById('dash-panel-distribusi').style.display='block';
}
}
};
+38
View File
@@ -0,0 +1,38 @@
/* ═══════════════════════════════════════════════════════════
8. DRAW MANAGER
═══════════════════════════════════════════════════════════ */
const Draw = (() => {
let _map, _drawn, _pending, _handler, _active=false;
const BAR=()=>document.getElementById('draw-bar');
const TXT=()=>document.getElementById('draw-indicator-text');
function init(m){
_map=m; _drawn=new L.FeatureGroup().addTo(_map);
_map.on(L.Draw.Event.CREATED, e=>{
_drawn.clearLayers(); _drawn.addLayer(e.layer);
_pending=e.layer; stop(); Modal.openAdd(App.state.activeLayer, e.layer);
});
_map.on('click', e=>{
if(!_active||App.state.activeLayer!=='spbu'&&App.state.activeLayer!=='warga'&&App.state.activeLayer!=='rumahIbadah') return;
const mk=L.marker(e.latlng);
_drawn.clearLayers(); _drawn.addLayer(mk);
_pending=mk; stop(); Modal.openAdd(App.state.activeLayer, mk);
});
document.getElementById('btn-cancel').addEventListener('click', cancel);
}
function start(ln){
_active=true;
const msgs={spbu:'Klik peta untuk menandai lokasi SPBU',jalan:'Klik titik-titik ruas jalan — klik ganda untuk selesai',parsil:'Klik titik-titik batas kavling — klik ganda untuk selesai',warga:'Klik peta untuk menandai lokasi rumah warga miskin',rumahIbadah:'Klik peta untuk menandai lokasi rumah ibadah'};
document.getElementById('draw-text').textContent=msgs[ln]||'Mode gambar aktif';
BAR().classList.remove('hidden');
if(ln==='jalan'){ _handler=new L.Draw.Polyline(_map,{shapeOptions:{color:'#f0a030',weight:4}}); _handler.enable(); }
else if(ln==='parsil'){ _handler=new L.Draw.Polygon(_map,{shapeOptions:{color:'#9b7bfa',weight:2,fillOpacity:.2}}); _handler.enable(); }
}
function stop(){ _active=false; BAR().classList.add('hidden'); if(_handler){_handler.disable();_handler=null;} }
function cancel(){ stop(); _drawn.clearLayers(); _pending=null; }
function clearPending(){ _drawn.clearLayers(); _pending=null; }
return {init, start, stop, cancel, clearPending};
})();
+63
View File
@@ -0,0 +1,63 @@
/* ═══════════════════════════════════════════════════════════
13. MAP RENDER
═══════════════════════════════════════════════════════════ */
function renderMap(){
const db=Store.allDb();
// SPBU
App._groups.spbu.clearLayers();
db.spbu.forEach(r=>{
const m=L.circleMarker([r.lat,r.lng],{radius:9,fillColor:CFG.spbu.color(r),color:'#fff',weight:2,fillOpacity:1});
m.bindPopup(Popup.spbu(r),{maxWidth:320});
m.on('click',()=>highlightCard(r.id));
App._groups.spbu.addLayer(m);
});
// Warga
App._groups.warga.clearLayers();
db.warga.forEach(r=>{
const clr=CFG.warga.color(r);
// Check if in any RI radius
const inRadius=db.rumahIbadah.some(hub=>{
if(!hub.lat) return false;
const d=L.latLng(r.lat,r.lng).distanceTo(L.latLng(hub.lat,hub.lng));
return d<=(parseFloat(hub.radius)||300);
});
const iconHtml=`<div style="width:22px;height:22px;background:${clr};border:2px solid #fff;border-radius:50%;
display:flex;align-items:center;justify-content:center;box-shadow:0 2px 6px rgba(0,0,0,.4);
font-size:11px;line-height:1">👨‍👩‍👧</div>${inRadius?'':'<div style="position:absolute;top:-4px;right:-4px;width:10px;height:10px;background:#f05c5c;border-radius:50%;border:1px solid #fff"></div>'}`;
const icon=L.divIcon({html:`<div style="position:relative">${iconHtml}</div>`,className:'',iconSize:[22,22],iconAnchor:[11,11]});
const m=L.marker([r.lat,r.lng],{icon});
m.bindPopup(Popup.warga(r),{maxWidth:360});
m.on('click',()=>highlightCard(r.id));
App._groups.warga.addLayer(m);
});
// Rumah Ibadah
App._groups.rumahIbadah.clearLayers();
db.rumahIbadah.forEach(r=>{
const clr=CFG.rumahIbadah.color(r);
const m=L.circleMarker([r.lat,r.lng],{radius:10,fillColor:clr,color:'#fff',weight:2.5,fillOpacity:1});
m.bindPopup(Popup.rumahIbadah(r),{maxWidth:320});
m.on('click',()=>highlightCard(r.id));
App._groups.rumahIbadah.addLayer(m);
// Always draw radius 300m coverage circle
const radiusM=parseFloat(r.radius)||300;
const ring=L.circle([r.lat,r.lng],{radius:radiusM,color:clr,weight:1.5,fillColor:clr,fillOpacity:.06,dashArray:'6 4'});
App._groups.rumahIbadah.addLayer(ring);
});
// Jalan
App._groups.jalan.clearLayers();
db.jalan.forEach(r=>{
const l=L.polyline(r.coords,{color:CFG.jalan.color(r),weight:4,opacity:.9});
l.bindPopup(Popup.jalan(r),{maxWidth:300});
l.on('click',()=>highlightCard(r.id));
App._groups.jalan.addLayer(l);
});
// Parsil
App._groups.parsil.clearLayers();
db.parsil.forEach(r=>{
const clr=CFG.parsil.color(r);
const p=L.polygon(r.coords,{color:clr,weight:2,fillColor:clr,fillOpacity:.2});
p.bindPopup(Popup.parsil(r),{maxWidth:300});
p.on('click',()=>highlightCard(r.id));
App._groups.parsil.addLayer(p);
});
}
+119
View File
@@ -0,0 +1,119 @@
/* ═══════════════════════════════════════════════════════════
9. MODAL MANAGER
═══════════════════════════════════════════════════════════ */
const Modal = (() => {
let _ln=null, _editId=null, _geo=null;
const ov=()=>document.getElementById('modal-ov');
function openAdd(ln, leafletLayer){
_ln=ln; _editId=null; _geo=leafletLayer;
const cfg=CFG[ln];
document.getElementById('modal-icon').textContent=cfg.icon;
document.getElementById('modal-title').textContent='Tambah '+cfg.label;
document.getElementById('modal-sub').textContent='Isi atribut data baru';
let html='';
if(ln==='spbu') html=Forms.spbu(null, leafletLayer.getLatLng());
else if(ln==='jalan') html=Forms.jalan(null, H.geoLen(leafletLayer.getLatLngs()));
else if(ln==='parsil') html=Forms.parsil(null, H.geoArea(leafletLayer.getLatLngs()[0]));
else if(ln==='warga') html=Forms.warga(null, leafletLayer.getLatLng());
else if(ln==='rumahIbadah') html=Forms.rumahIbadah(null, leafletLayer.getLatLng());
document.getElementById('modal-body').innerHTML=html;
_bindRadio(); _bindFormTabs(); _bindHistBtn(); ov().classList.remove('hidden');
setTimeout(()=>document.getElementById('f-nama')?.focus(),60);
}
function openEdit(ln, id){
_ln=ln; _editId=id; _geo=null;
const rec=Store.find(ln,id); if(!rec) return;
const cfg=CFG[ln];
document.getElementById('modal-icon').textContent=cfg.icon;
document.getElementById('modal-title').textContent='Edit '+cfg.label;
document.getElementById('modal-sub').textContent=H.esc(rec.nama);
let html='';
if(ln==='spbu') html=Forms.spbu(rec,null);
else if(ln==='jalan') html=Forms.jalan(rec,null);
else if(ln==='parsil') html=Forms.parsil(rec,null);
else if(ln==='warga') html=Forms.warga(rec,null);
else if(ln==='rumahIbadah') html=Forms.rumahIbadah(rec,null);
document.getElementById('modal-body').innerHTML=html;
_bindRadio(); _bindFormTabs(); _bindHistBtn(); ov().classList.remove('hidden');
setTimeout(()=>document.getElementById('f-nama')?.focus(),60);
}
function close(){ ov().classList.add('hidden'); Draw.clearPending(); }
async function submit(){
const data=Forms.readAll(_ln); if(!data) return;
if(_editId){
data.updatedAt=H.today();
await Store.update(_ln,_editId,data);
Toast.success('Data berhasil diperbarui');
} else {
const geo=_geo;
if(_ln==='spbu'||_ln==='warga'||_ln==='rumahIbadah'){
const ll=geo.getLatLng(); data.lat=ll.lat; data.lng=ll.lng;
} else if(_ln==='jalan'){
data.coords=geo.getLatLngs().map(l=>[l.lat,l.lng]);
} else if(_ln==='parsil'){
data.coords=geo.getLatLngs()[0].map(l=>[l.lat,l.lng]);
}
data.id=H.uid(); data.createdAt=H.today(); data.updatedAt=H.today();
await Store.add(_ln,data);
Toast.success('Data berhasil disimpan');
}
close(); App.refresh();
}
function _bindRadio(){
document.querySelectorAll('.ropt').forEach(el=>{
el.addEventListener('click',()=>{
const parent=el.closest('.rtoggle'); if(!parent) return;
parent.querySelectorAll('.ropt').forEach(r=>r.classList.remove('yes','no'));
const v=el.dataset.v; el.classList.add(v==='true'?'yes':'no');
const inp=el.querySelector('input'); if(inp) inp.checked=true;
});
});
// Bind penyakit checkboxes
document.querySelectorAll('.penyakit-check input').forEach(inp=>{
inp.addEventListener('change', e=>{
if(e.target.checked) e.target.parentElement.classList.add('checked');
else e.target.parentElement.classList.remove('checked');
});
});
}
function _bindFormTabs(){
const tabs=document.querySelectorAll('.form-tab');
if(!tabs.length) return;
tabs.forEach(tab=>{
tab.addEventListener('click',()=>{
tabs.forEach(t=>t.classList.remove('active'));
document.querySelectorAll('.form-section-panel').forEach(p=>p.classList.remove('active'));
tab.classList.add('active');
const panelId='panel-'+tab.dataset.panel;
document.getElementById(panelId)?.classList.add('active');
});
});
}
function _bindHistBtn(){
const btn=document.getElementById('btn-add-hist');
if(!btn) return;
btn.addEventListener('click',()=>{
const tbody=document.getElementById('hist-tbody');
if(!tbody) return;
const tr=document.createElement('tr');
tr.innerHTML=`
<td><input class="finput hist-program" style="padding:3px 6px;font-size:10px" placeholder="cth: PKH"/></td>
<td><input class="finput hist-tahun" style="padding:3px 6px;font-size:10px;width:70px" type="number" min="2000" max="2030" placeholder="${new Date().getFullYear()}"/></td>
<td><select class="finput hist-status" style="padding:3px 6px;font-size:10px">
<option>Aktif</option><option>Berhenti</option><option>Dalam Proses</option>
</select></td>
<td><input class="finput hist-nominal" style="padding:3px 6px;font-size:10px" type="number" placeholder="Rp"/></td>
<td><button class="btn-del-row" type="button" onclick="this.closest('tr').remove()">✕</button></td>`;
tbody.appendChild(tr);
});
}
return {openAdd, openEdit, close, submit};
})();
+31
View File
@@ -0,0 +1,31 @@
/* ═══════════════════════════════════════════════════════════
11. SIDEBAR
═══════════════════════════════════════════════════════════ */
function renderSidebar(ln){
const cfg=CFG[ln];
document.getElementById('sb-title').textContent=cfg.label;
document.getElementById('sb-sub').textContent=cfg.sub;
const data=Store.all(ln);
const list=document.getElementById('sb-list');
if(!data.length){
list.innerHTML=`<div class="empty"><svg width="44" height="44" viewBox="0 0 24 24" fill="currentColor"><path d="M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg><p>Belum ada data.<br/>Klik <strong>Tambah</strong> untuk mulai digitasi.</p></div>`;
return;
}
list.innerHTML=data.map(r=>Card[ln](r)).join('');
}
function updateBadges(){
const db=Store.allDb();
['spbu','jalan','parsil','warga','rumahIbadah'].forEach(k=>{
const el=document.getElementById('badge-'+k);
if(el) el.textContent=db[k]?.length||0;
});
const total=Object.values(db).reduce((s,a)=>s+a.length,0);
document.getElementById('total-count').textContent=total+' fitur';
}
function highlightCard(id){
document.querySelectorAll('.dcard').forEach(c=>c.classList.remove('active'));
const c=document.getElementById('card-'+id);
if(c){ c.classList.add('active'); c.scrollIntoView({behavior:'smooth',block:'nearest'}); }
}
+18
View File
@@ -0,0 +1,18 @@
'use strict';
/* ═══════════════════════════════════════════════════════════
1. HELPERS
═══════════════════════════════════════════════════════════ */
const H = {
uid: () => Math.random().toString(36).slice(2,9) + Date.now().toString(36),
esc: s => String(s||'').replace(/[&<>"']/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[c])),
fmDist: m => m==null?'-': m>=1000? (m/1000).toFixed(2)+' km': Math.round(m).toLocaleString('id-ID')+' m',
fmArea: m => m==null?'-': m>=10000? (m/10000).toFixed(4)+' ha': Math.round(m).toLocaleString('id-ID')+' m²',
fmNum: n => (n||0).toLocaleString('id-ID'),
today: () => new Date().toISOString().slice(0,10),
geoLen: lls => { let t=0; for(let i=0;i<lls.length-1;i++) t+=lls[i].distanceTo(lls[i+1]); return Math.round(t); },
geoArea: lls => {
const R=6371008.8; let a=0,n=lls.length;
for(let i=0;i<n;i++){const j=(i+1)%n,φ1=lls[i].lat*Math.PI/180,φ2=lls[j].lat*Math.PI/180,=(lls[j].lng-lls[i].lng)*Math.PI/180;a+=*(2+Math.sin(φ1)+Math.sin(φ2));}
return Math.round(Math.abs(a*R*R/2));
},
};
+55
View File
@@ -0,0 +1,55 @@
/* ═══════════════════════════════════════════════════════════
2. STORAGE (MySQL API Version)
═══════════════════════════════════════════════════════════ */
const Store = (() => {
const API_URL = '../api/index.php';
let db = {
spbu: [],
jalan: [],
parsil: [],
warga: [],
rumahIbadah: []
};
const request = async (action, payload = {}) => {
try {
const res = await fetch(`${API_URL}?action=${action}`, {
method: action === 'alldb' ? 'GET' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: action === 'alldb' ? null : JSON.stringify(payload)
});
return await res.json();
} catch (err) {
console.error('API Error:', err);
throw err;
}
};
return {
load: async () => {
try {
const data = await request('alldb');
if (data) db = { ...db, ...data };
} catch (e) {
console.error("Gagal load data dari server", e);
}
},
all: ln => db[ln]||[],
find: (ln,id) => (db[ln]||[]).find(r=>r.id===id)||null,
add: async (ln,rec) => {
db[ln].push(rec);
await request('add', { layer: ln, data: rec });
},
update: async (ln,id,patch) => {
const i=db[ln].findIndex(r=>r.id===id); if(i<0)return;
db[ln][i]={...db[ln][i],...patch};
await request('update', { layer: ln, id: id, patch: patch });
},
remove: async (ln,id) => {
db[ln]=db[ln].filter(r=>r.id!==id);
await request('remove', { layer: ln, id: id });
},
allDb: () => db,
};
})();
+9
View File
@@ -0,0 +1,9 @@
/* ═══════════════════════════════════════════════════════════
3. TOAST
═══════════════════════════════════════════════════════════ */
const Toast = {
_show(msg,type){ const c=document.getElementById('toasts'),el=document.createElement('div');
el.className=`toast ${type}`;el.innerHTML=`<span>${{success:'✓',error:'✕',info:''}[type]||''}</span>${H.esc(msg)}`;
c.appendChild(el);setTimeout(()=>{el.classList.add('out');el.addEventListener('animationend',()=>el.remove(),{once:true})},2800); },
success: m=>Toast._show(m,'success'), error: m=>Toast._show(m,'error'), info: m=>Toast._show(m,'info'),
};