Files
2026-06-11 21:20:59 +07:00

5261 lines
178 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoSosial — Sistem Informasi Sosial Kota Pontianak</title>
<meta name="description"
content="Sistem Informasi Geografis Kemiskinan dan Bantuan Sosial Kota Pontianak — Dinas Sosial">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<link
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=Syne:wght@700;800&display=swap"
rel="stylesheet">
<style>
:root {
--ink: #0b1120;
--ink2: #3d4a60;
--ink3: #8a97ab;
--paper: #f0f2f8;
--w: #fff;
--blue: #1a5fff;
--bdim: #e8efff;
--teal: #00c9aa;
--tdim: #dcfaf5;
--red: #f03e5a;
--rdim: #fde8ec;
--orange: #f97316;
--odim: #fff3e8;
--purple: #7c3aed;
--pdim: #f0ebff;
--green: #10b981;
--gdim: #e5f8f1;
--yellow: #f59e0b;
--ydim: #fef3c7;
--bdr: rgba(11, 17, 32, .08);
--sh: 0 4px 24px rgba(11, 17, 32, .08);
--shm: 0 8px 40px rgba(11, 17, 32, .14);
--r: 14px;
--rs: 9px;
--glass: rgba(255, 255, 255, .88)
}
* {
box-sizing: border-box;
margin: 0;
padding: 0
}
html,
body {
height: 100%;
font-family: 'Plus Jakarta Sans', sans-serif;
background: var(--paper)
}
.page {
position: fixed;
inset: 0;
z-index: 10;
display: none
}
.page.active {
display: flex
}
/* LOGIN */
#pageLogin {
background: linear-gradient(145deg, #0b1a3e, #0d2560 40%, #091830);
align-items: center;
justify-content: center;
overflow: hidden;
flex-direction: column
}
.lbg {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden
}
.lblob {
position: absolute;
border-radius: 50%;
filter: blur(60px);
opacity: .14;
will-change: transform;
contain: strict
}
.lblob.b1 {
width: 500px;
height: 500px;
background: #1a5fff;
top: -120px;
right: -100px
}
.lblob.b2 {
width: 400px;
height: 400px;
background: #00c9aa;
bottom: -80px;
left: -80px
}
.lblob.b3 {
width: 300px;
height: 300px;
background: #7c3aed;
top: 40%;
left: 30%
}
.lgrid {
position: absolute;
inset: 0;
background-image: linear-gradient(rgba(255, 255, 255, .04)1px, transparent 1px), linear-gradient(90deg, rgba(255, 255, 255, .04)1px, transparent 1px);
background-size: 48px 48px
}
.lcard {
position: relative;
z-index: 2;
background: rgba(15, 30, 70, 0.82);
border: 1px solid rgba(255, 255, 255, .12);
border-radius: 24px;
padding: 44px 40px;
width: 440px;
max-width: calc(100vw - 32px);
box-shadow: 0 24px 80px rgba(0, 0, 0, .4)
}
.llogo {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 32px
}
.llogo-ic {
width: 44px;
height: 44px;
background: linear-gradient(135deg, #1a5fff, #00c9aa);
border-radius: 13px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
box-shadow: 0 4px 16px rgba(26, 95, 255, .4)
}
.llogo-tx {
font-family: 'Syne', sans-serif;
font-size: 22px;
color: #fff;
letter-spacing: -.5px
}
.llogo-sb {
font-size: 10px;
color: rgba(255, 255, 255, .4);
margin-top: 1px;
letter-spacing: .06em
}
.lh1 {
font-family: 'Syne', sans-serif;
font-size: 26px;
color: #fff;
margin-bottom: 6px;
letter-spacing: -.5px
}
.ldesc {
font-size: 13px;
color: rgba(255, 255, 255, .5);
margin-bottom: 28px;
line-height: 1.6
}
.llbl {
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, .5);
letter-spacing: .08em;
text-transform: uppercase;
margin-bottom: 6px
}
.linp {
width: 100%;
padding: 13px 16px;
background: rgba(255, 255, 255, .08);
border: 1px solid rgba(255, 255, 255, .14);
border-radius: 10px;
font-size: 14px;
color: #fff;
outline: none;
font-family: inherit;
transition: all .2s;
margin-bottom: 16px
}
.linp::placeholder {
color: rgba(255, 255, 255, .28)
}
.linp:focus {
background: rgba(255, 255, 255, .13);
border-color: rgba(26, 95, 255, .8);
box-shadow: 0 0 0 3px rgba(26, 95, 255, .18)
}
.btn-login {
width: 100%;
padding: 14px;
margin-top: 20px;
background: linear-gradient(90deg, #1a5fff, #00c9aa);
border: none;
border-radius: 11px;
color: #fff;
font-size: 14px;
font-weight: 700;
cursor: pointer;
font-family: inherit;
transition: all .2s;
box-shadow: 0 6px 24px rgba(26, 95, 255, .35)
}
.btn-login:hover {
transform: translateY(-1px);
box-shadow: 0 10px 32px rgba(26, 95, 255, .45)
}
.lerr {
display: none;
background: rgba(240, 62, 90, .15);
border: 1px solid rgba(240, 62, 90, .3);
border-radius: 8px;
padding: 10px 14px;
font-size: 12px;
color: #f9b4bf;
margin-top: 14px;
text-align: center
}
.lerr.show {
display: block
}
.btn-lapor {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 11px;
margin-top: 10px;
background: rgba(255, 255, 255, .08);
border: 1px solid rgba(255, 255, 255, .2);
border-radius: 11px;
color: rgba(255, 255, 255, .8);
font-size: 13px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: all .2s
}
.btn-lapor:hover {
background: rgba(255, 255, 255, .15);
border-color: rgba(255, 255, 255, .35)
}
.ldemo {
margin-top: 18px;
padding: 14px;
background: rgba(255, 255, 255, .05);
border: 1px solid rgba(255, 255, 255, .08);
border-radius: 10px;
font-size: 11px;
color: rgba(255, 255, 255, .35);
text-align: center;
line-height: 1.9
}
.ldemo b {
color: rgba(255, 255, 255, .55)
}
/* PUBLIC REPORT */
#pageRep {
background: linear-gradient(160deg, #0b1a3e, #0d2560 50%, #0a2040);
flex-direction: column;
overflow: hidden
}
.rh {
padding: 14px 24px;
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid rgba(255, 255, 255, .1);
flex-shrink: 0
}
.btn-back {
padding: 7px 14px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
background: rgba(255, 255, 255, .1);
border: 1px solid rgba(255, 255, 255, .15);
color: rgba(255, 255, 255, .7);
cursor: pointer;
font-family: inherit;
transition: all .2s
}
.btn-back:hover {
background: rgba(255, 255, 255, .18);
color: #fff
}
.rb-logo {
display: flex;
align-items: center;
gap: 10px;
margin-left: 4px
}
.rb-lic {
width: 34px;
height: 34px;
background: linear-gradient(135deg, #1a5fff, #00c9aa);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px
}
.rb-ltx {
font-family: 'Syne', sans-serif;
font-size: 16px;
color: #fff
}
.rb-lsb {
font-size: 10px;
color: rgba(255, 255, 255, .4);
margin-top: 1px
}
.rb-badge {
margin-left: auto;
background: rgba(0, 201, 170, .2);
border: 1px solid rgba(0, 201, 170, .3);
padding: 4px 12px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
color: #00c9aa;
letter-spacing: .05em
}
.rb-body {
flex: 1;
overflow-y: auto;
display: flex;
justify-content: center;
padding: 24px
}
.rb-card {
background: rgba(10, 28, 70, 0.82);
border: 1px solid rgba(255, 255, 255, .1);
border-radius: 20px;
padding: 32px;
width: 100%;
max-width: 640px;
height: fit-content;
box-shadow: 0 20px 60px rgba(0, 0, 0, .3)
}
.rb-h1 {
font-family: 'Syne', sans-serif;
font-size: 20px;
color: #fff;
margin-bottom: 6px
}
.rb-desc {
font-size: 13px;
color: rgba(255, 255, 255, .5);
margin-bottom: 28px;
line-height: 1.6
}
.rs-lbl {
font-size: 10px;
font-weight: 700;
color: rgba(255, 255, 255, .45);
letter-spacing: .1em;
text-transform: uppercase;
margin: 20px 0 10px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, .08)
}
.rf-lbl {
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, .5);
letter-spacing: .08em;
text-transform: uppercase;
margin-bottom: 6px
}
.rf-inp,
.rf-sel,
.rf-ta {
width: 100%;
padding: 12px 14px;
background: rgba(255, 255, 255, .08);
border: 1px solid rgba(255, 255, 255, .14);
border-radius: 10px;
font-size: 13px;
color: #fff;
outline: none;
font-family: inherit;
transition: all .2s;
margin-bottom: 14px
}
.rf-inp::placeholder,
.rf-ta::placeholder {
color: rgba(255, 255, 255, .28)
}
.rf-inp:focus,
.rf-sel:focus,
.rf-ta:focus {
background: rgba(255, 255, 255, .13);
border-color: rgba(26, 95, 255, .8);
box-shadow: 0 0 0 3px rgba(26, 95, 255, .18)
}
.rf-sel option {
background: #0d2560;
color: #fff
}
.rf-ta {
resize: vertical;
min-height: 80px
}
.rf-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px
}
.rf-map {
width: 100%;
height: 180px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, .15);
overflow: hidden;
cursor: crosshair;
position: relative;
margin-bottom: 8px
}
.rf-maplbl {
position: absolute;
top: 8px;
left: 50%;
transform: translateX(-50%);
background: rgba(11, 17, 32, .7);
color: #fff;
font-size: 11px;
font-weight: 600;
padding: 4px 12px;
border-radius: 999px;
pointer-events: none;
z-index: 10;
white-space: nowrap
}
.rf-coords {
display: flex;
gap: 8px;
margin-bottom: 14px
}
.rf-cbox {
flex: 1;
padding: 8px 11px;
background: rgba(255, 255, 255, .06);
border: 1px solid rgba(255, 255, 255, .1);
border-radius: 8px;
font-size: 12px;
color: rgba(255, 255, 255, .6);
font-family: inherit
}
.btn-srep {
width: 100%;
padding: 14px;
border-radius: 11px;
font-size: 14px;
font-weight: 700;
background: linear-gradient(90deg, #00c9aa, #1a5fff);
border: none;
color: #fff;
cursor: pointer;
font-family: inherit;
transition: all .2s;
margin-top: 8px;
box-shadow: 0 6px 24px rgba(0, 201, 170, .3)
}
.btn-srep:hover {
transform: translateY(-1px);
opacity: .92
}
.rep-ok {
display: none;
text-align: center;
padding: 32px 0
}
.rep-ok.show {
display: block
}
.rep-ok-ic {
font-size: 56px;
margin-bottom: 16px
}
.rep-ok-h1 {
font-family: 'Syne', sans-serif;
font-size: 22px;
color: #00c9aa;
margin-bottom: 8px
}
.rep-ok-desc {
font-size: 13px;
color: rgba(255, 255, 255, .5);
line-height: 1.6;
margin-bottom: 20px
}
.rep-ok-no {
background: rgba(0, 201, 170, .15);
border: 1px solid rgba(0, 201, 170, .3);
padding: 12px 20px;
border-radius: 12px;
display: inline-block;
font-family: 'Syne', sans-serif;
font-size: 18px;
color: #00c9aa
}
/* APP */
#pageApp {
flex-direction: column;
background: var(--paper);
display: none;
overflow: hidden
}
#pageApp.active {
display: flex
}
#topbar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 56px;
z-index: 900;
display: flex;
align-items: center;
background: rgba(255, 255, 255, .98);
border-bottom: 1px solid var(--bdr);
padding: 0 20px;
gap: 6px;
box-shadow: 0 2px 12px rgba(11, 17, 32, .06)
}
.brand {
display: flex;
align-items: center;
gap: 9px;
margin-right: 10px;
flex-shrink: 0
}
.brand-ic {
width: 30px;
height: 30px;
background: linear-gradient(135deg, #1a5fff, #00c9aa);
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px
}
.brand-tx {
font-family: 'Syne', sans-serif;
font-size: 14px;
color: var(--ink)
}
.brand-sb {
font-size: 10px;
color: var(--ink3)
}
.ndiv {
width: 1px;
height: 20px;
background: var(--bdr);
margin: 0 4px;
flex-shrink: 0
}
.np {
padding: 6px 13px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
color: var(--ink2);
cursor: pointer;
border: none;
background: transparent;
transition: all .18s;
font-family: inherit;
white-space: nowrap;
display: flex;
align-items: center;
gap: 5px
}
.np:hover {
background: rgba(26, 95, 255, .07);
color: var(--blue)
}
.np.active {
background: var(--bdim);
color: var(--blue);
font-weight: 700
}
.np.hidden {
display: none !important
}
.np .nbadge {
background: var(--red);
color: #fff;
font-size: 9px;
font-weight: 800;
min-width: 16px;
height: 16px;
border-radius: 99px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px
}
.tr {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0
}
.role-badge {
background: linear-gradient(90deg, #1a5fff, #00c9aa);
color: #fff;
font-size: 10px;
font-weight: 700;
padding: 4px 11px;
border-radius: 999px;
letter-spacing: .05em
}
.top-user {
font-size: 11px;
color: var(--ink3)
}
.top-user-btn {
display: flex;
align-items: center;
gap: 7px;
padding: 4px 10px 4px 4px;
border-radius: 99px;
border: 1px solid var(--bdr);
background: var(--paper);
cursor: pointer;
text-decoration: none;
transition: all .18s;
}
.top-user-btn:hover {
border-color: var(--blue);
background: var(--bdim)
}
.top-user-av {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 800;
color: #fff;
flex-shrink: 0
}
.top-user-info {
display: flex;
flex-direction: column;
line-height: 1.25
}
.top-user-name {
font-size: 11px;
font-weight: 700;
color: var(--ink)
}
.top-user-role {
font-size: 9px;
color: var(--ink3);
font-weight: 500
}
.btn-out {
font-size: 12px;
color: var(--ink3);
background: none;
border: 1px solid var(--bdr);
cursor: pointer;
padding: 5px 11px;
border-radius: 7px;
font-family: inherit;
transition: all .15s
}
.btn-out:hover {
color: var(--red);
border-color: var(--rdim);
background: var(--rdim)
}
/* MAP */
#map {
position: fixed;
top: 56px;
left: 0;
right: 0;
bottom: 0;
z-index: 0
}
.panel {
position: fixed;
z-index: 800;
background: rgba(248, 249, 252, 0.97);
border: 1px solid rgba(220, 226, 240, .9);
border-radius: var(--r);
box-shadow: var(--sh)
}
#panelLeft {
top: 70px;
left: 14px;
width: 252px;
max-height: calc(100vh - 100px);
padding: 16px 14px;
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: rgba(26,95,255,.18) transparent
}
#panelLeft::-webkit-scrollbar { width: 4px }
#panelLeft::-webkit-scrollbar-track { background: transparent }
#panelLeft::-webkit-scrollbar-thumb { background: rgba(26,95,255,.2); border-radius: 99px }
#panelLeft::-webkit-scrollbar-thumb:hover { background: rgba(26,95,255,.4) }
.ph {
font-family: 'Syne', sans-serif;
font-size: 13px;
color: var(--ink)
}
.ps {
font-size: 11px;
color: var(--ink3);
margin-top: 1px
}
.fl {
font-size: 10px;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--ink3);
margin-bottom: 5px
}
select.fs {
width: 100%;
background: rgba(255, 255, 255, .7);
border: 1px solid var(--bdr);
border-radius: var(--rs);
padding: 8px 10px;
font-size: 12px;
color: var(--ink);
outline: none;
cursor: pointer;
font-family: inherit;
transition: border-color .15s
}
select.fs:focus {
border-color: var(--blue);
background: #fff
}
.pdiv {
height: 1px;
background: var(--bdr)
}
.act-btn {
width: 100%;
padding: 9px 13px;
border-radius: var(--rs);
font-size: 12px;
font-weight: 700;
cursor: pointer;
border: none;
display: flex;
align-items: center;
gap: 8px;
transition: all .18s;
font-family: inherit
}
.btn-ri {
background: var(--tdim);
color: #007e6a
}
.btn-ri:hover {
background: #c2f5ed
}
.btn-km {
background: var(--bdim);
color: var(--blue)
}
.btn-km:hover {
background: #cfdeff
}
.btn-blank {
background: var(--rdim);
color: var(--red)
}
.btn-blank:hover {
background: #fac8d2
}
.btn-blank.on {
background: var(--red);
color: #fff
}
.btn-radar {
background: var(--pdim);
color: var(--purple)
}
.btn-radar:hover {
background: #ddd1fc
}
.btn-radar.on {
background: var(--purple);
color: #fff
}
.rw {
overflow: hidden;
transition: max-height .3s;
max-height: 0
}
.rw.open {
max-height: 60px
}
.rr {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px
}
input[type=range] {
flex: 1;
accent-color: var(--purple);
cursor: pointer
}
.rl {
font-size: 11px;
font-weight: 700;
color: var(--purple);
min-width: 38px;
text-align: right
}
#panelStats {
top: 70px;
right: 14px;
width: 216px;
padding: 14px 12px;
display: flex;
flex-direction: column;
gap: 7px
}
#panelLayers {
top: auto;
bottom: 170px;
right: 14px;
width: 216px;
padding: 12px 10px;
display: flex;
flex-direction: column;
gap: 4px
}
.si {
display: flex;
align-items: center;
gap: 9px;
padding: 9px 11px;
border-radius: var(--rs);
background: rgba(255, 255, 255, .6);
border: 1px solid var(--bdr)
}
.sic {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0
}
.sic.r {
background: var(--rdim)
}
.sic.g {
background: var(--gdim)
}
.sic.p {
background: var(--pdim)
}
.sic.b {
background: var(--bdim)
}
.sn {
font-family: 'Syne', sans-serif;
font-size: 17px;
color: var(--ink);
line-height: 1
}
.sl {
font-size: 10px;
color: var(--ink3);
margin-top: 1px
}
#panelLeg {
bottom: 24px;
left: 14px;
padding: 13px 15px;
min-width: 175px
}
.lt {
font-size: 10px;
font-weight: 700;
color: var(--ink2);
margin-bottom: 9px;
letter-spacing: .06em;
text-transform: uppercase
}
.li {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--ink2);
margin-bottom: 6px
}
.ld {
width: 9px;
height: 9px;
border-radius: 50%;
flex-shrink: 0
}
.lp {
opacity: .85
}
#panelList {
bottom: 24px;
left: 50%;
transform: translateX(-50%);
padding: 13px 15px;
width: 510px;
max-width: calc(100vw - 280px)
}
.ltt {
font-size: 10px;
font-weight: 700;
color: var(--ink2);
margin-bottom: 9px;
letter-spacing: .06em;
text-transform: uppercase
}
.rsc {
display: flex;
gap: 7px;
overflow-x: auto;
padding-bottom: 2px
}
.rsc::-webkit-scrollbar {
height: 3px
}
.rsc::-webkit-scrollbar-thumb {
background: var(--bdr);
border-radius: 99px
}
.rc {
flex-shrink: 0;
background: rgba(255, 255, 255, .7);
border: 1px solid var(--bdr);
border-radius: var(--rs);
padding: 9px 11px;
cursor: pointer;
transition: all .18s;
min-width: 135px
}
.rc:hover {
background: #fff;
box-shadow: 0 4px 16px rgba(26, 95, 255, .1);
border-color: var(--blue);
transform: translateY(-1px)
}
.rn {
font-size: 11px;
font-weight: 700;
color: var(--ink);
margin-bottom: 2px
}
.rs {
font-size: 10px;
color: var(--ink3);
margin-bottom: 5px
}
.rb2 {
height: 3px;
background: rgba(0, 0, 0, .07);
border-radius: 99px;
overflow: hidden
}
.rf2 {
height: 100%;
border-radius: 99px;
transition: width .4s
}
.rp {
font-size: 10px;
font-weight: 700;
margin-top: 3px
}
/* LAYER TOGGLE: OPSIONAL */
.opt-section {
margin-top: 2px
}
.opt-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 2px;
cursor: pointer;
user-select: none;
border-radius: 6px;
transition: background .15s
}
.opt-head:hover { background: rgba(26, 95, 255, .05) }
.opt-title {
font-size: 10px;
font-weight: 800;
letter-spacing: .09em;
text-transform: uppercase;
color: var(--ink3)
}
.opt-arrow {
font-size: 11px;
color: var(--ink3);
transition: transform .2s
}
.opt-body {
display: flex;
flex-direction: column;
gap: 2px;
padding: 2px 0 4px
}
.opt-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 6px;
border-radius: 8px;
cursor: pointer;
transition: background .15s;
position: relative
}
.opt-item:hover { background: rgba(26, 95, 255, .06) }
.opt-item input[type=checkbox] {
width: 14px;
height: 14px;
accent-color: var(--blue);
cursor: pointer;
flex-shrink: 0
}
.opt-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0
}
.opt-info {
display: flex;
flex-direction: column;
gap: 1px
}
.opt-name {
font-size: 12px;
font-weight: 600;
color: var(--ink)
}
.opt-sub {
font-size: 10px;
color: var(--ink3)
}
.opt-soon {
color: var(--orange);
font-weight: 600
}
.opt-disabled {
opacity: .5;
cursor: not-allowed
}
.opt-disabled:hover { background: transparent }
.opt-disabled input { cursor: not-allowed }
/* POPUP */
.leaflet-popup-content-wrapper {
background: #fff !important;
border: 1px solid rgba(220, 226, 240, .9) !important;
border-radius: 16px !important;
box-shadow: var(--shm) !important;
padding: 0 !important
}
.leaflet-popup-tip-container {
display: none !important
}
.leaflet-popup-content {
margin: 0 !important
}
.leaflet-popup-close-button {
display: none !important
}
.pw {
padding: 15px 17px;
min-width: 240px
}
.pt {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 10px
}
.pn {
font-family: 'Syne', sans-serif;
font-size: 13px;
color: var(--ink)
}
.pn2 {
font-size: 10px;
color: var(--ink3);
margin-top: 2px
}
.kb {
font-size: 10px;
font-weight: 700;
padding: 3px 9px;
border-radius: 999px;
letter-spacing: .04em;
white-space: nowrap;
margin-left: 8px;
flex-shrink: 0
}
.ksm {
background: var(--rdim);
color: var(--red)
}
.km {
background: var(--odim);
color: var(--orange)
}
.khm {
background: var(--bdim);
color: var(--blue)
}
.prows {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px
}
.pr {
display: flex;
justify-content: space-between;
gap: 12px
}
.pk {
font-size: 11px;
color: var(--ink3)
}
.pv {
font-size: 11px;
font-weight: 600;
color: var(--ink);
text-align: right;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap
}
.pv.teal {
color: var(--teal) !important
}
.pcta-row {
display: flex;
gap: 6px
}
.pcta {
flex: 1;
padding: 8px;
border-radius: 9px;
background: linear-gradient(90deg, #1a5fff, #00c9aa);
color: #fff;
font-size: 11px;
font-weight: 700;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
transition: opacity .15s;
font-family: inherit
}
.pcta:hover {
opacity: .88
}
.pcta.sec {
background: var(--paper);
color: var(--ink2);
border: 1px solid var(--bdr);
font-weight: 600
}
.pcta.sec:hover {
background: #e4e8f2
}
.pdel {
margin-top: 7px;
width: 100%;
padding: 8px;
border-radius: 9px;
background: var(--rdim);
color: var(--red);
border: none;
font-size: 12px;
font-weight: 700;
cursor: pointer;
font-family: inherit;
transition: all .15s
}
.pdel:hover {
background: var(--red);
color: #fff
}
/* TAB CONTENT */
.tc {
position: fixed;
top: 56px;
left: 0;
right: 0;
bottom: 0;
overflow-y: auto;
z-index: 5;
background: var(--paper);
display: none;
padding: 28px 28px 40px
}
.tc.active {
display: block
}
#tabMap {
padding: 0;
overflow: hidden;
background: transparent;
z-index: 0
}
#tabMap.active {
display: block
}
/* DASHBOARD */
.dash-h {
margin-bottom: 24px
}
.dash-t {
font-family: 'Syne', sans-serif;
font-size: 22px;
color: var(--ink);
margin-bottom: 4px
}
.dash-s {
font-size: 13px;
color: var(--ink3)
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
margin-bottom: 24px
}
.kpi {
background: #fff;
border-radius: var(--r);
padding: 20px;
border: 1px solid var(--bdr);
box-shadow: var(--sh);
position: relative;
overflow: hidden;
transition: transform .2s, box-shadow .2s
}
.kpi:hover {
transform: translateY(-2px);
box-shadow: var(--shm)
}
.kpi::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px
}
.kpi.cr::before {
background: var(--red)
}
.kpi.cb::before {
background: var(--blue)
}
.kpi.ct::before {
background: var(--teal)
}
.kpi.cp::before {
background: var(--purple)
}
.kpi-ic {
font-size: 28px;
margin-bottom: 10px
}
.kpi-v {
font-family: 'Syne', sans-serif;
font-size: 28px;
color: var(--ink);
line-height: 1;
margin-bottom: 5px
}
.kpi-l {
font-size: 12px;
color: var(--ink3);
font-weight: 500
}
.kpi-d {
font-size: 11px;
font-weight: 700;
margin-top: 6px;
color: var(--ink3)
}
.charts-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 16px;
margin-bottom: 16px
}
.charts-grid2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px
}
.ccard {
background: #fff;
border-radius: var(--r);
padding: 22px;
border: 1px solid var(--bdr);
box-shadow: var(--sh)
}
.ct {
font-family: 'Syne', sans-serif;
font-size: 13px;
color: var(--ink);
margin-bottom: 4px
}
.cs {
font-size: 11px;
color: var(--ink3);
margin-bottom: 16px
}
/* DATA TABLE */
.dh {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px
}
.dt {
font-family: 'Syne', sans-serif;
font-size: 20px;
color: var(--ink)
}
.dtbar {
background: #fff;
border-radius: var(--r);
padding: 14px 16px;
border: 1px solid var(--bdr);
box-shadow: var(--sh);
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap
}
.dts {
flex: 1;
min-width: 200px;
padding: 9px 14px;
background: var(--paper);
border: 1.5px solid var(--bdr);
border-radius: var(--rs);
font-size: 13px;
color: var(--ink);
outline: none;
font-family: inherit;
transition: border-color .2s
}
.dts:focus {
border-color: var(--blue)
}
.dtsel {
padding: 9px 12px;
background: var(--paper);
border: 1.5px solid var(--bdr);
border-radius: var(--rs);
font-size: 12px;
color: var(--ink2);
outline: none;
cursor: pointer;
font-family: inherit;
transition: border-color .2s
}
.dtsel:focus {
border-color: var(--blue)
}
.dtc {
font-size: 12px;
color: var(--ink3);
white-space: nowrap
}
.dtexp {
padding: 9px 16px;
background: var(--tdim);
color: #007e6a;
border: none;
border-radius: var(--rs);
font-size: 12px;
font-weight: 700;
cursor: pointer;
font-family: inherit;
transition: all .15s
}
.dtexp:hover {
background: #c2f5ed
}
.tw {
background: #fff;
border-radius: var(--r);
border: 1px solid var(--bdr);
box-shadow: var(--sh);
overflow: hidden
}
table.mt {
width: 100%;
border-collapse: collapse
}
table.mt th {
font-size: 11px;
font-weight: 700;
color: var(--ink2);
text-transform: uppercase;
letter-spacing: .08em;
padding: 14px 18px;
text-align: left;
background: #fafbfe;
border-bottom: 1.5px solid var(--bdr);
}
table.mt td {
font-size: 13px;
color: var(--ink);
padding: 14px 18px;
border-bottom: 1px solid rgba(11, 17, 32, 0.05);
vertical-align: middle;
}
table.mt tr:last-child td {
border-bottom: none
}
table.mt tr:hover td {
background: #fcfdfe;
}
.td-name {
font-weight: 600
}
.td-nik {
font-family: monospace;
font-size: 11px;
color: var(--ink3)
}
.td-act {
display: flex;
gap: 5px
}
.asm {
padding: 5px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
border: none;
cursor: pointer;
font-family: inherit;
transition: all .15s
}
.asm.b {
background: var(--bdim);
color: var(--blue)
}
.asm.b:hover {
background: var(--blue);
color: #fff
}
.asm.t {
background: var(--tdim);
color: #007e6a
}
.asm.t:hover {
background: var(--teal);
color: #fff
}
.asm.r {
background: var(--rdim);
color: var(--red)
}
.asm.r:hover {
background: var(--red);
color: #fff
}
.kb2 {
font-size: 10px;
font-weight: 700;
padding: 3px 9px;
border-radius: 999px
}
.kb2.sm {
background: var(--rdim);
color: var(--red)
}
.kb2.m {
background: var(--odim);
color: var(--orange)
}
.kb2.hm {
background: var(--bdim);
color: var(--blue)
}
.pgn {
display: flex;
align-items: center;
gap: 4px;
padding: 14px 16px;
justify-content: flex-end;
border-top: 1px solid var(--bdr)
}
.pgb {
width: 32px;
height: 32px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
border: 1px solid var(--bdr);
background: #fff;
cursor: pointer;
color: var(--ink2);
display: flex;
align-items: center;
justify-content: center;
transition: all .15s
}
.pgb:hover {
background: var(--bdim);
border-color: var(--blue);
color: var(--blue)
}
.pgb.active {
background: var(--blue);
color: #fff;
border-color: var(--blue)
}
.pgb:disabled {
opacity: .4;
cursor: not-allowed
}
/* LAPORAN */
.ls-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 20px
}
.ls {
background: #fff;
border-radius: var(--rs);
padding: 16px;
border: 1px solid var(--bdr);
display: flex;
align-items: center;
gap: 12px
}
.ls-ic {
font-size: 22px
}
.ls-v {
font-family: 'Syne', sans-serif;
font-size: 20px;
color: var(--ink)
}
.ls-l {
font-size: 11px;
color: var(--ink3);
margin-top: 1px
}
.sb {
font-size: 10px;
font-weight: 700;
padding: 3px 10px;
border-radius: 99px;
letter-spacing: .04em;
white-space: nowrap
}
.sb.menunggu {
background: var(--ydim);
color: #b45309
}
.sb.disetujui {
background: var(--gdim);
color: var(--green)
}
.sb.ditolak {
background: var(--rdim);
color: var(--red)
}
/* MODAL */
.mo {
position: fixed;
inset: 0;
z-index: 2000;
background: rgba(11, 17, 32, .45);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity .25s
}
.mo.open {
opacity: 1;
pointer-events: all
}
.modal {
background: #fff;
border-radius: 20px;
box-shadow: var(--shm);
width: 520px;
max-width: calc(100vw - 32px);
max-height: 90vh;
overflow-y: auto;
transform: translateY(24px) scale(.97);
transition: transform .28s cubic-bezier(.34, 1.2, .64, 1)
}
.mo.open .modal {
transform: translateY(0) scale(1)
}
.modal.lg {
width: 680px
}
.mh {
padding: 22px 24px 16px;
border-bottom: 1px solid var(--bdr);
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
background: #fff;
z-index: 2;
border-radius: 20px 20px 0 0
}
.mh-t {
font-family: 'Syne', sans-serif;
font-size: 16px;
color: var(--ink)
}
.mh-s {
font-size: 11px;
color: var(--ink3);
margin-top: 2px
}
.mc {
width: 30px;
height: 30px;
border-radius: 8px;
border: 1px solid var(--bdr);
background: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
color: var(--ink2);
transition: all .15s
}
.mc:hover {
background: var(--rdim);
color: var(--red);
border-color: transparent
}
.mb {
padding: 20px 24px
}
.mf {
padding: 14px 24px 20px;
display: flex;
gap: 10px;
justify-content: flex-end;
border-top: 1px solid var(--bdr)
}
.fg {
margin-bottom: 16px
}
.flb {
font-size: 11px;
font-weight: 700;
color: var(--ink3);
letter-spacing: .07em;
text-transform: uppercase;
margin-bottom: 6px
}
.fi,
.fsl,
.fta {
width: 100%;
padding: 11px 13px;
background: var(--paper);
border: 1.5px solid var(--bdr);
border-radius: var(--rs);
font-size: 13px;
color: var(--ink);
outline: none;
font-family: inherit;
transition: all .2s
}
.fi:focus,
.fsl:focus,
.fta:focus {
border-color: var(--blue);
background: #fff;
box-shadow: 0 0 0 3px rgba(26, 95, 255, .1)
}
.fta {
resize: vertical;
min-height: 72px
}
.fr {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px
}
.fmpp {
width: 100%;
height: 180px;
border-radius: 10px;
border: 1.5px solid var(--bdr);
overflow: hidden;
cursor: crosshair;
position: relative
}
.fmpl {
position: absolute;
top: 8px;
left: 50%;
transform: translateX(-50%);
background: rgba(11, 17, 32, .7);
color: #fff;
font-size: 11px;
font-weight: 600;
padding: 4px 12px;
border-radius: 999px;
pointer-events: none;
z-index: 10;
white-space: nowrap
}
.fmpc {
display: flex;
gap: 8px;
margin-top: 8px
}
.fmcb {
flex: 1;
padding: 8px 11px;
background: var(--paper);
border: 1.5px solid var(--bdr);
border-radius: 8px;
font-size: 12px;
color: var(--ink2);
font-family: inherit
}
.penyakit-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 6px;
margin-bottom: 4px
}
.pcb {
display: flex;
align-items: center;
gap: 7px;
font-size: 12px;
color: var(--ink2);
cursor: pointer;
padding: 7px 10px;
border-radius: 8px;
border: 1.5px solid var(--bdr);
background: var(--paper);
transition: all .15s;
user-select: none
}
.pcb:has(input:checked) {
border-color: var(--red);
background: var(--rdim);
color: var(--red);
font-weight: 600
}
.pcb input {
display: none
}
.at {
width: 100%;
border-collapse: collapse;
margin-bottom: 8px
}
.at th {
font-size: 10px;
font-weight: 700;
color: var(--ink3);
text-transform: uppercase;
letter-spacing: .06em;
padding: 6px 8px;
text-align: left;
background: var(--paper);
border-bottom: 1px solid var(--bdr)
}
.at td {
padding: 5px 3px;
border-bottom: 1px solid rgba(0, 0, 0, .04)
}
.ai {
width: 100%;
padding: 6px 8px;
background: #fff;
border: 1px solid var(--bdr);
border-radius: 6px;
font-size: 12px;
color: var(--ink);
outline: none;
font-family: inherit
}
.ai:focus {
border-color: var(--blue)
}
.asel {
font-size: 11px;
padding: 5px 8px;
background: #fff;
border: 1px solid var(--bdr);
border-radius: 6px;
font-family: inherit;
color: var(--ink);
outline: none;
width: 100%
}
.apy-wrap {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 5px 4px;
background: var(--paper);
border: 1px solid var(--bdr);
border-radius: 6px;
min-height: 38px;
align-content: flex-start
}
.apy-wrap label {
display: flex;
align-items: center;
gap: 3px;
font-size: 10px;
color: var(--ink2);
cursor: pointer;
white-space: nowrap;
user-select: none;
padding: 3px 7px;
border-radius: 20px;
border: 1px solid var(--bdr);
background: #fff;
transition: all .15s;
font-family: inherit
}
.apy-wrap label:has(input:checked) {
border-color: var(--red);
background: var(--rdim);
color: var(--red);
font-weight: 600
}
.apy-wrap input[type=checkbox] {
display: none
}
.btn-add {
padding: 8px 16px;
border-radius: 8px;
border: none;
background: linear-gradient(135deg, var(--blue) 0%, var(--teal) 100%);
color: #fff;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
box-shadow: 0 4px 12px rgba(26, 95, 255, .2);
transition: all .2s ease;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn-add:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(26, 95, 255, .3);
opacity: 0.95;
}
.btn-usr-prof {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 8px;
background: linear-gradient(135deg, var(--blue) 0%, var(--teal) 100%);
color: #fff;
font-size: 11.5px;
font-weight: 700;
border: none;
cursor: pointer;
font-family: inherit;
box-shadow: 0 2px 8px rgba(26, 95, 255, .15);
transition: all .2s;
}
.btn-usr-prof:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(26, 95, 255, .25);
opacity: 0.95;
}
.btn-add-a {
width: 100%;
padding: 8px;
border-radius: 8px;
border: 1.5px dashed var(--bdr);
background: none;
color: var(--ink3);
font-size: 12px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: all .15s
}
.btn-add-a:hover {
border-color: var(--blue);
color: var(--blue);
background: var(--bdim)
}
.btn-del-a {
padding: 4px 8px;
border-radius: 5px;
border: none;
background: var(--rdim);
color: var(--red);
font-size: 12px;
cursor: pointer;
transition: all .15s
}
.btn-del-a:hover {
background: var(--red);
color: #fff
}
.bt {
width: 100%;
border-collapse: collapse;
margin-bottom: 10px
}
.bt th {
font-size: 10px;
font-weight: 700;
color: var(--ink3);
text-transform: uppercase;
letter-spacing: .06em;
padding: 8px 10px;
text-align: left;
background: var(--paper);
border-bottom: 1px solid var(--bdr)
}
.bt td {
font-size: 12px;
color: var(--ink);
padding: 9px 10px;
border-bottom: 1px solid rgba(0, 0, 0, .04)
}
.bt tr:hover td {
background: var(--paper)
}
.btot {
text-align: right;
font-size: 12px;
color: var(--ink3);
margin-bottom: 12px;
padding: 0 2px
}
.btot b {
color: var(--ink)
}
.bform {
background: var(--paper);
border-radius: var(--rs);
padding: 16px;
border: 1px solid var(--bdr)
}
.bform-t {
font-size: 12px;
font-weight: 700;
color: var(--ink);
margin-bottom: 12px
}
.jb {
font-size: 10px;
font-weight: 700;
padding: 2px 8px;
border-radius: 99px
}
.jb.pkh {
background: #fef3c7;
color: #b45309
}
.jb.bpnt {
background: #dbeafe;
color: #1d4ed8
}
.jb.blt {
background: #fce7f3;
color: #9d174d
}
.jb.bansos {
background: #d1fae5;
color: #065f46
}
.jb.kip {
background: #ede9fe;
color: #6d28d9
}
.jb.kis {
background: #fee2e2;
color: #991b1b
}
.jb.lain {
background: var(--paper);
color: var(--ink3)
}
.lpd-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--bdr)
}
.lpd-row:last-child {
border-bottom: none
}
.lpd-k {
font-size: 12px;
color: var(--ink3)
}
.lpd-v {
font-size: 12px;
font-weight: 600;
color: var(--ink);
text-align: right;
max-width: 270px
}
.btn-cancel {
padding: 10px 20px;
border-radius: var(--rs);
font-size: 13px;
font-weight: 600;
border: 1.5px solid var(--bdr);
background: none;
cursor: pointer;
color: var(--ink2);
font-family: inherit;
transition: all .15s
}
.btn-cancel:hover {
background: var(--paper)
}
.btn-sub {
padding: 10px 24px;
border-radius: var(--rs);
font-size: 13px;
font-weight: 700;
border: none;
cursor: pointer;
color: #fff;
font-family: inherit;
transition: all .2s
}
.btn-sub.teal {
background: linear-gradient(90deg, #00c9aa, #009b85);
box-shadow: 0 4px 16px rgba(0, 201, 170, .3)
}
.btn-sub.blue {
background: linear-gradient(90deg, #1a5fff, #0040d0);
box-shadow: 0 4px 16px rgba(26, 95, 255, .3)
}
.btn-sub.green {
background: linear-gradient(90deg, #10b981, #059669);
box-shadow: 0 4px 16px rgba(16, 185, 129, .3)
}
.btn-sub:hover {
transform: translateY(-1px);
opacity: .92
}
.btn-approve {
flex: 1;
padding: 11px;
border-radius: var(--rs);
background: var(--green);
color: #fff;
border: none;
font-size: 13px;
font-weight: 700;
cursor: pointer;
font-family: inherit;
transition: all .2s
}
.btn-approve:hover {
background: #0ea371
}
.btn-reject {
flex: 1;
padding: 11px;
border-radius: var(--rs);
background: var(--rdim);
color: var(--red);
border: 1px solid transparent;
font-size: 13px;
font-weight: 700;
cursor: pointer;
font-family: inherit;
transition: all .2s
}
.btn-reject:hover {
background: var(--red);
color: #fff
}
.pick-pin {
background: var(--blue);
color: #fff;
font-size: 13px;
border-radius: 50%;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, .3)
}
#toast {
position: fixed;
bottom: 28px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--ink);
color: #fff;
font-size: 13px;
font-weight: 600;
padding: 10px 20px;
border-radius: 999px;
display: flex;
align-items: center;
gap: 8px;
z-index: 9999;
opacity: 0;
transition: all .3s cubic-bezier(.34, 1.56, .64, 1);
pointer-events: none
}
#toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0)
}
.leaflet-control-zoom {
border: 1px solid rgba(255, 255, 255, .65) !important;
border-radius: 11px !important;
overflow: hidden;
backdrop-filter: blur(16px);
box-shadow: var(--sh) !important
}
.leaflet-control-zoom a {
background: var(--glass) !important;
color: var(--ink) !important;
border-bottom: 1px solid var(--bdr) !important;
font-size: 14px !important;
line-height: 28px !important
}
.leaflet-control-zoom a:hover {
background: #fff !important;
color: var(--blue) !important
}
.leaflet-control-attribution {
background: rgba(255, 255, 255, .7) !important;
color: var(--ink3) !important;
font-size: 10px !important;
backdrop-filter: blur(8px);
border-radius: 8px 0 0 0 !important;
padding: 3px 8px !important
}
.sec-sep {
font-size: 11px;
font-weight: 700;
color: var(--ink3);
letter-spacing: .08em;
text-transform: uppercase;
margin: 8px 0 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--bdr)
}
</style>
</head>
<body>
<!-- LOADING OVERLAY (tampil default, hilang setelah checkSession) -->
<div id="pageLoading" style="position:fixed;inset:0;z-index:9999;background:linear-gradient(135deg,#0b1120 0%,#1a2540 100%);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:20px;">
<div style="display:flex;align-items:center;gap:12px">
<div style="width:40px;height:40px;border-radius:12px;background:linear-gradient(135deg,#1a5fff,#00c9aa);display:flex;align-items:center;justify-content:center;font-size:22px">📍</div>
<div>
<div style="font-size:20px;font-weight:700;color:#fff;letter-spacing:-.3px">GeoSosial</div>
<div style="font-size:11px;color:#8a97ab;margin-top:1px">Kota Pontianak</div>
</div>
</div>
<div style="width:36px;height:36px;border-radius:50%;border:3px solid rgba(255,255,255,.15);border-top-color:#1a5fff;animation:ldSpin .7s linear infinite"></div>
<style>@keyframes ldSpin{to{transform:rotate(360deg)}}</style>
</div>
<!-- LOGIN PAGE (tidak aktif — redirect ke index.php jika tidak ada sesi) -->
<div id="pageLogin" class="page">
<div class="lbg">
<div class="lblob b1"></div>
<div class="lblob b2"></div>
<div class="lblob b3"></div>
<div class="lgrid"></div>
</div>
<div class="lcard">
<div class="llogo">
<div class="llogo-ic">📍</div>
<div>
<div class="llogo-tx">GeoSosial</div>
<div class="llogo-sb">SISTEM INFORMASI SOSIAL — KOTA PONTIANAK</div>
</div>
</div>
<div class="lh1">Selamat Datang</div>
<div class="ldesc">Masuk untuk mengakses sistem informasi geografis kemiskinan dan bantuan sosial Kota Pontianak.
</div>
<div class="llbl">Username</div>
<input class="linp" id="liUser" type="text" placeholder="Masukkan username" autocomplete="username">
<div class="llbl">Password</div>
<input class="linp" id="liPass" type="password" placeholder="Masukkan password" autocomplete="current-password">
<button class="btn-login" onclick="doLogin()">Masuk ke Sistem</button>
<div class="lerr" id="loginErr">⚠ Username atau password salah.</div>
<button class="btn-lapor" onclick="showPublicReportPage()">📢 Laporkan Kondisi Kemiskinan (Publik)</button>
<div class="ldemo"><b>Demo Akun:</b><br>Admin: <b>admin</b>/<b>admin123</b> &nbsp;|&nbsp; Petugas:
<b>petugas</b>/<b>petugas123</b><br>Walikota: <b>walikota</b>/<b>wali2024</b> &nbsp;|&nbsp; Viewer:
<b>viewer</b>/<b>viewer123</b></div>
</div>
</div>
<!-- PUBLIC REPORT PAGE -->
<div id="pageRep" class="page">
<div class="rh">
<button class="btn-back" onclick="showPage('pageLogin')">← Kembali</button>
<div class="rb-logo">
<div class="rb-lic">📍</div>
<div>
<div class="rb-ltx">GeoSosial</div>
<div class="rb-lsb">KOTA PONTIANAK</div>
</div>
</div>
<div class="rb-badge">LAPORAN PUBLIK</div>
</div>
<div class="rb-body">
<div class="rb-card">
<div id="repFormWrap">
<div class="rb-h1">📢 Laporkan Kondisi Kemiskinan</div>
<div class="rb-desc">Bantu kami mengidentifikasi warga yang membutuhkan bantuan. Data akan diverifikasi
petugas Dinas Sosial. Laporan boleh anonim.</div>
<div class="rs-lbl">Data Pelapor (Opsional)</div>
<div class="rf-row">
<div>
<div class="rf-lbl">Nama Pelapor</div><input class="rf-inp" id="rep_pelapor"
placeholder="Nama Anda (boleh kosong)">
</div>
<div>
<div class="rf-lbl">No. HP / WhatsApp</div><input class="rf-inp" id="rep_hp" placeholder="0812xxxxx"
type="tel">
</div>
</div>
<div class="rs-lbl">Data Warga yang Dilaporkan</div>
<div class="rf-row">
<div>
<div class="rf-lbl">Nama Kepala Keluarga *</div><input class="rf-inp" id="rep_nama"
placeholder="Nama lengkap">
</div>
<div>
<div class="rf-lbl">NIK (jika diketahui)</div><input class="rf-inp" id="rep_nik"
placeholder="16 digit NIK" maxlength="16">
</div>
</div>
<div class="rf-row">
<div>
<div class="rf-lbl">Kelurahan *</div><select class="rf-sel" id="rep_kel"></select>
</div>
<div>
<div class="rf-lbl">Kategori Kondisi *</div>
<select class="rf-sel" id="rep_kat">
<option value="sangat_miskin">Sangat Miskin</option>
<option value="miskin">Miskin</option>
<option value="hampir_miskin">Hampir Miskin</option>
</select>
</div>
</div>
<div class="rf-row">
<div>
<div class="rf-lbl">Jumlah Anggota KK</div><input class="rf-inp" id="rep_jml" placeholder="cth. 4"
type="number" min="1">
</div>
<div>
<div class="rf-lbl">Perkiraan Penghasilan/Bln</div><input class="rf-inp" id="rep_ph" placeholder="Rp ..."
type="number" min="0">
</div>
</div>
<div class="rf-lbl">Keterangan Kondisi *</div>
<textarea class="rf-ta" id="rep_ket"
placeholder="Ceritakan kondisi warga: rumah, pekerjaan, kesehatan, dll..."></textarea>
<div class="rf-lbl">Lokasi Rumah (Klik peta)</div>
<div class="rf-map" id="repMap">
<div class="rf-maplbl" id="repMapLbl">🖱 Klik untuk memilih lokasi</div>
</div>
<div class="rf-coords"><input class="rf-cbox" id="rep_lat" placeholder="Latitude" readonly><input
class="rf-cbox" id="rep_lng" placeholder="Longitude" readonly></div>
<button class="btn-srep" onclick="submitPublicReport()">📨 Kirim Laporan</button>
</div>
<div class="rep-ok" id="repOk">
<div class="rep-ok-ic"></div>
<div class="rep-ok-h1">Laporan Berhasil Dikirim!</div>
<div class="rep-ok-desc">Laporan Anda diterima dan akan diverifikasi petugas Dinas Sosial Kota
Pontianak.<br>Simpan nomor laporan berikut:</div>
<div class="rep-ok-no" id="repOkNo">RPT-2026-XXX</div>
<button class="btn-lapor" style="margin-top:20px;max-width:300px;" onclick="resetRep()">📢 Buat Laporan
Baru</button>
<button class="btn-lapor" style="margin-top:8px;max-width:300px;" onclick="showPage('pageLogin')">← Kembali ke
Halaman Utama</button>
</div>
</div>
</div>
</div>
<!-- APP PAGE -->
<div id="pageApp" class="page">
<nav id="topbar">
<div class="brand">
<div class="brand-ic">📍</div>
<div>
<div class="brand-tx">GeoSosial</div>
<div class="brand-sb">Kota Pontianak</div>
</div>
</div>
<div class="ndiv"></div>
<button class="np active" id="nav-map" onclick="switchTab('map')">🗺️ Peta Spasial</button>
<button class="np" id="nav-dashboard" onclick="switchTab('dashboard')">📊 Dashboard</button>
<button class="np" id="nav-data" onclick="switchTab('data')">📋 Data Warga</button>
<button class="np hidden" id="nav-laporan" onclick="switchTab('laporan')">📩 Laporan <span class="nbadge"
id="lbadge" style="display:none">0</span></button>
<button class="np hidden" id="nav-pengguna" onclick="switchTab('pengguna')">👤 Pengguna</button>
<div class="tr">
<a class="top-user-btn" id="topUserBtn" href="profile.html" title="Lihat Profil Saya">
<div class="top-user-av" id="topUserAv">A</div>
<div class="top-user-info">
<span class="top-user-name" id="topUser"></span>
<span class="top-user-role" id="topRole">ADMIN</span>
</div>
</a>
<button class="btn-out" onclick="doLogout()">Keluar →</button>
</div>
</nav>
<!-- MAP TAB -->
<div id="tabMap" class="tc active">
<div id="map"></div>
<div class="panel" id="panelLeft">
<div>
<div class="ph">Filter Peta</div>
<div class="ps">Pemetaan spasial kemiskinan Pontianak</div>
</div>
<div>
<div class="fl">Kategori</div><select class="fs" id="fKat" onchange="applyFilter()">
<option value="semua">Semua Kategori</option>
<option value="sangat_miskin">Sangat Miskin</option>
<option value="miskin">Miskin</option>
<option value="hampir_miskin">Hampir Miskin</option>
</select>
</div>
<div>
<div class="fl">Kecamatan</div><select class="fs" id="fKec" onchange="applyFilter()">
<option value="semua">Semua Kecamatan</option>
<option>Pontianak Kota</option>
<option>Pontianak Selatan</option>
<option>Pontianak Barat</option>
<option>Pontianak Utara</option>
<option>Pontianak Timur</option>
<option>Pontianak Tenggara</option>
</select>
</div>
<div>
<div class="fl">Kelurahan</div><select class="fs" id="fKel" onchange="applyFilter()">
<option value="semua">Semua Kelurahan</option>
</select>
</div>
<div class="pdiv"></div>
<div style="display:flex;flex-direction:column;gap:6px" id="adminActions">
<button class="act-btn btn-ri" onclick="openModalRI()"> Tambah Rumah Ibadah</button>
<button class="act-btn btn-km" onclick="openModalKM()"> Tambah Data Warga</button>
</div>
<div class="pdiv"></div>
<div style="display:flex;flex-direction:column;gap:6px">
<button class="act-btn btn-blank" id="btnBlank" onclick="toggleBlank()">◉ Deteksi Blank Spot</button>
<button class="act-btn btn-radar" id="btnRadar" onclick="toggleRadar()">⚡ Radar Rumah Ibadah</button>
<div class="rw" id="radiusWrap">
<div class="rr"><input type="range" id="radSlider" min="100" max="1500" value="500" step="50"
oninput="updateRadius(this.value)"><span class="rl" id="radLbl">500m</span></div>
</div>
</div>
</div>
<div class="panel" id="panelStats">
<div class="si">
<div class="sic r">👥</div>
<div>
<div class="sn" id="sKK">0</div>
<div class="sl">Total KK Miskin</div>
</div>
</div>
<div class="si">
<div class="sic g">🕌</div>
<div>
<div class="sn" id="sRI">0</div>
<div class="sl">Rumah Ibadah</div>
</div>
</div>
<div class="si">
<div class="sic p"></div>
<div>
<div class="sn" id="sBS"></div>
<div class="sl">Blank Spot</div>
</div>
</div>
<div class="si">
<div class="sic b">📡</div>
<div>
<div class="sn" id="sCov"></div>
<div class="sl">Cakupan Radar</div>
</div>
</div>
</div>
<!-- PANEL LAYER TOGGLE (kanan bawah) -->
<div class="panel" id="panelLayers">
<div class="opt-section">
<div class="opt-head" onclick="toggleOptSection('optMain')">
<span class="opt-title">Tematik Utama</span>
<span class="opt-arrow" id="arrowOptMain"></span>
</div>
<div class="opt-body" id="optMain">
<label class="opt-item">
<input type="checkbox" id="layerWarga" checked onchange="toggleLayerWarga(this.checked)">
<div class="opt-dot" style="background:#f03e5a"></div>
<div class="opt-info">
<span class="opt-name">Kemiskinan</span>
<span class="opt-sub">Data KK miskin</span>
</div>
</label>
<label class="opt-item">
<input type="checkbox" id="layerRI" checked onchange="toggleLayerRI(this.checked)">
<div class="opt-dot" style="background:#10b981"></div>
<div class="opt-info">
<span class="opt-name">Rumah Ibadah</span>
<span class="opt-sub">Fasilitas ibadah</span>
</div>
</label>
</div>
</div>
<div class="opt-section">
<div class="opt-head" onclick="toggleOptSection('optOps')">
<span class="opt-title">Opsional</span>
<span class="opt-arrow" id="arrowOptOps"></span>
</div>
<div class="opt-body" id="optOps" style="display:none">
<label class="opt-item">
<input type="checkbox" id="layer_kesehatan" onchange="toggleLayerFasilitas('kesehatan', this.checked)">
<div class="opt-dot" style="background:#f59e0b"></div>
<div class="opt-info">
<span class="opt-name">🏥 Fasilitas Kesehatan</span>
<span class="opt-sub">Puskesmas & RS</span>
</div>
</label>
<label class="opt-item">
<input type="checkbox" id="layer_pendidikan" onchange="toggleLayerFasilitas('pendidikan', this.checked)">
<div class="opt-dot" style="background:#1a5fff"></div>
<div class="opt-info">
<span class="opt-name">🏫 Fasilitas Pendidikan</span>
<span class="opt-sub">Sekolah & Kampus</span>
</div>
</label>
<label class="opt-item">
<input type="checkbox" id="layer_pasar" onchange="toggleLayerFasilitas('pasar', this.checked)">
<div class="opt-dot" style="background:#7c3aed"></div>
<div class="opt-info">
<span class="opt-name">🛒 Pasar Tradisional</span>
<span class="opt-sub">Pasar Rakyat</span>
</div>
</label>
<label class="opt-item">
<input type="checkbox" id="layer_kelurahan" onchange="toggleLayerFasilitas('kelurahan', this.checked)">
<div class="opt-dot" style="background:#00c9aa"></div>
<div class="opt-info">
<span class="opt-name">🏛️ Kantor Kelurahan</span>
<span class="opt-sub">Kantor Lurah</span>
</div>
</label>
</div>
</div>
</div>
<div class="panel" id="panelLeg">
<div class="lt">Legenda</div>
<div class="li">
<div class="ld" style="background:#f03e5a"></div>Sangat Miskin
</div>
<div class="li">
<div class="ld" style="background:#f97316"></div>Miskin
</div>
<div class="li">
<div class="ld" style="background:#1a5fff"></div>Hampir Miskin
</div>
<div class="li">
<div class="ld" style="background:#10b981;border:2px solid #059669"></div>Rumah Ibadah
</div>
<div class="li">
<div class="ld lp" style="background:#7c3aed"></div>Blank Spot
</div>
</div>
<div class="panel" id="panelList">
<div class="ltt">Rumah Ibadah — Kapasitas Terpakai</div>
<div class="rsc" id="riList"></div>
</div>
</div>
<!-- DASHBOARD TAB -->
<div id="tabDashboard" class="tc">
<div class="dash-h">
<div class="dash-t">📊 Dashboard Eksekutif</div>
<div class="dash-s">Ringkasan Data Kemiskinan & Bantuan Sosial — Kota Pontianak</div>
<div style="font-size:12px;color:var(--ink3);margin-top:2px" id="dashDate"></div>
</div>
<div class="kpi-grid">
<div class="kpi cr">
<div class="kpi-ic">👥</div>
<div class="kpi-v" id="kpiKK">0</div>
<div class="kpi-l">Total KK Terdaftar</div>
<div class="kpi-d" id="kpiLap"></div>
</div>
<div class="kpi cb">
<div class="kpi-ic">🏠</div>
<div class="kpi-v" id="kpiSM">0</div>
<div class="kpi-l">Keluarga Sangat Miskin</div>
</div>
<div class="kpi ct">
<div class="kpi-ic">💰</div>
<div class="kpi-v" id="kpiBn">0</div>
<div class="kpi-l">Total Bantuan Disalurkan</div>
<div class="kpi-d" id="kpiBnNom"></div>
</div>
<div class="kpi cp">
<div class="kpi-ic">🏥</div>
<div class="kpi-v" id="kpiPy"></div>
<div class="kpi-l">Penyakit Terbanyak</div>
</div>
</div>
<div class="charts-grid">
<div class="ccard">
<div class="ct">KK Miskin per Kelurahan</div>
<div class="cs">10 kelurahan tertinggi</div>
<div style="height:260px;position:relative"><canvas id="chartKel"></canvas></div>
</div>
<div class="ccard">
<div class="ct">Distribusi Kategori</div>
<div class="cs">Proporsi tingkat kemiskinan</div>
<div style="height:260px;position:relative"><canvas id="chartKat"></canvas></div>
</div>
</div>
<div class="charts-grid2">
<div class="ccard">
<div class="ct">Program Bantuan Disalurkan</div>
<div class="cs">Jumlah penyaluran per program</div>
<div style="height:200px;position:relative"><canvas id="chartBn"></canvas></div>
</div>
<div class="ccard">
<div class="ct">Prevalensi Penyakit</div>
<div class="cs">Distribusi riwayat penyakit seluruh anggota</div>
<div style="height:200px;position:relative"><canvas id="chartPy"></canvas></div>
</div>
</div>
<div class="tw">
<div style="padding:20px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
<div class="ct">🏆 Ranking Kelurahan — Kemiskinan Tertinggi</div>
<div id="rankInfo" style="font-size:12px;color:var(--ink3)"></div>
</div>
<div class="cs" style="margin-bottom:16px">Data terkini seluruh kelurahan di Kota Pontianak</div>
<table class="mt">
<thead>
<tr>
<th>#</th>
<th>Kelurahan</th>
<th>Kecamatan</th>
<th>Sangat Miskin</th>
<th>Miskin</th>
<th>Hampir Miskin</th>
<th>Total</th>
<th>Total Bantuan</th>
</tr>
</thead>
<tbody id="dashTb"></tbody>
</table>
<div id="rankPgn" style="display:flex;align-items:center;justify-content:center;gap:6px;margin-top:16px;flex-wrap:wrap"></div>
</div>
</div>
</div>
<!-- DATA TAB -->
<div id="tabData" class="tc">
<div class="dh">
<div>
<div class="dt">📋 Manajemen Data Warga</div>
</div><button class="act-btn btn-km" style="width:auto;padding:10px 18px" id="addWBtn" onclick="openModalKM()">
Tambah Data Warga</button>
</div>
<div class="dtbar">
<input class="dts" id="dtSearch" type="search" placeholder="🔍 Cari nama, NIK, kelurahan..."
oninput="renderDT()">
<select class="dtsel" id="dtKat" onchange="renderDT()">
<option value="">Semua Kategori</option>
<option value="sangat_miskin">Sangat Miskin</option>
<option value="miskin">Miskin</option>
<option value="hampir_miskin">Hampir Miskin</option>
</select>
<select class="dtsel" id="dtKel" onchange="renderDT()">
<option value="">Semua Kelurahan</option>
</select>
<select class="dtsel" id="dtPy" onchange="renderDT()">
<option value="">Semua Kondisi</option>
<option value="hipertensi">Hipertensi</option>
<option value="diabetes">Diabetes</option>
<option value="tbc">TBC</option>
<option value="stunting">Stunting</option>
<option value="odgj">ODGJ</option>
</select>
<div class="dtc" id="dtCount">0 data</div>
<button class="dtexp" onclick="exportCSV()">⬇ Export CSV</button>
</div>
<div class="tw">
<table class="mt">
<thead>
<tr>
<th>#</th>
<th>Nama KK</th>
<th>Kelurahan</th>
<th>Kategori</th>
<th>Tgl Lahir</th>
<th>Pendidikan</th>
<th>Penghasilan</th>
<th>Penyakit</th>
<th>Bantuan</th>
<th>Aksi</th>
</tr>
</thead>
<tbody id="dtTb"></tbody>
</table>
<div class="pgn" id="pgn"></div>
</div>
</div>
<!-- LAPORAN TAB -->
<div id="tabLaporan" class="tc">
<div class="dh">
<div>
<div class="dt">📩 Laporan Masuk dari Masyarakat</div>
</div>
<button class="act-btn btn-km" id="addLapBtn" style="width:auto;padding:10px 18px" onclick="openModalLaporanAdmin()"> Buat Laporan</button>
</div>
<div class="ls-row">
<div class="ls">
<div class="ls-ic"></div>
<div>
<div class="ls-v" id="lsM">0</div>
<div class="ls-l">Menunggu Verifikasi</div>
</div>
</div>
<div class="ls">
<div class="ls-ic"></div>
<div>
<div class="ls-v" id="lsD">0</div>
<div class="ls-l">Disetujui</div>
</div>
</div>
<div class="ls">
<div class="ls-ic"></div>
<div>
<div class="ls-v" id="lsT">0</div>
<div class="ls-l">Ditolak</div>
</div>
</div>
</div>
<div class="tw">
<table class="mt">
<thead>
<tr>
<th>No. Laporan</th>
<th>Tanggal</th>
<th>Pelapor</th>
<th>Nama Dilaporkan</th>
<th>Kelurahan</th>
<th>Kategori</th>
<th>Status</th>
<th>Aksi</th>
</tr>
</thead>
<tbody id="lapTb"></tbody>
</table>
</div>
</div>
<!-- PENGGUNA TAB -->
<div id="tabPengguna" class="tc">
<div class="dh">
<div class="dt">👤 Manajemen Pengguna</div>
<button class="btn-add" id="addUsrBtn" onclick="openAddUser()" style="display:none">+ Tambah Pengguna</button>
</div>
<div class="tw">
<table class="mt">
<thead>
<tr>
<th>#</th>
<th>Username</th>
<th>Jabatan</th>
<th>Role</th>
<th>Hak Akses</th>
<th>Status</th>
<th>Aksi</th>
</tr>
</thead>
<tbody id="usrTb"></tbody>
</table>
</div>
</div>
<div id="toast"><span id="toastMsg"></span></div>
</div>
<!-- MODAL PROFIL PENGGUNA -->
<div class="mo" id="modalProfile">
<div class="modal">
<div class="mh">
<div>
<div class="mh-t" id="profModalTitle">👤 Profil Pengguna</div>
<div class="mh-s" id="profModalSub">Detail akun</div>
</div><button class="mc" onclick="closeModal('modalProfile')"></button>
</div>
<div class="mb">
<input type="hidden" id="prof_id">
<!-- Avatar -->
<div style="display:flex;align-items:center;gap:16px;padding:12px 0 18px">
<div id="profAvatar" style="width:56px;height:56px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:22px;font-weight:800;color:#fff;background:var(--blue);flex-shrink:0">A</div>
<div>
<div id="profUsername" style="font-size:18px;font-weight:800;font-family:monospace;color:var(--ink)"></div>
<div id="profCreated" style="font-size:11px;color:var(--ink3);margin-top:3px"></div>
</div>
</div>
<div class="fr">
<div class="fg">
<div class="flb">Jabatan</div>
<input class="fi" id="prof_jabatan" placeholder="cth. Kadis Sosial">
</div>
<div class="fg">
<div class="flb">Role</div>
<select class="fsl" id="prof_role">
<option value="admin">Admin</option>
<option value="petugas">Petugas</option>
<option value="walikota">Walikota</option>
<option value="viewer">Viewer</option>
</select>
</div>
</div>
<div class="fr">
<div class="fg">
<div class="flb">Hak Akses</div>
<select class="fsl" id="prof_can_edit">
<option value="1">✏️ Baca &amp; Tulis</option>
<option value="0">👁️ Baca Saja</option>
</select>
</div>
<div class="fg">
<div class="flb">Status</div>
<select class="fsl" id="prof_aktif">
<option value="1">● Aktif</option>
<option value="0">○ Nonaktif</option>
</select>
</div>
</div>
<div class="fg" id="prof_pw_wrap">
<div class="flb">Ganti Password <span style="font-size:10px;color:var(--ink3)">(kosongkan jika tidak diganti)</span></div>
<input class="fi" id="prof_password" type="password" placeholder="Min. 6 karakter" autocomplete="new-password">
</div>
</div>
<div class="mf" id="profFooter">
<button class="btn-cancel" onclick="closeModal('modalProfile')">Tutup</button>
<button class="btn-sub blue" id="profSaveBtn" onclick="saveUserProfile()">Simpan Perubahan</button>
</div>
</div>
</div>
<!-- MODAL TAMBAH PENGGUNA -->
<div class="mo" id="modalAddUser">
<div class="modal">
<div class="mh">
<div>
<div class="mh-t"> Tambah Pengguna Baru</div>
<div class="mh-s">Isi data akun pengguna baru</div>
</div><button class="mc" onclick="closeModal('modalAddUser')"></button>
</div>
<div class="mb">
<div class="fr">
<div class="fg">
<div class="flb">Username *</div>
<input class="fi" id="nu_username" placeholder="cth. budi_dinsos">
</div>
<div class="fg">
<div class="flb">Password *</div>
<input class="fi" id="nu_password" type="password" placeholder="Min. 6 karakter">
</div>
</div>
<div class="fr">
<div class="fg">
<div class="flb">Jabatan</div>
<input class="fi" id="nu_jabatan" placeholder="cth. Staf Verifikasi">
</div>
<div class="fg">
<div class="flb">Role</div>
<select class="fsl" id="nu_role">
<option value="admin">Admin</option>
<option value="petugas">Petugas</option>
<option value="walikota">Walikota</option>
<option value="viewer">Viewer</option>
</select>
</div>
</div>
</div>
<div class="mf">
<button class="btn-cancel" onclick="closeModal('modalAddUser')">Batal</button>
<button class="btn-sub blue" onclick="submitAddUser()">Tambahkan</button>
</div>
</div>
</div>
<!-- MODAL RUMAH IBADAH -->
<div class="mo" id="modalRI">
<div class="modal">
<div class="mh">
<div>
<div class="mh-t" id="riModalTitle">🕌 Tambah Rumah Ibadah</div>
<div class="mh-s" id="riModalSub">Klik mini-peta untuk menentukan lokasi</div>
</div><button class="mc" onclick="closeModal('modalRI')"></button>
</div>
<div class="mb">
<input type="hidden" id="ri_edit_id" value="">
<div class="fr">
<div class="fg">
<div class="flb">Nama *</div><input class="fi" id="ri_nama" placeholder="cth. Masjid Al-Falah">
</div>
<div class="fg">
<div class="flb">Tipe *</div><select class="fsl" id="ri_tipe">
<option value="masjid">🕌 Masjid</option>
<option value="gereja">⛪ Gereja</option>
<option value="pura">🛕 Pura</option>
<option value="vihara">🏛️ Vihara</option>
<option value="klenteng">🏯 Klenteng</option>
</select>
</div>
</div>
<div class="fr">
<div class="fg">
<div class="flb">Kapasitas</div><input class="fi" id="ri_kap" placeholder="cth. 200" type="number" min="1">
</div>
<div class="fg">
<div class="flb">Kelurahan</div><select class="fsl" id="ri_kel"></select>
</div>
</div>
<div class="fg">
<div class="flb">Lokasi — Klik peta *</div>
<div class="fmpp" id="riMapPick">
<div class="fmpl" id="riMapLbl">🖱 Klik untuk memilih lokasi</div>
</div>
<div class="fmpc"><input class="fmcb" id="ri_lat" placeholder="Latitude" readonly><input class="fmcb"
id="ri_lng" placeholder="Longitude" readonly></div>
</div>
<div class="fg">
<div class="flb">Alamat</div><input class="fi" id="ri_alamat" placeholder="Jl. ..., Pontianak">
</div>
</div>
<div class="mf"><button class="btn-cancel" onclick="closeModal('modalRI')">Batal</button><button
class="btn-sub teal" id="riSubmitBtn" onclick="submitRI()">Simpan</button></div>
</div>
</div>
<!-- MODAL DATA WARGA -->
<div class="mo" id="modalKM">
<div class="modal lg">
<div class="mh">
<div>
<div class="mh-t">👤 Tambah Data Warga</div>
<div class="mh-s">Lengkapi data kepala keluarga dan anggota</div>
</div><button class="mc" onclick="closeModal('modalKM')"></button>
</div>
<div class="mb">
<div class="sec-sep">Data Kepala Keluarga</div>
<div class="fr">
<div class="fg">
<div class="flb">Nama KK *</div><input class="fi" id="km_nama" placeholder="Nama lengkap">
</div>
<div class="fg">
<div class="flb">NIK</div><input class="fi" id="km_nik" placeholder="16 digit NIK" maxlength="16">
</div>
</div>
<div class="fr">
<div class="fg">
<div class="flb">Tanggal Lahir</div><input class="fi" id="km_tgl" type="date">
</div>
<div class="fg">
<div class="flb">Pendidikan Terakhir</div><select class="fsl" id="km_pend">
<option value="">— Pilih —</option>
<option value="tidak_sekolah">Tidak Sekolah</option>
<option value="sd">SD</option>
<option value="smp">SMP</option>
<option value="sma">SMA/SMK</option>
<option value="d3">D3/Diploma</option>
<option value="s1">S1/Sarjana</option>
<option value="s2">S2/Pasca</option>
</select>
</div>
</div>
<div class="fr">
<div class="fg">
<div class="flb">Kategori *</div><select class="fsl" id="km_kat">
<option value="sangat_miskin">Sangat Miskin</option>
<option value="miskin">Miskin</option>
<option value="hampir_miskin">Hampir Miskin</option>
</select>
</div>
<div class="fg">
<div class="flb">Kelurahan *</div><select class="fsl" id="km_kel"></select>
</div>
</div>
<div class="fr">
<div class="fg">
<div class="flb">Jumlah Anggota KK</div><input class="fi" id="km_jml" placeholder="cth. 4" type="number"
min="1" max="20">
</div>
<div class="fg">
<div class="flb">Penghasilan/Bulan (Rp)</div><input class="fi" id="km_ph" placeholder="cth. 500000"
type="number" min="0">
</div>
</div>
<div class="fg">
<div class="flb">Riwayat Penyakit KK</div>
<div class="penyakit-grid">
<label class="pcb"><input type="checkbox" value="hipertensi" class="pykk"> 🩸 Hipertensi</label>
<label class="pcb"><input type="checkbox" value="diabetes" class="pykk"> 🍬 Diabetes</label>
<label class="pcb"><input type="checkbox" value="tbc" class="pykk"> 🫁 TBC</label>
<label class="pcb"><input type="checkbox" value="asma" class="pykk"> 💨 Asma</label>
<label class="pcb"><input type="checkbox" value="stroke" class="pykk"> 🧠 Stroke</label>
<label class="pcb"><input type="checkbox" value="jantung" class="pykk"> ❤️ Jantung</label>
<label class="pcb"><input type="checkbox" value="odgj" class="pykk"> 🧩 ODGJ</label>
<label class="pcb"><input type="checkbox" value="stunting" class="pykk"> 📏 Stunting</label>
<label class="pcb"><input type="checkbox" value="kanker" class="pykk"> 🔬 Kanker</label>
</div>
</div>
<div class="fg">
<div class="flb">Lokasi Rumah — Klik peta *</div>
<div class="fmpp" id="kmMapPick">
<div class="fmpl" id="kmMapLbl">🖱 Klik untuk memilih lokasi</div>
</div>
<div class="fmpc"><input class="fmcb" id="km_lat" placeholder="Latitude" readonly><input class="fmcb"
id="km_lng" placeholder="Longitude" readonly></div>
</div>
<div class="fg">
<div class="flb">Keterangan</div><textarea class="fta" id="km_ket"
placeholder="Kondisi rumah, pekerjaan, dll..."></textarea>
</div>
<div class="sec-sep">Anggota Keluarga &amp; Riwayat Penyakit</div>
<table class="at" id="anggotaTable">
<thead>
<tr>
<th style="width:32%">Nama</th>
<th style="width:12%">Usia</th>
<th style="width:20%">Hubungan</th>
<th style="width:29%">Penyakit</th>
<th style="width:7%"></th>
</tr>
</thead>
<tbody id="anggotaBody"></tbody>
</table>
<button class="btn-add-a" onclick="addAnggotaRow()"> Tambah Anggota</button>
</div>
<div class="mf"><button class="btn-cancel" onclick="closeModal('modalKM')">Batal</button><button
class="btn-sub blue" onclick="submitKM()">Simpan Data</button></div>
</div>
</div>
<!-- MODAL EDIT WARGA -->
<div class="mo" id="modalEditWarga">
<div class="modal lg">
<div class="mh">
<div>
<div class="mh-t">✏️ Edit Data Warga</div>
<div class="mh-s" id="ewSubtitle">Perbarui data kepala keluarga dan anggota</div>
</div><button class="mc" onclick="closeModal('modalEditWarga')"></button>
</div>
<div class="mb">
<div class="sec-sep">Data Kepala Keluarga</div>
<div class="fr">
<div class="fg">
<div class="flb">Nama KK *</div><input class="fi" id="ew_nama" placeholder="Nama lengkap">
</div>
<div class="fg">
<div class="flb">NIK</div><input class="fi" id="ew_nik" placeholder="16 digit NIK" maxlength="16">
</div>
</div>
<div class="fr">
<div class="fg">
<div class="flb">Tanggal Lahir</div><input class="fi" id="ew_tgl" type="date">
</div>
<div class="fg">
<div class="flb">Pendidikan Terakhir</div><select class="fsl" id="ew_pend">
<option value="">— Pilih —</option>
<option value="tidak_sekolah">Tidak Sekolah</option>
<option value="sd">SD</option>
<option value="smp">SMP</option>
<option value="sma">SMA/SMK</option>
<option value="d3">D3/Diploma</option>
<option value="s1">S1/Sarjana</option>
<option value="s2">S2/Pasca</option>
</select>
</div>
</div>
<div class="fr">
<div class="fg">
<div class="flb">Kategori *</div><select class="fsl" id="ew_kat">
<option value="sangat_miskin">Sangat Miskin</option>
<option value="miskin">Miskin</option>
<option value="hampir_miskin">Hampir Miskin</option>
</select>
</div>
<div class="fg">
<div class="flb">Kelurahan *</div><select class="fsl" id="ew_kel"></select>
</div>
</div>
<div class="fr">
<div class="fg">
<div class="flb">Jumlah Anggota KK</div><input class="fi" id="ew_jml" placeholder="cth. 4" type="number" min="1" max="20">
</div>
<div class="fg">
<div class="flb">Penghasilan/Bulan (Rp)</div><input class="fi" id="ew_ph" placeholder="cth. 500000" type="number" min="0">
</div>
</div>
<div class="fg">
<div class="flb">Riwayat Penyakit KK</div>
<div class="penyakit-grid">
<label class="pcb"><input type="checkbox" value="hipertensi" class="ewpykk"> 🩸 Hipertensi</label>
<label class="pcb"><input type="checkbox" value="diabetes" class="ewpykk"> 🍬 Diabetes</label>
<label class="pcb"><input type="checkbox" value="tbc" class="ewpykk"> 🫁 TBC</label>
<label class="pcb"><input type="checkbox" value="asma" class="ewpykk"> 💨 Asma</label>
<label class="pcb"><input type="checkbox" value="stroke" class="ewpykk"> 🧠 Stroke</label>
<label class="pcb"><input type="checkbox" value="jantung" class="ewpykk"> ❤️ Jantung</label>
<label class="pcb"><input type="checkbox" value="odgj" class="ewpykk"> 🧩 ODGJ</label>
<label class="pcb"><input type="checkbox" value="stunting" class="ewpykk"> 📏 Stunting</label>
<label class="pcb"><input type="checkbox" value="kanker" class="ewpykk"> 🔬 Kanker</label>
</div>
</div>
<div class="fg">
<div class="flb">Lokasi Rumah — Klik peta untuk memperbarui</div>
<div class="fmpp" id="ewMapPick">
<div class="fmpl" id="ewMapLbl" style="display:none">🖱 Klik untuk memperbarui lokasi</div>
</div>
<div class="fmpc"><input class="fmcb" id="ew_lat" placeholder="Latitude" readonly><input class="fmcb" id="ew_lng" placeholder="Longitude" readonly></div>
</div>
<div class="fg">
<div class="flb">Keterangan</div><textarea class="fta" id="ew_ket" placeholder="Kondisi rumah, pekerjaan, dll..."></textarea>
</div>
<div class="sec-sep">Anggota Keluarga &amp; Riwayat Penyakit</div>
<table class="at" id="ewAnggotaTable">
<thead>
<tr>
<th style="width:32%">Nama</th>
<th style="width:12%">Usia</th>
<th style="width:20%">Hubungan</th>
<th style="width:29%">Penyakit</th>
<th style="width:7%"></th>
</tr>
</thead>
<tbody id="ewAnggotaBody"></tbody>
</table>
<button class="btn-add-a" onclick="addEwAnggotaRow()"> Tambah Anggota</button>
</div>
<div class="mf">
<button class="btn-cancel" onclick="closeModal('modalEditWarga')">Batal</button>
<button class="btn-sub blue" onclick="submitEditWarga()">💾 Simpan Perubahan</button>
</div>
</div>
</div>
<!-- MODAL HISTORI BANTUAN -->
<div class="mo" id="modalBantuan">
<div class="modal lg">
<div class="mh">
<div>
<div class="mh-t">💰 Histori Bantuan Sosial</div>
<div class="mh-s" id="bnWargaNama"></div>
</div><button class="mc" onclick="closeModal('modalBantuan')"></button>
</div>
<div class="mb">
<table class="bt">
<thead>
<tr>
<th>Tanggal</th>
<th>Program</th>
<th>Nominal</th>
<th>Sumber</th>
<th>Keterangan</th>
<th></th>
</tr>
</thead>
<tbody id="bnTb"></tbody>
</table>
<div class="btot" id="bnTot"></div>
<div id="bnEmpty" style="display:none;text-align:center;padding:20px;color:var(--ink3);font-size:13px">💸 Belum
ada riwayat bantuan</div>
<div class="bform" id="bnForm">
<div class="bform-t"> Tambah Penyaluran Bantuan</div>
<div class="fr">
<div class="fg" style="margin-bottom:0">
<div class="flb">Tanggal *</div><input class="fi" id="bh_tgl" type="date">
</div>
<div class="fg" style="margin-bottom:0">
<div class="flb">Program Bantuan *</div><select class="fsl" id="bh_jenis">
<option value="PKH">PKH (Program Keluarga Harapan)</option>
<option value="BPNT">BPNT (Bantuan Pangan Non Tunai)</option>
<option value="BLT">BLT (Bantuan Langsung Tunai)</option>
<option value="Bansos Pemkot">Bansos Pemkot Pontianak</option>
<option value="KIP">KIP (Kartu Indonesia Pintar)</option>
<option value="KIS">KIS (Kartu Indonesia Sehat)</option>
<option value="Sembako">Bantuan Sembako</option>
<option value="Lainnya">Lainnya</option>
</select>
</div>
</div>
<div class="fr" style="margin-top:10px">
<div class="fg" style="margin-bottom:0">
<div class="flb">Nominal (Rp)</div><input class="fi" id="bh_nom" type="number" placeholder="cth. 750000"
min="0">
</div>
<div class="fg" style="margin-bottom:0">
<div class="flb">Sumber Dana</div><select class="fsl" id="bh_src">
<option value="Kemensos RI">Kemensos RI</option>
<option value="APBD Kota Pontianak">APBD Kota Pontianak</option>
<option value="APBN">APBN</option>
<option value="Dana Desa">Dana Desa</option>
<option value="CSR / Donasi">CSR / Donasi</option>
</select>
</div>
</div>
<div class="fg" style="margin-top:10px;margin-bottom:0">
<div class="flb">Keterangan</div><input class="fi" id="bh_ket" placeholder="cth. Triwulan I 2026...">
</div>
<button class="btn-sub blue" style="margin-top:12px;padding:10px 20px" onclick="submitBantuan()">Simpan
Bantuan</button>
</div>
</div>
<div class="mf"><button class="btn-cancel" onclick="closeModal('modalBantuan')">Tutup</button></div>
</div>
</div>
<!-- MODAL ANGGOTA -->
<div class="mo" id="modalAnggota">
<div class="modal">
<div class="mh">
<div>
<div class="mh-t">👨‍👩‍👧 Data Anggota Keluarga</div>
<div class="mh-s" id="agWargaNama"></div>
</div><button class="mc" onclick="closeModal('modalAnggota')"></button>
</div>
<div class="mb" id="agBody"></div>
<div class="mf"><button class="btn-cancel" onclick="closeModal('modalAnggota')">Tutup</button></div>
</div>
</div>
<!-- MODAL LAPORAN ADMIN -->
<div class="mo" id="modalLaporanAdmin">
<div class="modal lg">
<div class="mh">
<div>
<div class="mh-t">📢 Buat Laporan Baru</div>
<div class="mh-s">Buat laporan kondisi kemiskinan warga</div>
</div><button class="mc" onclick="closeModal('modalLaporanAdmin')"></button>
</div>
<div class="mb">
<div class="sec-sep">Data Pelapor (Opsional)</div>
<div class="fr">
<div class="fg">
<div class="flb">Nama Pelapor</div><input class="fi" id="lap_adm_pelapor" placeholder="Nama pelapor (boleh kosong)">
</div>
<div class="fg">
<div class="flb">No. HP / WhatsApp</div><input class="fi" id="lap_adm_hp" placeholder="0812xxxxx" type="tel">
</div>
</div>
<div class="sec-sep">Data Warga yang Dilaporkan</div>
<div class="fr">
<div class="fg">
<div class="flb">Nama Kepala Keluarga *</div><input class="fi" id="lap_adm_nama" placeholder="Nama lengkap">
</div>
<div class="fg">
<div class="flb">NIK (jika diketahui)</div><input class="fi" id="lap_adm_nik" placeholder="16 digit NIK" maxlength="16">
</div>
</div>
<div class="fr">
<div class="fg">
<div class="flb">Kelurahan *</div><select class="fsl" id="lap_adm_kel"></select>
</div>
<div class="fg">
<div class="flb">Kategori Kondisi *</div>
<select class="fsl" id="lap_adm_kat">
<option value="sangat_miskin">Sangat Miskin</option>
<option value="miskin">Miskin</option>
<option value="hampir_miskin">Hampir Miskin</option>
</select>
</div>
</div>
<div class="fr">
<div class="fg">
<div class="flb">Jumlah Anggota KK</div><input class="fi" id="lap_adm_jml" placeholder="cth. 4" type="number" min="1">
</div>
<div class="fg">
<div class="flb">Perkiraan Penghasilan/Bln</div><input class="fi" id="lap_adm_ph" placeholder="Rp ..." type="number" min="0">
</div>
</div>
<div class="fg">
<div class="flb">Keterangan Kondisi *</div>
<textarea class="fta" id="lap_adm_ket" placeholder="Ceritakan kondisi warga: rumah, pekerjaan, kesehatan, dll..."></textarea>
</div>
<div class="fg">
<div class="flb">Lokasi Rumah — Klik peta</div>
<div class="fmpp" id="lapAdmMapPick">
<div class="fmpl" id="lapAdmMapLbl">🖱 Klik untuk memilih lokasi</div>
</div>
<div class="fmpc"><input class="fmcb" id="lap_adm_lat" placeholder="Latitude" readonly><input class="fmcb" id="lap_adm_lng" placeholder="Longitude" readonly></div>
</div>
</div>
<div class="mf"><button class="btn-cancel" onclick="closeModal('modalLaporanAdmin')">Batal</button><button class="btn-sub teal" onclick="submitLaporanAdmin()">Kirim Laporan</button></div>
</div>
</div>
<!-- MODAL LAPORAN DETAIL -->
<div class="mo" id="modalLaporan">
<div class="modal lg">
<div class="mh">
<div>
<div class="mh-t">📩 Detail Laporan Masyarakat</div>
<div class="mh-s" id="lapNo"></div>
</div><button class="mc" onclick="closeModal('modalLaporan')"></button>
</div>
<div class="mb">
<div id="lapDetailBody"></div>
<div id="lapMap"
style="width:100%;height:150px;border-radius:10px;overflow:hidden;border:1px solid var(--bdr);margin:12px 0;display:none">
</div>
</div>
<div class="mf" id="lapFoot"><button class="btn-cancel" onclick="closeModal('modalLaporan')">Tutup</button></div>
</div>
</div>
<script>
/* ══ STATE & CONSTANTS ══ */
let currentUser = null;
let kelurahanList = [];
let KEC = {};
let KEL = [];
const PL = { hipertensi: 'Hipertensi', diabetes: 'Diabetes', tbc: 'TBC', asma: 'Asma', stroke: 'Stroke', jantung: 'Jantung', odgj: 'ODGJ', stunting: 'Stunting', kanker: 'Kanker' };
const PLIK = { hipertensi: '🩸', diabetes: '🍬', tbc: '🫁', asma: '💨', stroke: '🧠', jantung: '❤️', odgj: '🧩', stunting: '📏', kanker: '🔬' };
const EDL = { '': '—', tidak_sekolah: 'Tdk Sekolah', sd: 'SD', smp: 'SMP', sma: 'SMA/SMK', d3: 'D3', s1: 'S1', s2: 'S2' };
const KL = { sangat_miskin: { label: 'Sangat Miskin', cls: 'ksm', col: '#f03e5a' }, miskin: { label: 'Miskin', cls: 'km', col: '#f97316' }, hampir_miskin: { label: 'Hampir Miskin', cls: 'khm', col: '#1a5fff' } };
const JL = { PKH: 'pkh', BPNT: 'bpnt', BLT: 'blt', 'Bansos Pemkot': 'bansos', KIP: 'kip', KIS: 'kis' };
let rumahIbadah = [];
let warga = [];
let laporan = [];
let curBantuanId = null, curLapId = null;
/* ══ PERFORMANCE CACHE ══ */
let _nearestCache = new Map(); // warga.id → { ri, jarak }
let _pairDist = new Map(); // ri.id → Map<wargaId, jarak>
let dataDirty = true; // flag: data perlu di-reload?
let sharedRenderer = null; // Leaflet shared canvas renderer
/* ══ MAP CONFIG ══ */
const CENTER = [-0.023, 109.342];
let map = null, mapReady = false;
let radius = 500, modeBlank = false, modeRadar = false;
let wMarkers = [], riMarkers = [], radarL = [], blankL = [];
let fasMarkers = { kesehatan: [], pendidikan: [], pasar: [], kelurahan: [] };
let fasData = { kesehatan: null, pendidikan: null, pasar: null, kelurahan: null };
let charts = {};
let riPickMap = null, riPickMk = null, kmPickMap = null, kmPickMk = null, repPickMap2 = null, repPickMk2 = null, lapAdmPickMap = null, lapAdmPickMk = null, ewPickMap = null, ewPickMk = null;
/* ══ AUTHENTICATION ══ */
async function doLogin() {
const u = document.getElementById('liUser').value.trim();
const p = document.getElementById('liPass').value;
const err = document.getElementById('loginErr');
if (!u || !p) { err.textContent = '⚠ Username dan password wajib diisi.'; err.classList.add('show'); return; }
try {
const res = await fetch('api/auth.php?action=login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u, password: p })
});
const json = await res.json();
if (json.success) {
currentUser = json.data;
err.classList.remove('show');
showPage('pageApp');
setTopbarUser(currentUser);
setupNav();
initApp();
} else {
err.textContent = '⚠ ' + json.message;
err.classList.add('show');
document.getElementById('liPass').value = '';
document.getElementById('liPass').focus();
}
} catch (e) {
err.textContent = '⚠ Koneksi ke server gagal.';
err.classList.add('show');
}
}
async function doLogout() {
try {
await fetch('api/auth.php?action=logout', { method: 'POST' });
} catch (e) { }
currentUser = null;
location.href = 'index.php';
}
async function checkSession() {
try {
const res = await fetch('api/auth.php?action=check');
const json = await res.json();
// Sembunyikan loading overlay
const ld = document.getElementById('pageLoading');
if (ld) ld.style.display = 'none';
if (json.success) {
currentUser = json.data;
showPage('pageApp');
setTopbarUser(currentUser);
setupNav();
initApp();
} else {
location.href = 'index.php';
}
} catch (e) {
location.href = 'index.php';
}
}
function setTopbarUser(u) {
const ROLE_LABELS = { admin: 'Super Admin', petugas: 'Petugas', walikota: 'Walikota', viewer: 'Viewer' };
const AV_COLORS = ['#10b981','#1a5fff','#f97316','#7c3aed','#f03e5a','#00c9aa'];
const initials = (u.username || 'U').substring(0, 2).toUpperCase();
const color = AV_COLORS[(u.id || 0) % AV_COLORS.length];
const av = document.getElementById('topUserAv');
if (av) { av.textContent = initials; av.style.background = color; }
const tu = document.getElementById('topUser');
if (tu) tu.textContent = u.jabatan || u.username;
const tr = document.getElementById('topRole');
if (tr) tr.textContent = ROLE_LABELS[u.role] || u.role.toUpperCase();
}
function setupNav() {
const lapNav = document.getElementById('nav-laporan');
const pgnNav = document.getElementById('nav-pengguna');
if (currentUser.is_admin) {
lapNav?.classList.remove('hidden');
pgnNav?.classList.remove('hidden');
} else if (currentUser.role === 'petugas' || currentUser.role === 'admin') {
lapNav?.classList.remove('hidden');
}
if (!currentUser.can_edit) {
const aa = document.getElementById('adminActions'); if (aa) aa.style.display = 'none';
const aw = document.getElementById('addWBtn'); if (aw) aw.style.display = 'none';
const al = document.getElementById('addLapBtn'); if (al) al.style.display = 'none';
} else {
const al = document.getElementById('addLapBtn'); if (al) al.style.display = '';
}
updateLapBadge();
}
function showPage(id) { document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); document.getElementById(id).classList.add('active'); }
document.getElementById('liPass').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
document.getElementById('liUser').addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('liPass').focus(); });
/* ══ TABS ══ */
async function switchTab(tab) {
document.querySelectorAll('.np').forEach(p => p.classList.remove('active'));
document.getElementById('nav-' + tab)?.classList.add('active');
document.querySelectorAll('.tc').forEach(t => t.classList.remove('active'));
document.getElementById('tab' + tab.charAt(0).toUpperCase() + tab.slice(1)).classList.add('active');
const mapPanels = ['panelLeft', 'panelStats', 'panelLeg', 'panelList', 'panelLayers'];
mapPanels.forEach(id => { const el = document.getElementById(id); if (el) el.style.display = tab === 'map' ? '' : 'none'; });
// Hanya loadData jika data ditandai dirty (ada perubahan sejak terakhir load)
if (currentUser && dataDirty) await loadData();
if (tab === 'map' && map) {
setTimeout(() => map.invalidateSize(), 100);
// Render ulang hanya jika data baru dimuat atau belum pernah render
if (!wMarkers.length || dataDirty) render();
else _updateOverlays();
}
else if (tab === 'dashboard') renderDashboard();
else if (tab === 'data') { populateDtKel(); renderDT(); }
else if (tab === 'laporan') renderLap();
else if (tab === 'pengguna') renderUsers();
}
/* ══ MASTER DATA & CORE DATA ══ */
async function fetchKelurahan() {
try {
const res = await fetch('api/kelurahan.php');
const json = await res.json();
if (json.success) {
kelurahanList = json.data;
KEL = kelurahanList.map(k => k.nama);
// Build KEC mapping dynamically
KEC = {};
kelurahanList.forEach(k => {
if (!KEC[k.kecamatan]) KEC[k.kecamatan] = [];
if (!KEC[k.kecamatan].includes(k.nama)) KEC[k.kecamatan].push(k.nama);
});
populateSelects();
}
} catch (e) {
console.error('Error fetching kelurahan:', e);
}
}
function getKec(kel) { for (const [k, v] of Object.entries(KEC)) if (v.includes(kel)) return k; return '—'; }
async function loadData(force = false) {
// Hanya fetch ulang jika data ditandai dirty atau dipaksa
if (!force && !dataDirty) return;
try {
const [resW, resR] = await Promise.all([
fetch('api/warga.php'),
fetch('api/rumah_ibadah.php')
]);
const jsonW = await resW.json();
const jsonR = await resR.json();
if (jsonW.success) {
warga = jsonW.data.map(w => ({
id: w.id, nama: w.nama, nik: w.nik || '',
kel: w.kelurahan || '',
lat: parseFloat(w.lat), lng: parseFloat(w.lng),
kat: w.kategori, jml: parseInt(w.jumlah_anggota) || 1,
ph: parseInt(w.penghasilan) || 0, tgl: w.tgl_lahir || '',
pend: w.pendidikan || '', rp: w.riwayat_penyakit || [],
anggota: (w.anggota || []).map(a => ({
id: a.id, nama: a.nama, usia: parseInt(a.usia) || 0,
hub: a.hubungan || 'Anak', py: a.penyakit || []
})),
bantuan: (w.histori_bantuan || []).map(b => ({
id: b.id, tgl: b.tgl || b.tanggal, jenis: b.jenis,
nom: parseInt(b.nominal) || 0, src: b.sumber || b.sumber_dana || '',
ket: b.keterangan || ''
})),
status: w.status, ket: w.keterangan || '',
pelapor: w.pelapor, no_laporan: w.no_laporan
}));
}
if (jsonR.success) {
rumahIbadah = jsonR.data.map(ri => ({
id: ri.id, nama: ri.nama, tipe: ri.tipe, ikon: ri.ikon,
lat: parseFloat(ri.lat), lng: parseFloat(ri.lng),
kap: parseInt(ri.kapasitas) || 0, kel: ri.kelurahan || '', alamat: ri.alamat || ''
}));
}
if (currentUser && (currentUser.is_admin || currentUser.role === 'admin' || currentUser.role === 'petugas')) {
const resL = await fetch('api/laporan.php');
const jsonL = await resL.json();
if (jsonL.success) laporan = jsonL.data;
}
// Precompute nearest cache setelah data fresh
_buildNearestCache();
dataDirty = false;
} catch (e) {
console.error('Error loading data:', e);
}
}
/* Precompute nearest RI dan semua jarak pasangan RI×warga — O(n×m) sekali saja */
function _buildNearestCache() {
_nearestCache.clear();
_pairDist.clear();
// 1. Precompute semua jarak RI×warga
rumahIbadah.forEach(ri => {
const wDists = new Map();
warga.forEach(w => { wDists.set(w.id, Math.round(dist(w, ri))); });
_pairDist.set(ri.id, wDists);
});
// 2. Nearest per warga pakai hasil precompute
warga.forEach(w => {
let best = null, bd = Infinity;
rumahIbadah.forEach(ri => {
const d = _pairDist.get(ri.id)?.get(w.id) ?? Infinity;
if (d < bd) { bd = d; best = ri; }
});
_nearestCache.set(w.id, { ri: best, jarak: Math.round(bd) });
});
}
function populateSelects() {
['ri_kel', 'km_kel', 'rep_kel', 'lap_adm_kel', 'ew_kel'].forEach(id => {
const el = document.getElementById(id); if (!el) return;
el.innerHTML = '<option value="">— Pilih —</option>';
kelurahanList.forEach(k => { const o = document.createElement('option'); o.value = k.id; o.textContent = k.nama; el.appendChild(o); });
});
const fk = document.getElementById('fKel');
if (fk) {
fk.innerHTML = '<option value="semua">Semua Kelurahan</option>';
kelurahanList.forEach(k => { const o = document.createElement('option'); o.value = k.nama; o.textContent = k.nama; fk.appendChild(o); });
}
}
function populateDtKel() {
const el = document.getElementById('dtKel'); if (!el || el.options.length > 1) return;
kelurahanList.forEach(k => { const o = document.createElement('option'); o.value = k.nama; o.textContent = k.nama; el.appendChild(o); });
}
function dist(a, b) { const R = 6371000, dLa = (b.lat - a.lat) * Math.PI / 180, dLo = (b.lng - a.lng) * Math.PI / 180; const x = Math.sin(dLa / 2) ** 2 + Math.cos(a.lat * Math.PI / 180) * Math.cos(b.lat * Math.PI / 180) * Math.sin(dLo / 2) ** 2; return R * 2 * Math.atan2(Math.sqrt(x), Math.sqrt(1 - x)); }
/* nearest() — gunakan cache jika tersedia, hitung langsung jika belum */
function nearest(w) { return _nearestCache.get(w.id) || (function(){ let best = null, bd = Infinity; rumahIbadah.forEach(r => { const d = dist(w, r); if (d < bd) { bd = d; best = r; } }); return { ri: best, jarak: Math.round(bd) }; })(); }
function riColor(t) { return { masjid: '#10b981', gereja: '#1a5fff', pura: '#f59e0b', vihara: '#7c3aed', klenteng: '#e11d48' }[t] || '#10b981'; }
function riIkon(t) { return { masjid: '🕌', gereja: '⛪', pura: '🛕', vihara: '🏛️', klenteng: '🏯' }[t] || '🛕'; }
function fmtRp(n) { if (!n) return '—'; return 'Rp ' + parseInt(n).toLocaleString('id-ID'); }
function fmtD(d) { if (!d) return '—'; return new Date(d).toLocaleDateString('id-ID', { day: '2-digit', month: 'short', year: 'numeric' }); }
function calcAge(d) { if (!d) return null; const t = new Date(), b = new Date(d); let a = t.getFullYear() - b.getFullYear(); if (t.getMonth() < b.getMonth() || (t.getMonth() === b.getMonth() && t.getDate() < b.getDate())) a--; return a; }
function clearAll() {
[...wMarkers, ...riMarkers, ...radarL, ...blankL].forEach(l => map.removeLayer(l));
wMarkers = []; riMarkers = []; radarL = []; blankL = [];
['kesehatan', 'pendidikan', 'pasar', 'kelurahan'].forEach(kat => {
if (fasMarkers[kat]) {
fasMarkers[kat].forEach(m => { if (map.hasLayer(m)) map.removeLayer(m); });
}
});
}
function getFiltered() {
const fk = document.getElementById('fKat')?.value || 'semua';
const fl = document.getElementById('fKel')?.value || 'semua';
const fc = document.getElementById('fKec')?.value || 'semua';
return warga.filter(w => w.status === 'aktif' && (fk === 'semua' || w.kat === fk) && (fl === 'semua' || w.kel === fl) && (fc === 'semua' || (KEC[fc] || []).includes(w.kel)));
}
/* ══ MAP RENDER ══ */
function render() {
if (!mapReady) return;
clearAll();
document.getElementById('sBS').textContent = '—';
document.getElementById('sCov').textContent = '—';
const vis = getFiltered();
// Gunakan shared canvas renderer — BUKAN L.canvas() per marker!
const renderer = sharedRenderer;
vis.forEach(w => {
const k = KL[w.kat]; const n = nearest(w); const age = calcAge(w.tgl);
const py = w.rp?.length ? w.rp.map(p => PL[p] || p).join(', ') : 'Tidak Ada';
const m = L.circleMarker([w.lat, w.lng], { radius: 9, color: '#fff', weight: 2, fillColor: k.col, fillOpacity: .9, renderer }).addTo(map);
const del = currentUser?.can_edit ? `<button class="pdel" onclick="deleteW('${w.id}')">🗑 Hapus Data Ini</button>` : '';
m.bindPopup(`<div class="pw">
<div class="pt"><div><div class="pn">${w.nama}</div><div class="pn2">NIK: ${w.nik || '—'}</div></div><span class="kb ${k.cls}">${k.label}</span></div>
<div class="prows">
<div class="pr"><span class="pk">Kelurahan</span><span class="pv">${w.kel}</span></div>
${age !== null ? `<div class="pr"><span class="pk">Usia</span><span class="pv">${age} thn (${fmtD(w.tgl)})</span></div>` : ''}
<div class="pr"><span class="pk">Pendidikan</span><span class="pv">${EDL[w.pend || '']}</span></div>
<div class="pr"><span class="pk">Anggota KK</span><span class="pv">${w.jml || '—'} orang</span></div>
<div class="pr"><span class="pk">Penghasilan</span><span class="pv">${fmtRp(w.ph)}</span></div>
<div class="pr"><span class="pk">Penyakit</span><span class="pv" style="color:var(--red)">${py}</span></div>
<div class="pr"><span class="pk">RI Terdekat</span><span class="pv">${n.ri ? n.ri.nama : '—'}</span></div>
<div class="pr"><span class="pk">Jarak</span><span class="pv teal">${n.jarak}m</span></div>
<div class="pr"><span class="pk">Riwayat Bantuan</span><span class="pv">${w.bantuan?.length || 0} kali</span></div>
</div>
<div class="pcta-row">
<button class="pcta sec" onclick="openAnggota('${w.id}')">👨‍👩‍👧 Anggota</button>
<button class="pcta" onclick="openBantuan('${w.id}')">💰 Bantuan</button>
</div>
${del}</div>`, { maxWidth: 290, closeButton: false });
wMarkers.push(m);
});
rumahIbadah.forEach(ri => {
const col = riColor(ri.tipe);
const html = `<div style="width:34px;height:34px;background:${col};border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:17px;border:3px solid #fff;box-shadow:0 2px 10px rgba(0,0,0,.22)">${ri.ikon || riIkon(ri.tipe)}</div>`;
const icon = L.divIcon({ html, iconSize: [34, 34], iconAnchor: [17, 17], className: '' });
const m = L.marker([ri.lat, ri.lng], { icon }).addTo(map);
const actRI = currentUser?.can_edit ? `<div class="pctas" style="margin-top:8px;display:flex;gap:8px"><button class="pcta" onclick="openEditRI(${ri.id})">✏️ Edit</button><button class="pcta" style="background:var(--rdim);color:var(--red);border-color:var(--red)" onclick="deleteRI(${ri.id})">🗑 Hapus</button></div>` : '';
m.bindPopup(`<div class="pw"><div class="pt"><div class="pn">${ri.nama}</div><span class="kb khm">${ri.tipe}</span></div><div class="prows"><div class="pr"><span class="pk">Kelurahan</span><span class="pv">${ri.kel || '—'}</span></div><div class="pr"><span class="pk">Kapasitas</span><span class="pv">${ri.kap} jemaah</span></div><div class="pr"><span class="pk">Alamat</span><span class="pv">${ri.alamat || '—'}</span></div></div>${actRI}</div>`, { maxWidth: 260, closeButton: false });
m.bindTooltip(`<b>${ri.nama}</b>`, { direction: 'top' });
riMarkers.push(m);
});
if (modeRadar) renderRadar(vis);
if (modeBlank) renderBlank(vis);
updateStats(vis); buildList(vis);
// Terapkan kembali state toggle layer setelah rebuild marker
if (!layerWargaVisible) toggleLayerWarga(false);
if (!layerRIVisible) toggleLayerRI(false);
['kesehatan', 'pendidikan', 'pasar', 'kelurahan'].forEach(kat => {
const cb = document.getElementById('layer_' + kat);
if (cb && cb.checked) {
if (fasMarkers[kat]) {
fasMarkers[kat].forEach(m => { if (!map.hasLayer(m)) m.addTo(map); });
}
}
});
}
/* Hanya update overlay (radar/blank) tanpa rebuild semua marker warga/RI */
function _updateOverlays() {
if (!mapReady) return;
[...radarL, ...blankL].forEach(l => map.removeLayer(l));
radarL = []; blankL = [];
const vis = getFiltered();
if (modeRadar) renderRadar(vis);
if (modeBlank) renderBlank(vis);
updateStats(vis);
buildList(vis);
}
function renderRadar(vis) {
// Pakai _pairDist precompute — zero distance calculation!
const visSet = new Set(vis.map(w => w.id));
rumahIbadah.forEach(ri => {
const wDists = _pairDist.get(ri.id) || new Map();
let wIn = 0;
wDists.forEach((d, wId) => { if (d <= radius && visSet.has(wId)) wIn++; });
const col = wIn > 12 ? '#f03e5a' : wIn > 5 ? '#f97316' : '#7c3aed';
const c = L.circle([ri.lat, ri.lng], { radius, color: col, fillColor: col, fillOpacity: .07, weight: 1.5, dashArray: '5 4', opacity: .55 }).addTo(map);
radarL.push(c);
});
}
function renderBlank(vis) {
let bs = 0;
vis.forEach(w => {
const n = nearest(w);
if (n.jarak > radius) {
bs++;
const c = L.circleMarker([w.lat, w.lng], { radius: 16, color: '#7c3aed', fillColor: '#7c3aed', fillOpacity: .15, weight: 2, opacity: .7, dashArray: '4 3' }).addTo(map);
c.bindTooltip(`⚠ Blank Spot: ${w.nama}<br>${n.jarak}m`, { direction: 'top' });
blankL.push(c);
}
});
document.getElementById('sBS').textContent = bs;
}
function updateStats(vis) {
document.getElementById('sKK').textContent = vis.length;
document.getElementById('sRI').textContent = rumahIbadah.length;
if (!modeBlank) document.getElementById('sBS').textContent = '—';
if (!modeRadar) { document.getElementById('sCov').textContent = '—'; return; }
// Pakai _pairDist untuk coverage — akurat per RI bukan nearest saja
const visSet = new Set(vis.map(w => w.id));
const cov = new Set();
rumahIbadah.forEach(ri => {
const wDists = _pairDist.get(ri.id) || new Map();
wDists.forEach((d, wId) => { if (d <= radius && visSet.has(wId)) cov.add(wId); });
});
document.getElementById('sCov').textContent = vis.length ? Math.round(cov.size / vis.length * 100) + '%' : '—';
}
function buildList(vis) {
const el = document.getElementById('riList'); el.innerHTML = '';
const visSet = new Set(vis.map(w => w.id));
rumahIbadah.forEach(ri => {
// Pakai _pairDist: hitung warga yang benar-benar dalam radius RI ini
const wDists = _pairDist.get(ri.id) || new Map();
let wIn = 0;
wDists.forEach((d, wId) => { if (d <= radius && visSet.has(wId)) wIn++; });
const pct = Math.min(100, Math.round(wIn / ri.kap * 100));
const col = pct > 50 ? '#f03e5a' : pct > 25 ? '#f97316' : '#10b981';
const d2 = document.createElement('div'); d2.className = 'rc';
d2.innerHTML = `<div class="rn">${ri.ikon || riIkon(ri.tipe)} ${ri.nama}</div><div class="rs">${wIn} warga dalam radius</div><div class="rb2"><div class="rf2" style="width:${pct}%;background:${col}"></div></div><div class="rp" style="color:${col}">${pct}%</div>`;
d2.onclick = () => map.setView([ri.lat, ri.lng], 16, { animate: true }); el.appendChild(d2);
});
}
function toggleBlank() { modeBlank = !modeBlank; document.getElementById('btnBlank').classList.toggle('on', modeBlank); _updateOverlays(); }
function toggleRadar() { modeRadar = !modeRadar; document.getElementById('btnRadar').classList.toggle('on', modeRadar); document.getElementById('radiusWrap').classList.toggle('open', modeRadar); _updateOverlays(); }
function updateRadius(v) { radius = parseInt(v); document.getElementById('radLbl').textContent = v + 'm'; _updateOverlays(); }
function applyFilter() { render(); }
/* ══ LAYER TOGGLE OPSIONAL ══ */
let layerWargaVisible = true;
let layerRIVisible = true;
function toggleOptSection(id) {
const body = document.getElementById(id);
const isOpen = body.style.display !== 'none';
body.style.display = isOpen ? 'none' : '';
const sectionId = id === 'optMain' ? 'arrowOptMain' : 'arrowOptOps';
document.getElementById(sectionId).textContent = isOpen ? '▸' : '▾';
}
function toggleLayerWarga(visible) {
layerWargaVisible = visible;
wMarkers.forEach(m => {
if (visible) { if (!map.hasLayer(m)) m.addTo(map); }
else { if (map.hasLayer(m)) map.removeLayer(m); }
});
}
function toggleLayerRI(visible) {
layerRIVisible = visible;
riMarkers.forEach(m => {
if (visible) { if (!map.hasLayer(m)) m.addTo(map); }
else { if (map.hasLayer(m)) map.removeLayer(m); }
});
}
async function toggleLayerFasilitas(kategori, visible) {
if (visible) {
if (!fasData[kategori]) {
try {
const res = await fetch(`api/fasilitas_umum.php?kategori=${kategori}`);
const json = await res.json();
if (json.success) {
fasData[kategori] = json.data;
} else {
toast('Gagal memuat data: ' + json.message);
const cb = document.getElementById('layer_' + kategori);
if (cb) cb.checked = false;
return;
}
} catch (e) {
toast('Koneksi server gagal.');
const cb = document.getElementById('layer_' + kategori);
if (cb) cb.checked = false;
return;
}
}
if (!fasMarkers[kategori] || fasMarkers[kategori].length === 0) {
fasMarkers[kategori] = [];
const colors = { kesehatan: '#f59e0b', pendidikan: '#1a5fff', pasar: '#7c3aed', kelurahan: '#00c9aa' };
const icons = { kesehatan: '🏥', pendidikan: '🏫', pasar: '🛒', kelurahan: '🏛️' };
const col = colors[kategori] || '#999';
const ik = icons[kategori] || '📍';
fasData[kategori].forEach(f => {
const html = `<div style="width:30px;height:30px;background:${col};border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:15px;border:2px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,.2)">${ik}</div>`;
const icon = L.divIcon({ html, iconSize: [30, 30], iconAnchor: [15, 15], className: '' });
const m = L.marker([parseFloat(f.lat), parseFloat(f.lng)], { icon });
m.bindPopup(`<div class="pw">
<div class="pt"><div class="pn">${f.nama}</div><span class="kb" style="background:${col};color:#fff">${kategori.toUpperCase()}</span></div>
<div class="prows">
<div class="pr"><span class="pk">Alamat</span><span class="pv">${f.alamat || '—'}</span></div>
<div class="pr"><span class="pk">Keterangan</span><span class="pv">${f.keterangan || '—'}</span></div>
</div>
</div>`, { maxWidth: 260, closeButton: false });
m.bindTooltip(`<b>${f.nama}</b>`, { direction: 'top' });
fasMarkers[kategori].push(m);
});
}
fasMarkers[kategori].forEach(m => {
if (!map.hasLayer(m)) m.addTo(map);
});
} else {
if (fasMarkers[kategori]) {
fasMarkers[kategori].forEach(m => {
if (map.hasLayer(m)) map.removeLayer(m);
});
}
}
}
async function deleteW(id) {
map.closePopup();
if (!confirm('Hapus data warga ini?')) return;
await processDeleteW(id);
}
async function delWData(id) {
if (!confirm('Hapus data warga ini?')) return;
await processDeleteW(id);
}
async function processDeleteW(id) {
try {
const res = await fetch(`api/warga.php?id=${id}`, { method: 'DELETE' });
const json = await res.json();
if (json.success) {
dataDirty = true;
await loadData();
render();
if (document.getElementById('tabData').classList.contains('active')) renderDT();
toast('Data warga berhasil dihapus');
} else {
alert('Gagal menghapus: ' + json.message);
}
} catch (e) {
alert('Koneksi server gagal.');
}
}
async function deleteRI(id) {
if (!confirm('Hapus rumah ibadah ini?')) return;
map.closePopup();
try {
const res = await fetch(`api/rumah_ibadah.php?id=${id}`, { method: 'DELETE' });
const json = await res.json();
if (json.success) {
dataDirty = true;
await loadData();
render();
toast('Rumah ibadah berhasil dihapus');
} else {
alert('Gagal menghapus: ' + json.message);
}
} catch (e) {
alert('Koneksi server gagal.');
}
}
/* ══ DASHBOARD ══ */
async function renderDashboard() {
try {
const res = await fetch('api/dashboard.php');
const json = await res.json();
if (!json.success) {
console.error('Gagal mengambil data dashboard:', json.message);
return;
}
const d = json.data;
// KPIs
const lapM = laporan.filter(l => l.status === 'menunggu').length;
document.getElementById('kpiKK').textContent = d.kpis.total_kk;
document.getElementById('kpiSM').textContent = d.kpis.sangat_miskin;
document.getElementById('kpiBn').textContent = d.kpis.total_penyaluran;
document.getElementById('kpiLap').textContent = lapM + ' laporan menunggu verifikasi';
document.getElementById('kpiBnNom').textContent = 'Total: ' + fmtRp(d.kpis.total_nominal);
document.getElementById('kpiPy').textContent = PL[d.kpis.penyakit_top] || d.kpis.penyakit_top || '—';
document.getElementById('dashDate').textContent = 'Per ' + new Date().toLocaleDateString('id-ID', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
// Chart 1: Kelurahan
if (charts.kel) charts.kel.destroy();
charts.kel = new Chart(document.getElementById('chartKel'), {
type: 'bar',
data: {
labels: d.charts.kelurahan.labels.map(l => l.replace('Kel. ', '')),
datasets: [{
label: 'Jumlah KK',
data: d.charts.kelurahan.data,
backgroundColor: d.charts.kelurahan.data.map(v => v >= 4 ? 'rgba(240,62,90,.8)' : v >= 3 ? 'rgba(249,115,22,.8)' : 'rgba(26,95,255,.8)'),
borderRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 }, grid: { color: 'rgba(0,0,0,.04)' } }, x: { grid: { display: false }, ticks: { font: { size: 10 } } } }
}
});
// Chart 2: Kategori
if (charts.kat) charts.kat.destroy();
charts.kat = new Chart(document.getElementById('chartKat'), {
type: 'doughnut',
data: {
labels: d.charts.kategori.labels,
datasets: [{
data: d.charts.kategori.data,
backgroundColor: ['rgba(240,62,90,.85)', 'rgba(249,115,22,.85)', 'rgba(26,95,255,.85)'],
borderColor: '#fff',
borderWidth: 3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '65%',
plugins: { legend: { position: 'bottom', labels: { font: { size: 11 }, padding: 12 } } }
}
});
// Chart 3: Program Bantuan
const bnCol = ['rgba(26,95,255,.8)', 'rgba(0,201,170,.8)', 'rgba(124,58,237,.8)', 'rgba(240,62,90,.8)', 'rgba(249,115,22,.8)', 'rgba(16,185,129,.8)', 'rgba(245,158,11,.8)'];
if (charts.bn) charts.bn.destroy();
charts.bn = new Chart(document.getElementById('chartBn'), {
type: 'bar',
data: {
labels: d.charts.bantuan.labels,
datasets: [{
label: 'Penyaluran',
data: d.charts.bantuan.data,
backgroundColor: d.charts.bantuan.data.map((_, i) => bnCol[i % bnCol.length]),
borderRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 }, grid: { color: 'rgba(0,0,0,.04)' } }, x: { grid: { display: false }, ticks: { font: { size: 10 } } } }
}
});
// Chart 4: Prevalensi Penyakit
const pyCol = ['rgba(240,62,90,.8)', 'rgba(249,115,22,.8)', 'rgba(124,58,237,.8)', 'rgba(26,95,255,.8)', 'rgba(0,201,170,.8)', 'rgba(16,185,129,.8)', 'rgba(245,158,11,.8)', 'rgba(239,68,68,.8)', 'rgba(99,102,241,.8)'];
if (charts.py) charts.py.destroy();
charts.py = new Chart(document.getElementById('chartPy'), {
type: 'bar',
data: {
labels: d.charts.penyakit.labels.map(k => PL[k] || k),
datasets: [{
label: 'Jumlah',
data: d.charts.penyakit.data,
backgroundColor: d.charts.penyakit.data.map((_, i) => pyCol[i % pyCol.length]),
borderRadius: 6
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { x: { beginAtZero: true, ticks: { stepSize: 1 }, grid: { color: 'rgba(0,0,0,.04)' } }, y: { grid: { display: false }, ticks: { font: { size: 11 } } } }
}
});
// Tabel Ranking Kelurahan
rankData = d.ranking || [];
rankPage = 1;
renderRank();
} catch (e) {
console.error('Error rendering dashboard:', e);
}
}
/* ══ RANKING PAGINATION ══ */
let rankData = [], rankPage = 1;
const rankPS = 10;
function renderRank() {
const tp = Math.max(1, Math.ceil(rankData.length / rankPS));
if (rankPage > tp) rankPage = tp;
const slice = rankData.slice((rankPage - 1) * rankPS, rankPage * rankPS);
const offset = (rankPage - 1) * rankPS;
document.getElementById('dashTb').innerHTML = slice.map((r, i) =>
`<tr>
<td style="color:var(--ink3);font-weight:700;font-family:'Syne',sans-serif">${offset + i + 1}</td>
<td style="font-weight:600">${r.kelurahan}</td>
<td style="color:var(--ink3);font-size:12px">${r.kecamatan}</td>
<td><span class="kb2 sm">${r.sangat_miskin}</span></td>
<td><span class="kb2 m">${r.miskin}</span></td>
<td><span class="kb2 hm">${r.hampir_miskin}</span></td>
<td style="font-weight:700">${r.total}</td>
<td style="color:var(--teal);font-weight:600">${fmtRp(r.total_bantuan)}</td>
</tr>`
).join('');
// info
const info = document.getElementById('rankInfo');
if (info) info.textContent = `${offset+1}${Math.min(offset+rankPS, rankData.length)} dari ${rankData.length} kelurahan`;
// pagination buttons
const pg = document.getElementById('rankPgn'); pg.innerHTML = '';
if (tp > 1) {
const prev = document.createElement('button');
prev.className = 'pgb'; prev.textContent = '←'; prev.disabled = rankPage <= 1;
prev.onclick = () => { rankPage--; renderRank(); };
pg.appendChild(prev);
// show at most 7 page buttons with ellipsis
for (let i = 1; i <= tp; i++) {
if (i === 1 || i === tp || (i >= rankPage - 2 && i <= rankPage + 2)) {
const b = document.createElement('button');
b.className = 'pgb' + (i === rankPage ? ' active' : '');
b.textContent = i;
b.onclick = (function(p){ return () => { rankPage = p; renderRank(); }; })(i);
pg.appendChild(b);
} else if (i === rankPage - 3 || i === rankPage + 3) {
const sp = document.createElement('span');
sp.textContent = '…'; sp.style.cssText = 'padding:0 4px;color:var(--ink3);font-size:13px;align-self:center';
pg.appendChild(sp);
}
}
const nxt = document.createElement('button');
nxt.className = 'pgb'; nxt.textContent = '→'; nxt.disabled = rankPage >= tp;
nxt.onclick = () => { rankPage++; renderRank(); };
pg.appendChild(nxt);
}
}
/* ══ DATA TABLE ══ */
let dtPage = 1, dtPS = 12;
function renderDT() {
const q = (document.getElementById('dtSearch')?.value || '').toLowerCase();
const k = document.getElementById('dtKat')?.value || '';
const l = document.getElementById('dtKel')?.value || '';
const p = document.getElementById('dtPy')?.value || '';
let f = warga.filter(w => w.status === 'aktif');
if (q) f = f.filter(w => w.nama.toLowerCase().includes(q) || (w.nik || '').includes(q) || w.kel.toLowerCase().includes(q));
if (k) f = f.filter(w => w.kat === k);
if (l) f = f.filter(w => w.kel === l);
if (p) f = f.filter(w => w.anggota?.some(a => a.py?.includes(p)) || w.rp?.includes(p));
document.getElementById('dtCount').textContent = f.length + ' data';
const tp = Math.max(1, Math.ceil(f.length / dtPS));
if (dtPage > tp) dtPage = tp;
const pd = f.slice((dtPage - 1) * dtPS, dtPage * dtPS);
const canEdit = currentUser?.can_edit;
document.getElementById('dtTb').innerHTML = pd.map((w, i) => {
const k2 = KL[w.kat]; const age = calcAge(w.tgl);
const pyAll = new Set(); w.anggota?.forEach(a => a.py?.forEach(p2 => pyAll.add(p2)));
const pyD = pyAll.size > 0 ? [...pyAll].slice(0, 2).map(p2 => `<span style="font-size:10px;color:var(--red)">${PL[p2] || p2}</span>`).join(' ') + (pyAll.size > 2 ? ` <span style="color:var(--ink3);font-size:10px">+${pyAll.size - 2}</span>` : '') : '<span style="color:var(--green);font-size:10px">✓ Sehat</span>';
const bc = w.bantuan?.length || 0;
return `<tr><td style="color:var(--ink3);font-size:11px">${(dtPage - 1) * dtPS + i + 1}</td><td><div class="td-name">${w.nama}</div><div class="td-nik">${w.nik || '—'}</div></td><td style="font-size:12px">${w.kel}</td><td><span class="kb2 ${w.kat === 'sangat_miskin' ? 'sm' : w.kat === 'miskin' ? 'm' : 'hm'}">${k2.label}</span></td><td style="font-size:12px">${age !== null ? age + ' thn<div class="td-nik">' + fmtD(w.tgl) + '</div>' : '—'}</td><td style="font-size:12px">${EDL[w.pend || '']}</td><td style="font-size:12px">${fmtRp(w.ph)}</td><td>${pyD}</td><td>${bc > 0 ? `<span style="font-size:11px;font-weight:700;color:var(--teal);background:var(--tdim);padding:2px 8px;border-radius:99px">${bc}x</span>` : '<span style="color:var(--ink3);font-size:11px">—</span>'}</td><td><div class="td-act">${canEdit ? `<button class="asm t" onclick="openBantuan('${w.id}')">💰</button><button class="asm t" onclick="openEditWarga('${w.id}')" title="Edit Warga">✏️</button>` : ''} <button class="asm b" onclick="openAnggota('${w.id}')">👨‍👩‍👧</button>${canEdit ? `<button class="asm r" onclick="delWData('${w.id}')">🗑</button>` : ''}</div></td></tr>`;
}).join('');
const pg = document.getElementById('pgn'); pg.innerHTML = '';
if (tp > 1) {
const prev = document.createElement('button'); prev.className = 'pgb'; prev.textContent = '←'; prev.disabled = dtPage <= 1; prev.onclick = () => { dtPage--; renderDT(); }; pg.appendChild(prev);
for (let i = 1; i <= tp; i++) { const b = document.createElement('button'); b.className = 'pgb' + (i === dtPage ? ' active' : ''); b.textContent = i; b.onclick = (() => { const p = i; return () => { dtPage = p; renderDT(); }; })(); pg.appendChild(b); }
const nxt = document.createElement('button'); nxt.className = 'pgb'; nxt.textContent = '→'; nxt.disabled = dtPage >= tp; nxt.onclick = () => { dtPage++; renderDT(); }; pg.appendChild(nxt);
}
}
function exportCSV() {
const akt = warga.filter(w => w.status === 'aktif');
const rows = [['Nama', 'NIK', 'Kelurahan', 'Kecamatan', 'Kategori', 'Tgl Lahir', 'Usia', 'Pendidikan', 'Penghasilan', 'Anggota', 'Penyakit', 'Jumlah Bantuan']];
akt.forEach(w => { const py = [...new Set(w.anggota?.flatMap(a => a.py) || [])].map(p => PL[p] || p).join('; '); rows.push([w.nama, w.nik || '', w.kel, getKec(w.kel), KL[w.kat].label, w.tgl || '', calcAge(w.tgl) || '', EDL[w.pend || ''] || '', w.ph || 0, w.jml || '', py, w.bantuan?.length || 0]); });
const csv = rows.map(r => r.map(c => `"${c}"`).join(',')).join('\n');
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'geososial_' + new Date().toISOString().slice(0, 10) + '.csv'; a.click();
toast('Export CSV berhasil!');
}
/* ══ LAPORAN MASUK ══ */
function renderLap() {
const m2 = laporan.filter(l => l.status === 'menunggu').length;
const d2 = laporan.filter(l => l.status === 'disetujui').length;
const t2 = laporan.filter(l => l.status === 'ditolak').length;
document.getElementById('lsM').textContent = m2;
document.getElementById('lsD').textContent = d2;
document.getElementById('lsT').textContent = t2;
const kl = { sangat_miskin: 'Sangat Miskin', miskin: 'Miskin', hampir_miskin: 'Hampir Miskin' };
document.getElementById('lapTb').innerHTML = laporan.map(l => `<tr>
<td style="font-family:monospace;font-size:11px;font-weight:700;color:var(--blue)">${l.no}</td>
<td style="font-size:12px">${fmtD(l.tgl)}</td>
<td style="font-size:12px">${l.pelapor || 'Anonim'}</td>
<td style="font-weight:600">${l.nama}</td>
<td style="font-size:12px">${l.kel}</td>
<td><span class="kb2 ${l.kat === 'sangat_miskin' ? 'sm' : l.kat === 'miskin' ? 'm' : 'hm'}">${kl[l.kat]}</span></td>
<td><span class="sb ${l.status}">${l.status.charAt(0).toUpperCase() + l.status.slice(1)}</span></td>
<td><div style="display:flex;gap:5px">
<button class="asm b" onclick="openLapDetail('${l.id}')">Detail</button>
${l.status === 'menunggu' && currentUser?.can_edit ? `<button class="asm t" onclick="approveLap('${l.id}')">✓</button><button class="asm r" onclick="rejectLap('${l.id}')">✕</button>` : ''}
</div></td>
</tr>`).join('');
updateLapBadge();
}
function updateLapBadge() {
const n = laporan.filter(l => l.status === 'menunggu').length;
const b = document.getElementById('lbadge');
if (b) {
b.textContent = n;
b.style.display = n > 0 ? '' : 'none';
}
}
function openLapDetail(id) {
const l = laporan.find(x => x.id == id); if (!l) return; curLapId = id;
document.getElementById('lapNo').textContent = l.no + ' — ' + fmtD(l.tgl);
const kl = { sangat_miskin: 'Sangat Miskin', miskin: 'Miskin', hampir_miskin: 'Hampir Miskin' };
document.getElementById('lapDetailBody').innerHTML = `
<div class="lpd-row"><span class="lpd-k">Nama Dilaporkan</span><span class="lpd-v" style="font-weight:700">${l.nama}</span></div>
<div class="lpd-row"><span class="lpd-k">NIK</span><span class="lpd-v">${l.nik || 'Tidak diketahui'}</span></div>
<div class="lpd-row"><span class="lpd-k">Kelurahan</span><span class="lpd-v">${l.kel}</span></div>
<div class="lpd-row"><span class="lpd-k">Kategori</span><span class="lpd-v">${kl[l.kat]}</span></div>
<div class="lpd-row"><span class="lpd-k">Jumlah Anggota</span><span class="lpd-v">${l.jml || '—'} orang</span></div>
<div class="lpd-row"><span class="lpd-k">Penghasilan</span><span class="lpd-v">${fmtRp(l.ph)}</span></div>
<div class="lpd-row" style="flex-direction:column;gap:6px"><span class="lpd-k">Keterangan</span><span style="font-size:13px;color:var(--ink);line-height:1.6;background:var(--paper);padding:10px;border-radius:8px">${l.ket}</span></div>
<div class="lpd-row"><span class="lpd-k">Pelapor</span><span class="lpd-v">${l.pelapor || 'Anonim'}</span></div>
<div class="lpd-row"><span class="lpd-k">Kontak HP</span><span class="lpd-v">${l.hp || '—'}</span></div>
<div class="lpd-row"><span class="lpd-k">Status</span><span class="lpd-v"><span class="sb ${l.status}">${l.status.charAt(0).toUpperCase() + l.status.slice(1)}</span></span></div>
`;
const foot = document.getElementById('lapFoot');
if (l.status === 'menunggu' && currentUser?.can_edit) {
foot.innerHTML = `<button class="btn-cancel" onclick="closeModal('modalLaporan')">Tutup</button><button class="btn-reject" onclick="rejectLap('${l.id}');closeModal('modalLaporan')">✕ Tolak</button><button class="btn-approve" onclick="approveLap('${l.id}');closeModal('modalLaporan')">✓ Setujui & Tambahkan Data</button>`;
} else { foot.innerHTML = `<button class="btn-cancel" onclick="closeModal('modalLaporan')">Tutup</button>`; }
openModal('modalLaporan');
setTimeout(() => {
const con = document.getElementById('lapMap');
if (l.lat && l.lng) { con.style.display = 'block'; if (window._lapMap) { window._lapMap.remove(); } window._lapMap = L.map(con, { zoomControl: false, dragging: false, scrollWheelZoom: false }).setView([l.lat, l.lng], 15); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '', maxZoom: 19 }).addTo(window._lapMap); L.circleMarker([l.lat, l.lng], { radius: 10, color: '#f03e5a', fillColor: '#f03e5a', fillOpacity: .7, weight: 2 }).addTo(window._lapMap); } else { con.style.display = 'none'; }
}, 200);
}
async function approveLap(id) {
if (!confirm('Setujui laporan ini dan tambahkan ke data warga?')) return;
try {
const res = await fetch(`api/laporan.php?id=${id}&action=approve`, { method: 'PUT' });
const json = await res.json();
if (json.success) {
dataDirty = true;
await loadData();
renderLap();
render();
toast('✓ Laporan disetujui. Data warga ditambahkan.');
} else {
alert('Gagal memproses: ' + json.message);
}
} catch (e) {
alert('Koneksi server gagal.');
}
}
async function rejectLap(id) {
if (!confirm('Tolak laporan ini?')) return;
try {
const res = await fetch(`api/laporan.php?id=${id}&action=reject`, { method: 'PUT' });
const json = await res.json();
if (json.success) {
dataDirty = true;
await loadData();
renderLap();
toast('Laporan ditolak.');
} else {
alert('Gagal memproses: ' + json.message);
}
} catch (e) {
alert('Koneksi server gagal.');
}
}
/* ══ BANTUAN ══ */
function openBantuan(wId) {
curBantuanId = wId; const w = warga.find(x => x.id == wId); if (!w) return;
if (map) map.closePopup();
document.getElementById('bnWargaNama').textContent = w.nama + ' — ' + w.kel;
document.getElementById('bnForm').style.display = currentUser?.can_edit ? '' : 'none';
renderBnTable(w); openModal('modalBantuan');
}
function renderBnTable(w) {
const list = w.bantuan || [];
const tbody = document.getElementById('bnTb');
if (!list.length) { tbody.innerHTML = ''; document.getElementById('bnEmpty').style.display = 'block'; document.getElementById('bnTot').textContent = ''; return; }
document.getElementById('bnEmpty').style.display = 'none';
const tot = list.reduce((s, b) => s + (b.nom || 0), 0);
document.getElementById('bnTot').innerHTML = `Total nilai bantuan: <b>${fmtRp(tot)}</b> dari ${list.length} penyaluran`;
const ce = currentUser?.can_edit;
tbody.innerHTML = list.map(b => `<tr><td>${fmtD(b.tgl)}</td><td><span class="jb ${JL[b.jenis] || 'lain'}">${b.jenis}</span></td><td style="font-weight:600;color:var(--teal)">${b.nom ? fmtRp(b.nom) : 'Non-tunai'}</td><td style="font-size:11px;color:var(--ink3)">${b.src}</td><td style="font-size:11px">${b.ket || '—'}</td><td>${ce ? `<button class="asm r" onclick="delBn('${w.id}','${b.id}')">🗑</button>` : ''}</td></tr>`).join('');
}
async function submitBantuan() {
const w = warga.find(x => x.id == curBantuanId); if (!w) return;
const tgl = document.getElementById('bh_tgl').value; if (!tgl) { alert('Tanggal wajib diisi.'); return; }
const jenis = document.getElementById('bh_jenis').value;
const nominal = parseInt(document.getElementById('bh_nom').value) || 0;
const sumber_dana = document.getElementById('bh_src').value;
const keterangan = document.getElementById('bh_ket').value.trim();
try {
const res = await fetch('api/bantuan.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ warga_id: curBantuanId, tanggal: tgl, jenis, nominal, sumber_dana, keterangan })
});
const json = await res.json();
if (json.success) {
document.getElementById('bh_tgl').value = '';
document.getElementById('bh_nom').value = '';
document.getElementById('bh_ket').value = '';
dataDirty = true;
await loadData();
const updatedW = warga.find(x => x.id == curBantuanId);
renderBnTable(updatedW);
toast('Bantuan berhasil ditambahkan');
} else {
alert('Gagal menambahkan bantuan: ' + json.message);
}
} catch (e) {
alert('Koneksi server gagal.');
}
}
async function delBn(wId, bId) {
if (!confirm('Hapus data bantuan ini?')) return;
try {
const res = await fetch(`api/bantuan.php?id=${bId}`, { method: 'DELETE' });
const json = await res.json();
if (json.success) {
dataDirty = true;
await loadData();
const updatedW = warga.find(x => x.id == wId);
renderBnTable(updatedW);
toast('Data bantuan dihapus');
} else {
alert('Gagal menghapus bantuan: ' + json.message);
}
} catch (e) {
alert('Koneksi server gagal.');
}
}
/* ══ ANGGOTA ══ */
function openAnggota(wId) {
const w = warga.find(x => x.id == wId); if (!w) return;
if (map) map.closePopup();
document.getElementById('agWargaNama').textContent = w.nama + ' — ' + w.kel;
const body = document.getElementById('agBody');
if (!w.anggota?.length) {
body.innerHTML = '<div style="text-align:center;padding:32px;color:var(--ink3)">👤 Belum ada data anggota keluarga</div>';
} else { body.innerHTML = `<table class="bt"><thead><tr><th>Nama</th><th>Usia</th><th>Hubungan</th><th>Riwayat Penyakit</th></tr></thead><tbody>${w.anggota.map(a => `<tr><td style="font-weight:600">${a.nama}</td><td>${a.usia} thn</td><td style="color:var(--ink3)">${a.hub}</td><td>${a.py?.length ? a.py.map(p => `<span style="font-size:10px;font-weight:700;padding:2px 8px;border-radius:99px;background:var(--rdim);color:var(--red)">${PL[p] || p}</span>`).join(' ') : '<span style="color:var(--green);font-size:11px">✓ Tidak Ada</span>'}</td></tr>`).join('')}</tbody></table>`; }
openModal('modalAnggota');
}
/* ══ PENGGUNA ══ */
async function renderUsers() {
try {
const res = await fetch('api/users.php');
const json = await res.json();
if (!json.success) return;
const users = json.data;
const isAdmin = currentUser?.is_admin;
// Tampilkan tombol tambah hanya untuk admin
const addBtn = document.getElementById('addUsrBtn');
if (addBtn) addBtn.style.display = isAdmin ? '' : 'none';
const roleLabel = { admin: 'Super Admin', petugas: 'Petugas Dinsos', walikota: 'Walikota', viewer: 'Viewer' };
const roleColor = {
admin: { bg: 'var(--rdim)', fg: 'var(--red)' },
petugas: { bg: 'var(--bdim)', fg: 'var(--blue)' },
walikota: { bg: 'var(--pdim)', fg: 'var(--purple)' },
viewer: { bg: 'var(--paper)', fg: 'var(--ink3)' }
};
const avatarColors = ['#1a5fff','#10b981','#f97316','#7c3aed','#f03e5a','#00c9aa'];
document.getElementById('usrTb').innerHTML = users.map((u, i) => {
const rc = roleColor[u.role] || roleColor.viewer;
const rl = roleLabel[u.role] || u.role;
const ac = avatarColors[i % avatarColors.length];
const initials = u.username.substring(0, 2).toUpperCase();
const isSelf = u.id == currentUser?.id;
const canViewBtn = isAdmin || isSelf;
const permissionBadge = u.can_edit
? `<span style="display:inline-flex;align-items:center;gap:5px;font-size:12px;color:var(--blue);font-weight:600">
<span style="font-size:11px">✏️</span> Baca & Tulis
</span>`
: `<span style="display:inline-flex;align-items:center;gap:5px;font-size:12px;color:var(--ink3);font-weight:500">
<span style="font-size:11px">👁️</span> Baca saja
</span>`;
const statusBadge = u.aktif
? `<span style="display:inline-flex;align-items:center;font-size:11px;font-weight:700;padding:4px 10px;border-radius:99px;background:var(--gdim);color:var(--green)">
Aktif
</span>`
: `<span style="display:inline-flex;align-items:center;font-size:11px;font-weight:700;padding:4px 10px;border-radius:99px;background:var(--paper);color:var(--ink3)">
Nonaktif
</span>`;
return `<tr>
<td style="color:var(--ink3); text-align: center; font-weight:600">${i + 1}</td>
<td>
<div style="display:flex;align-items:center;gap:10px">
<div style="width:32px;height:32px;border-radius:50%;background:${ac};display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:800;color:#fff;flex-shrink:0;box-shadow: 0 2px 6px rgba(0,0,0,0.1)">${initials}</div>
<div style="display:flex;flex-direction:column;gap:2px">
<span style="font-weight:700;color:var(--ink);font-size:13px">${u.username}</span>
${isSelf ? '<span style="font-size:9px;background:var(--tdim);color:var(--teal);padding:1px 6px;border-radius:99px;font-weight:800;width:fit-content">Akun Anda</span>' : ''}
</div>
</div>
</td>
<td style="font-weight:500;color:var(--ink2)">${u.jabatan || '—'}</td>
<td><span style="font-size:11px;font-weight:700;padding:4px 11px;border-radius:99px;background:${rc.bg};color:${rc.fg};box-shadow: 0 1px 4px rgba(0,0,0,0.02)">${rl}</span></td>
<td>${permissionBadge}</td>
<td>${statusBadge}</td>
<td>${canViewBtn ? `<button class="btn-usr-prof" onclick="openUserProfile(${JSON.stringify(u).replace(/"/g,'&quot;')})">👤 Profil</button>` : ''}</td>
</tr>`;
}).join('');
} catch (e) { console.error(e); }
}
function openUserProfile(u) {
const isAdmin = currentUser?.is_admin;
const isSelf = u.id == currentUser?.id;
document.getElementById('prof_id').value = u.id;
document.getElementById('prof_jabatan').value = u.jabatan || '';
document.getElementById('prof_role').value = u.role;
document.getElementById('prof_can_edit').value = u.can_edit ? '1' : '0';
document.getElementById('prof_aktif').value = u.aktif ? '1' : '0';
document.getElementById('prof_password').value = '';
document.getElementById('profUsername').textContent = u.username;
document.getElementById('profModalSub').textContent = (u.jabatan || 'Pengguna') + ' — ' + (u.aktif ? 'Aktif' : 'Nonaktif');
document.getElementById('profCreated').textContent = 'Bergabung: ' + (u.created_at ? new Date(u.created_at).toLocaleDateString('id-ID', {day:'2-digit',month:'long',year:'numeric'}) : '—');
// Avatar
const colors = ['#1a5fff','#10b981','#f97316','#7c3aed','#f03e5a','#00c9aa'];
const av = document.getElementById('profAvatar');
av.textContent = u.username.substring(0, 2).toUpperCase();
av.style.background = colors[u.id % colors.length];
// Readonly jika bukan admin dan bukan diri sendiri
const editable = isAdmin;
['prof_jabatan','prof_role','prof_can_edit','prof_aktif'].forEach(id => {
document.getElementById(id).disabled = !editable;
});
// Password hanya bisa diganti oleh admin atau diri sendiri
document.getElementById('prof_pw_wrap').style.display = (isAdmin || isSelf) ? '' : 'none';
document.getElementById('profSaveBtn').style.display = (isAdmin || isSelf) ? '' : 'none';
openModal('modalProfile');
}
async function saveUserProfile() {
const id = document.getElementById('prof_id').value;
const jabatan = document.getElementById('prof_jabatan').value.trim();
const role = document.getElementById('prof_role').value;
const can_edit = document.getElementById('prof_can_edit').value === '1';
const aktif = document.getElementById('prof_aktif').value === '1';
const password = document.getElementById('prof_password').value;
const payload = { jabatan, role, can_edit, aktif };
if (password) payload.password = password;
try {
const res = await fetch(`api/users.php?id=${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const json = await res.json();
if (json.success) {
closeModal('modalProfile');
renderUsers();
toast('Profil pengguna berhasil diperbarui');
} else {
alert('Gagal: ' + json.message);
}
} catch (e) { alert('Koneksi server gagal.'); }
}
function openAddUser() {
['nu_username','nu_password','nu_jabatan'].forEach(id => document.getElementById(id).value = '');
document.getElementById('nu_role').value = 'petugas';
openModal('modalAddUser');
}
async function submitAddUser() {
const username = document.getElementById('nu_username').value.trim();
const password = document.getElementById('nu_password').value;
const jabatan = document.getElementById('nu_jabatan').value.trim();
const role = document.getElementById('nu_role').value;
if (!username) { alert('Username wajib diisi.'); return; }
if (!password || password.length < 6) { alert('Password minimal 6 karakter.'); return; }
try {
const res = await fetch('api/users.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, jabatan, role })
});
const json = await res.json();
if (json.success) {
closeModal('modalAddUser');
renderUsers();
toast(`Pengguna "${username}" berhasil ditambahkan`);
} else {
alert('Gagal: ' + json.message);
}
} catch (e) { alert('Koneksi server gagal.'); }
}
/* ══ MODALS ══ */
function openModal(id) { document.getElementById(id).classList.add('open'); }
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
document.querySelectorAll('.mo').forEach(o => { o.addEventListener('click', e => { if (e.target === o) o.classList.remove('open'); }); });
/* MODAL RI */
function openModalRI() {
clearFormRI();
document.getElementById('ri_edit_id').value = '';
document.getElementById('riModalTitle').textContent = '🕌 Tambah Rumah Ibadah';
document.getElementById('riModalSub').textContent = 'Klik mini-peta untuk menentukan lokasi';
document.getElementById('riSubmitBtn').textContent = 'Simpan';
openModal('modalRI');
setTimeout(initRIMap, 200);
}
function openEditRI(id) {
const ri = rumahIbadah.find(r => r.id == id);
if (!ri) return;
map.closePopup();
clearFormRI();
document.getElementById('ri_edit_id').value = ri.id;
document.getElementById('ri_nama').value = ri.nama;
document.getElementById('ri_tipe').value = ri.tipe;
document.getElementById('ri_kap').value = ri.kap || '';
document.getElementById('ri_alamat').value = ri.alamat || '';
document.getElementById('ri_lat').value = ri.lat;
document.getElementById('ri_lng').value = ri.lng;
document.getElementById('riMapLbl').style.display = 'none';
document.getElementById('riModalTitle').textContent = '✏️ Edit Rumah Ibadah';
document.getElementById('riModalSub').textContent = ri.nama;
document.getElementById('riSubmitBtn').textContent = 'Simpan Perubahan';
// Set kelurahan
const kelEl = document.getElementById('ri_kel');
if (kelEl) {
const opt = [...kelEl.options].find(o => o.textContent === ri.kel);
if (opt) kelEl.value = opt.value;
}
openModal('modalRI');
setTimeout(() => {
initRIMap();
// place existing marker
if (riPickMap) {
riPickMap.setView([ri.lat, ri.lng], 15);
if (riPickMk) riPickMap.removeLayer(riPickMk);
riPickMk = L.marker([ri.lat, ri.lng], { icon: L.divIcon({ html: '<div class="pick-pin">📍</div>', iconSize: [26, 26], iconAnchor: [13, 26], className: '' }) }).addTo(riPickMap);
}
}, 200);
}
function clearFormRI() {
['ri_nama', 'ri_kap', 'ri_alamat', 'ri_lat', 'ri_lng'].forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; });
document.getElementById('ri_tipe').value = 'masjid';
document.getElementById('riMapLbl').style.display = '';
if (riPickMk && riPickMap) { riPickMap.removeLayer(riPickMk); riPickMk = null; }
}
function initRIMap() {
const con = document.getElementById('riMapPick');
if (riPickMap) { riPickMap.invalidateSize(); return; }
riPickMap = L.map(con, { zoomControl: false }).setView(CENTER, 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '', maxZoom: 19 }).addTo(riPickMap);
riPickMap.on('click', e => {
const { lat, lng } = e.latlng;
document.getElementById('ri_lat').value = lat.toFixed(6); document.getElementById('ri_lng').value = lng.toFixed(6);
document.getElementById('riMapLbl').style.display = 'none';
if (riPickMk) riPickMap.removeLayer(riPickMk);
riPickMk = L.marker([lat, lng], { icon: L.divIcon({ html: '<div class="pick-pin">📍</div>', iconSize: [26, 26], iconAnchor: [13, 26], className: '' }) }).addTo(riPickMap);
});
}
async function submitRI() {
const nama = document.getElementById('ri_nama').value.trim();
const lat = parseFloat(document.getElementById('ri_lat').value);
const lng = parseFloat(document.getElementById('ri_lng').value);
if (!nama) { alert('Nama wajib diisi.'); return; }
if (isNaN(lat) || isNaN(lng)) { alert('Pilih lokasi di peta.'); return; }
const tipe = document.getElementById('ri_tipe').value;
const kelurahan_id = document.getElementById('ri_kel').value;
const kapasitas = parseInt(document.getElementById('ri_kap').value) || 50;
const alamat = document.getElementById('ri_alamat').value.trim();
const editId = document.getElementById('ri_edit_id').value;
const isEdit = !!editId;
try {
const res = await fetch(isEdit ? `api/rumah_ibadah.php?id=${editId}` : 'api/rumah_ibadah.php', {
method: isEdit ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nama, tipe, lat, lng, kapasitas, kelurahan_id, alamat })
});
const json = await res.json();
if (json.success) {
closeModal('modalRI');
dataDirty = true;
await loadData();
render();
toast(isEdit ? `"${nama}" berhasil diperbarui` : `"${nama}" berhasil ditambahkan`);
} else {
alert((isEdit ? 'Gagal memperbarui: ' : 'Gagal menambahkan: ') + json.message);
}
} catch (e) {
alert('Koneksi server gagal.');
}
}
/* MODAL KM */
function openModalKM() { clearFormKM(); openModal('modalKM'); setTimeout(initKMMap, 200); }
function clearFormKM() {
['km_nama', 'km_nik', 'km_jml', 'km_ph', 'km_lat', 'km_lng', 'km_ket', 'km_tgl'].forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; });
document.getElementById('km_kat').value = 'sangat_miskin'; document.getElementById('km_pend').value = '';
document.getElementById('kmMapLbl').style.display = '';
document.querySelectorAll('.pykk').forEach(cb => cb.checked = false);
document.getElementById('anggotaBody').innerHTML = '';
if (kmPickMk && kmPickMap) { kmPickMap.removeLayer(kmPickMk); kmPickMk = null; }
}
function initKMMap() {
const con = document.getElementById('kmMapPick');
if (kmPickMap) { kmPickMap.invalidateSize(); return; }
kmPickMap = L.map(con, { zoomControl: false }).setView(CENTER, 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '', maxZoom: 19 }).addTo(kmPickMap);
kmPickMap.on('click', e => {
const { lat, lng } = e.latlng;
document.getElementById('km_lat').value = lat.toFixed(6); document.getElementById('km_lng').value = lng.toFixed(6);
document.getElementById('kmMapLbl').style.display = 'none';
if (kmPickMk) kmPickMap.removeLayer(kmPickMk);
const kat = document.getElementById('km_kat').value;
const col = kat === 'sangat_miskin' ? '#f03e5a' : kat === 'miskin' ? '#f97316' : '#1a5fff';
kmPickMk = L.marker([lat, lng], { icon: L.divIcon({ html: `<div style="width:18px;height:18px;background:${col};border-radius:50%;border:2px solid #fff;box-shadow:0 2px 6px rgba(0,0,0,.3)"></div>`, iconSize: [18, 18], iconAnchor: [9, 9], className: '' }) }).addTo(kmPickMap);
});
}
function addAnggotaRow() {
const tbody = document.getElementById('anggotaBody');
const tr = document.createElement('tr');
const penyakitCbs = Object.entries(PL).map(([v, l]) =>
`<label title="${l}"><input type="checkbox" class="apy-cb" value="${v}">${PLIK[v] || ''} ${l}</label>`
).join('');
tr.innerHTML = `
<td><input class="ai" type="text" placeholder="Nama anggota"></td>
<td><input class="ai" type="number" placeholder="Usia" min="0" max="120" style="width:100%"></td>
<td><select class="asel"><option value="Istri">Istri</option><option value="Suami">Suami</option><option value="Anak">Anak</option><option value="Orang Tua">Orang Tua</option><option value="Cucu">Cucu</option><option value="Lainnya">Lainnya</option></select></td>
<td><div class="apy-wrap">${penyakitCbs}</div></td>
<td><button class="btn-del-a" onclick="this.closest('tr').remove()">✕</button></td>
`;
tbody.appendChild(tr);
}
async function submitKM() {
const nama = document.getElementById('km_nama').value.trim();
const lat = parseFloat(document.getElementById('km_lat').value);
const lng = parseFloat(document.getElementById('km_lng').value);
if (!nama) { alert('Nama KK wajib diisi.'); return; } if (isNaN(lat) || isNaN(lng)) { alert('Pilih lokasi di peta.'); return; }
const rp = [...document.querySelectorAll('.pykk:checked')].map(cb => cb.value);
const kelurahan_id = document.getElementById('km_kel').value;
const nik = document.getElementById('km_nik').value.trim();
const kategori = document.getElementById('km_kat').value;
const jumlah_anggota = parseInt(document.getElementById('km_jml').value) || 1;
const penghasilan = parseInt(document.getElementById('km_ph').value) || 0;
const tgl_lahir = document.getElementById('km_tgl').value;
const pendidikan = document.getElementById('km_pend').value;
const keterangan = document.getElementById('km_ket').value.trim();
const anggotaRows = document.querySelectorAll('#anggotaBody tr');
const anggota = [];
anggotaRows.forEach(tr => {
const inputs = tr.querySelectorAll('input[type=text], input[type=number]');
const sel = tr.querySelector('select');
const nm = inputs[0]?.value.trim();
const us = parseInt(inputs[1]?.value) || 0;
const hub = sel?.value || 'Anak';
const py = [...tr.querySelectorAll('.apy-cb:checked')].map(cb => cb.value);
if (nm) anggota.push({ nama: nm, usia: us, hub, py });
});
try {
const res = await fetch('api/warga.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nama, nik, lat, lng, rp, kelurahan_id, kategori, jumlah_anggota, penghasilan, tgl_lahir, pendidikan, keterangan, anggota })
});
const json = await res.json();
if (json.success) {
closeModal('modalKM');
dataDirty = true;
await loadData();
render();
renderDT();
toast(`Data "${nama}" berhasil ditambahkan`);
} else {
alert('Gagal menyimpan: ' + json.message);
}
} catch (e) {
alert('Koneksi server gagal.');
}
}
/* EDIT WARGA MODAL LOGIC */
let curEditWargaId = null;
function openEditWarga(id) {
const w = warga.find(x => x.id == id);
if (!w) { alert('Data warga tidak ditemukan.'); return; }
curEditWargaId = id;
// Pre-fill inputs
document.getElementById('ew_nama').value = w.nama || '';
document.getElementById('ew_nik').value = w.nik || '';
document.getElementById('ew_tgl').value = w.tgl || '';
document.getElementById('ew_pend').value = w.pend || '';
document.getElementById('ew_kat').value = w.kat || 'miskin';
// Select kelurahan by ID
const kelOpt = kelurahanList.find(k => k.nama === w.kel);
document.getElementById('ew_kel').value = kelOpt ? kelOpt.id : '';
document.getElementById('ew_jml').value = w.jml || 1;
document.getElementById('ew_ph').value = w.ph || 0;
document.getElementById('ew_ket').value = w.ket || '';
document.getElementById('ew_lat').value = w.lat ? parseFloat(w.lat).toFixed(6) : '';
document.getElementById('ew_lng').value = w.lng ? parseFloat(w.lng).toFixed(6) : '';
// Uncheck all KK illnesses, then check the ones that match
document.querySelectorAll('.ewpykk').forEach(cb => {
cb.checked = (w.rp && w.rp.includes(cb.value));
});
// Clear family member body
const tbody = document.getElementById('ewAnggotaBody');
tbody.innerHTML = '';
// Add other family members (excluding KK)
if (w.anggota) {
w.anggota.forEach(a => {
if (a.hub === 'Kepala Keluarga') return;
addEwAnggotaRow(a.nama, a.usia, a.hub, a.py);
});
}
openModal('modalEditWarga');
setTimeout(() => initEwMap(w.lat, w.lng, w.kat), 200);
}
function initEwMap(lat, lng, kat) {
const con = document.getElementById('ewMapPick');
if (ewPickMap) {
ewPickMap.invalidateSize();
} else {
ewPickMap = L.map(con, { zoomControl: false }).setView(CENTER, 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '', maxZoom: 19 }).addTo(ewPickMap);
ewPickMap.on('click', e => {
const { lat, lng } = e.latlng;
document.getElementById('ew_lat').value = lat.toFixed(6);
document.getElementById('ew_lng').value = lng.toFixed(6);
if (ewPickMk) ewPickMap.removeLayer(ewPickMk);
const currentKat = document.getElementById('ew_kat').value;
const col = currentKat === 'sangat_miskin' ? '#f03e5a' : currentKat === 'miskin' ? '#f97316' : '#1a5fff';
ewPickMk = L.marker([lat, lng], {
icon: L.divIcon({
html: `<div style="width:18px;height:18px;background:${col};border-radius:50%;border:2px solid #fff;box-shadow:0 2px 6px rgba(0,0,0,.3)"></div>`,
iconSize: [18, 18],
iconAnchor: [9, 9],
className: ''
})
}).addTo(ewPickMap);
});
}
if (ewPickMk) ewPickMap.removeLayer(ewPickMk);
if (lat && lng) {
const parsedLat = parseFloat(lat);
const parsedLng = parseFloat(lng);
ewPickMap.setView([parsedLat, parsedLng], 14);
const col = kat === 'sangat_miskin' ? '#f03e5a' : kat === 'miskin' ? '#f97316' : '#1a5fff';
ewPickMk = L.marker([parsedLat, parsedLng], {
icon: L.divIcon({
html: `<div style="width:18px;height:18px;background:${col};border-radius:50%;border:2px solid #fff;box-shadow:0 2px 6px rgba(0,0,0,.3)"></div>`,
iconSize: [18, 18],
iconAnchor: [9, 9],
className: ''
})
}).addTo(ewPickMap);
}
}
function addEwAnggotaRow(nama = '', usia = '', hubungan = 'Anak', penyakit = []) {
const tbody = document.getElementById('ewAnggotaBody');
const tr = document.createElement('tr');
const penyakitCbs = Object.entries(PL).map(([v, l]) => {
const checked = penyakit.includes(v) ? 'checked' : '';
return `<label title="${l}"><input type="checkbox" class="ewapy-cb" value="${v}" ${checked}>${PLIK[v] || ''} ${l}</label>`;
}).join('');
tr.innerHTML = `
<td><input class="ai" type="text" placeholder="Nama anggota" value="${nama}"></td>
<td><input class="ai" type="number" placeholder="Usia" min="0" max="120" style="width:100%" value="${usia}"></td>
<td><select class="asel">
<option value="Istri" ${hubungan === 'Istri' ? 'selected' : ''}>Istri</option>
<option value="Suami" ${hubungan === 'Suami' ? 'selected' : ''}>Suami</option>
<option value="Anak" ${hubungan === 'Anak' ? 'selected' : ''}>Anak</option>
<option value="Orang Tua" ${hubungan === 'Orang Tua' ? 'selected' : ''}>Orang Tua</option>
<option value="Cucu" ${hubungan === 'Cucu' ? 'selected' : ''}>Cucu</option>
<option value="Lainnya" ${hubungan === 'Lainnya' ? 'selected' : ''}>Lainnya</option>
</select></td>
<td><div class="apy-wrap">${penyakitCbs}</div></td>
<td><button class="btn-del-a" onclick="this.closest('tr').remove()">✕</button></td>
`;
tbody.appendChild(tr);
}
async function submitEditWarga() {
if (!curEditWargaId) return;
const nama = document.getElementById('ew_nama').value.trim();
const lat = parseFloat(document.getElementById('ew_lat').value);
const lng = parseFloat(document.getElementById('ew_lng').value);
if (!nama) { alert('Nama KK wajib diisi.'); return; }
if (isNaN(lat) || isNaN(lng)) { alert('Pilih lokasi di peta.'); return; }
const rp = [...document.querySelectorAll('.ewpykk:checked')].map(cb => cb.value);
const kelurahan_id = document.getElementById('ew_kel').value;
const nik = document.getElementById('ew_nik').value.trim();
const kategori = document.getElementById('ew_kat').value;
const jumlah_anggota = parseInt(document.getElementById('ew_jml').value) || 1;
const penghasilan = parseInt(document.getElementById('ew_ph').value) || 0;
const tgl_lahir = document.getElementById('ew_tgl').value;
const pendidikan = document.getElementById('ew_pend').value;
const keterangan = document.getElementById('ew_ket').value.trim();
const anggotaRows = document.querySelectorAll('#ewAnggotaBody tr');
const anggota = [];
anggotaRows.forEach(tr => {
const inputs = tr.querySelectorAll('input[type=text], input[type=number]');
const sel = tr.querySelector('select');
const nm = inputs[0]?.value.trim();
const us = parseInt(inputs[1]?.value) || 0;
const hub = sel?.value || 'Anak';
const py = [...tr.querySelectorAll('.ewapy-cb:checked')].map(cb => cb.value);
if (nm) anggota.push({ nama: nm, usia: us, hub, py });
});
try {
const res = await fetch(`api/warga.php?id=${curEditWargaId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nama, nik, lat, lng, rp, kelurahan_id, kategori, jumlah_anggota, penghasilan, tgl_lahir, pendidikan, keterangan, anggota })
});
const json = await res.json();
if (json.success) {
closeModal('modalEditWarga');
dataDirty = true;
await loadData();
render();
renderDT();
toast(`Data "${nama}" berhasil diperbarui`);
} else {
alert('Gagal memperbarui: ' + json.message);
}
} catch (e) {
alert('Koneksi server gagal.');
}
}
/* PUBLIC REPORT */
// repPickMap2 dan repPickMk2 sudah dideklarasikan di atas (global state)
function showPublicReportPage() {
showPage('pageRep');
setTimeout(() => {
const con = document.getElementById('repMap'); if (!con) return;
if (repPickMap2) { repPickMap2.invalidateSize(); return; }
repPickMap2 = L.map(con, { zoomControl: false }).setView(CENTER, 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '', maxZoom: 19 }).addTo(repPickMap2);
repPickMap2.on('click', e => {
const { lat, lng } = e.latlng;
document.getElementById('rep_lat').value = lat.toFixed(6); document.getElementById('rep_lng').value = lng.toFixed(6);
document.getElementById('repMapLbl').style.display = 'none';
if (repPickMk2) repPickMap2.removeLayer(repPickMk2);
repPickMk2 = L.marker([lat, lng], { icon: L.divIcon({ html: '<div class="pick-pin">📍</div>', iconSize: [26, 26], iconAnchor: [13, 26], className: '' }) }).addTo(repPickMap2);
});
}, 200);
}
async function submitPublicReport() {
const nama = document.getElementById('rep_nama').value.trim();
const kelurahan_id = document.getElementById('rep_kel').value;
const ket = document.getElementById('rep_ket').value.trim();
if (!nama) { alert('Nama wajib diisi.'); return; } if (!kelurahan_id) { alert('Kelurahan wajib dipilih.'); return; } if (!ket) { alert('Keterangan wajib diisi.'); return; }
const payload = {
pelapor: document.getElementById('rep_pelapor').value.trim() || 'Anonim',
hp: document.getElementById('rep_hp').value.trim(),
nama,
nik: document.getElementById('rep_nik').value.trim(),
kelurahan_id,
kategori: document.getElementById('rep_kat').value,
jumlah_anggota: parseInt(document.getElementById('rep_jml').value) || 0,
penghasilan: parseInt(document.getElementById('rep_ph').value) || 0,
keterangan: ket,
lat: parseFloat(document.getElementById('rep_lat').value) || null,
lng: parseFloat(document.getElementById('rep_lng').value) || null
};
try {
const res = await fetch('api/laporan.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const json = await res.json();
if (json.success) {
document.getElementById('repOkNo').textContent = json.data.no;
document.getElementById('repFormWrap').style.display = 'none';
document.getElementById('repOk').classList.add('show');
dataDirty = true; // laporan baru masuk, tandai data perlu reload
await loadData();
updateLapBadge();
} else {
alert('Gagal mengirim laporan: ' + json.message);
}
} catch (e) {
alert('Koneksi server gagal.');
}
}
function resetRep() { document.getElementById('repFormWrap').style.display = ''; document.getElementById('repOk').classList.remove('show');['rep_pelapor', 'rep_hp', 'rep_nama', 'rep_nik', 'rep_ket', 'rep_lat', 'rep_lng', 'rep_jml', 'rep_ph'].forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; }); document.getElementById('repMapLbl').style.display = ''; if (repPickMk2 && repPickMap2) { repPickMap2.removeLayer(repPickMk2); repPickMk2 = null; } }
/* ADMIN REPORT CREATION */
function openModalLaporanAdmin() {
clearFormLaporanAdmin();
openModal('modalLaporanAdmin');
setTimeout(initLapAdmMap, 200);
}
function clearFormLaporanAdmin() {
['lap_adm_pelapor', 'lap_adm_hp', 'lap_adm_nama', 'lap_adm_nik', 'lap_adm_kel', 'lap_adm_kat', 'lap_adm_jml', 'lap_adm_ph', 'lap_adm_ket', 'lap_adm_lat', 'lap_adm_lng'].forEach(id => {
const el = document.getElementById(id);
if (el) {
if (el.tagName === 'SELECT') el.value = el.options[0]?.value || '';
else el.value = '';
}
});
document.getElementById('lapAdmMapLbl').style.display = '';
if (lapAdmPickMk && lapAdmPickMap) {
lapAdmPickMap.removeLayer(lapAdmPickMk);
lapAdmPickMk = null;
}
}
function initLapAdmMap() {
const con = document.getElementById('lapAdmMapPick');
if (lapAdmPickMap) { lapAdmPickMap.invalidateSize(); return; }
lapAdmPickMap = L.map(con, { zoomControl: false }).setView(CENTER, 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '', maxZoom: 19 }).addTo(lapAdmPickMap);
lapAdmPickMap.on('click', e => {
const { lat, lng } = e.latlng;
document.getElementById('lap_adm_lat').value = lat.toFixed(6);
document.getElementById('lap_adm_lng').value = lng.toFixed(6);
document.getElementById('lapAdmMapLbl').style.display = 'none';
if (lapAdmPickMk) lapAdmPickMap.removeLayer(lapAdmPickMk);
lapAdmPickMk = L.marker([lat, lng], { icon: L.divIcon({ html: '<div class="pick-pin">📍</div>', iconSize: [26, 26], iconAnchor: [13, 26], className: '' }) }).addTo(lapAdmPickMap);
});
}
async function submitLaporanAdmin() {
const nama = document.getElementById('lap_adm_nama').value.trim();
const kelurahan_id = document.getElementById('lap_adm_kel').value;
const ket = document.getElementById('lap_adm_ket').value.trim();
if (!nama) { alert('Nama Kepala Keluarga wajib diisi.'); return; }
if (!kelurahan_id) { alert('Kelurahan wajib dipilih.'); return; }
if (!ket) { alert('Keterangan wajib diisi.'); return; }
const payload = {
pelapor: document.getElementById('lap_adm_pelapor').value.trim() || 'Anonim',
hp: document.getElementById('lap_adm_hp').value.trim(),
nama,
nik: document.getElementById('lap_adm_nik').value.trim(),
kelurahan_id,
kategori: document.getElementById('lap_adm_kat').value,
jumlah_anggota: parseInt(document.getElementById('lap_adm_jml').value) || 0,
penghasilan: parseInt(document.getElementById('lap_adm_ph').value) || 0,
keterangan: ket,
lat: parseFloat(document.getElementById('lap_adm_lat').value) || null,
lng: parseFloat(document.getElementById('lap_adm_lng').value) || null
};
try {
const res = await fetch('api/laporan.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const json = await res.json();
if (json.success) {
closeModal('modalLaporanAdmin');
dataDirty = true;
await loadData();
renderLap();
updateLapBadge();
toast(`✓ Laporan untuk "${nama}" berhasil dibuat: ${json.data.no}`);
} else {
alert('Gagal mengirim laporan: ' + json.message);
}
} catch (e) {
alert('Koneksi server gagal.');
}
}
/* TOAST */
let toastT = null;
function toast(msg) { document.getElementById('toastMsg').textContent = msg; const t = document.getElementById('toast'); t.classList.add('show'); if (toastT) clearTimeout(toastT); toastT = setTimeout(() => t.classList.remove('show'), 2800); }
/* APP INITIALIZATION */
async function initApp() {
if (mapReady) {
// Peta sudah siap — render ulang hanya jika data berubah
if (dataDirty) { await loadData(); render(); }
return;
}
// First load: fetch data lalu inisialisasi peta
dataDirty = true;
await loadData();
setTimeout(() => {
sharedRenderer = L.canvas({ padding: 0.5 });
map = L.map('map', {
zoomControl: true,
preferCanvas: true,
renderer: sharedRenderer,
zoomAnimation: true,
markerZoomAnimation: false
}).setView(CENTER, 14);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: 'Leaflet | &copy; OpenStreetMap | CartoDB',
maxZoom: 19, subdomains: 'abcd',
keepBuffer: 1
}).addTo(map);
mapReady = true;
render();
// renderUsers() dipanggil lazy saat user buka tab Pengguna
}, 50);
}
// Check session and load kelurahan on load (check readyState first to prevent race condition)
async function startApp() {
await fetchKelurahan();
await checkSession();
const btnLapor = document.querySelector('#pageLogin .btn-lapor');
if (btnLapor) btnLapor.onclick = showPublicReportPage;
}
if (document.readyState === 'loading') {
window.addEventListener('DOMContentLoaded', startApp);
} else {
startApp();
}
</script>
</body>
</html>