Files
D1041231014_ProvertyMapping/01/index.html
T
2026-06-07 19:10:40 +07:00

7363 lines
258 KiB
HTML
Raw 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>SiPetaSos — Sistem Informasi Peta Sosial</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@600;700&display=swap"
rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg: #f8fafc;
--panel: #ffffff;
--panel2: #f1f5f9;
--border: #e2e8f0;
--navy: #0f172a;
--text: #1e293b;
--muted: #64748b;
--muted2: #94a3b8;
--green: #10b981;
--green-light: #d1fae5;
--red: #ef4444;
--red-light: #fee2e2;
--amber: #f59e0b;
--blue: #3b82f6;
--blue-light: #dbeafe;
--teal: #14b8a6;
--teal-light: #ccfbf1;
--purple: #8b5cf6;
--purple-light: #ede9fe;
}
body {
font-family: 'Inter', sans-serif;
background: var(--bg);
color: var(--text);
display: flex;
height: 100vh;
overflow: hidden;
}
@keyframes riseIn {
from {
opacity: 0;
transform: translateY(12px)
}
to {
opacity: 1;
transform: translateY(0)
}
}
@keyframes pulseGlow {
0% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4);
}
70% {
box-shadow: 0 0 0 10px rgba(59, 130, 246, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0);
}
}
@keyframes popupIn {
from {
opacity: 0;
transform: translateY(10px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* ── SIDEBAR ── */
#sidebar {
width: 360px;
min-width: 360px;
background: #ffffff;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border);
box-shadow: 0 0 30px rgba(0, 0, 0, 0.05);
z-index: 1000;
overflow: hidden;
}
.brand {
padding: 18px 20px 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.brand-row {
display: flex;
align-items: center;
gap: 10px;
}
.brand-icon {
width: 36px;
height: 36px;
background: var(--blue);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 16px;
animation: pulseGlow 2.5s ease-out infinite;
flex-shrink: 0;
}
.brand-title {
font-family: 'Space Grotesk', sans-serif;
font-size: 18px;
font-weight: 700;
color: var(--navy);
letter-spacing: -0.3px;
line-height: 1.1;
}
.brand-sub {
font-size: 10px;
color: var(--muted);
font-weight: 500;
display: block;
margin-top: 3px;
}
.sec {
padding: 16px 16px 10px;
flex-shrink: 0;
}
.sec-lbl {
font-size: 10px;
font-weight: 800;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 10px;
padding: 0 4px;
}
.mode-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.mbtn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 14px 8px;
background: #fff;
border: 1.5px solid var(--border);
border-radius: 12px;
cursor: pointer;
color: var(--navy);
transition: all 0.2s;
font-size: 12px;
font-weight: 700;
text-decoration: none;
}
.mbtn:hover {
border-color: var(--blue);
background: var(--blue-light);
color: var(--blue);
transform: translateY(-1px);
}
.mbtn i {
font-size: 18px;
color: var(--muted);
transition: 0.2s;
}
.mbtn:hover i {
color: var(--blue);
}
.mbtn.active {
border-color: var(--blue);
background: var(--blue-light);
}
.mbtn.active i {
color: var(--blue);
}
.list-wrap {
flex: 1;
overflow-y: auto;
padding: 0 12px;
}
.list-wrap::-webkit-scrollbar {
width: 4px;
}
.list-wrap::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 4px;
}
.leg-sec {
padding: 0 20px 18px;
flex-shrink: 0;
}
.leg-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.leg-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
font-weight: 600;
color: var(--navy);
}
.leg-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
/* ── MAIN CONTAINER ── */
#main-container {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
background: var(--bg);
overflow: hidden;
}
/* ── TOPBAR ── */
.app-topbar {
height: 64px;
min-height: 64px;
background: #ffffff;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 0 20px;
z-index: 900;
flex-shrink: 0;
}
.public-brand {
display: flex;
align-items: center;
gap: 10px;
min-width: 200px;
}
.brand-icon-mini {
width: 30px;
height: 30px;
border-radius: 9px;
display: grid;
place-items: center;
background: var(--blue);
color: #fff;
font-size: 13px;
}
.public-brand-text {
display: flex;
flex-direction: column;
}
.public-brand-title {
font-family: 'Space Grotesk', sans-serif;
font-weight: 700;
font-size: 15px;
color: var(--navy);
}
.public-brand-sub {
font-size: 10px;
color: var(--muted);
}
.public-only {
display: none !important;
}
body.public-mode .public-only {
display: flex !important;
}
body.public-mode .admin-only {
display: none !important;
}
.map-search {
flex: 1;
max-width: 380px;
display: flex;
align-items: center;
}
.map-search input {
width: 100%;
height: 38px;
border: 1.5px solid var(--border);
border-right: none;
border-radius: 10px 0 0 10px;
padding: 0 14px;
font-size: 13px;
outline: none;
transition: border-color 0.2s;
}
.map-search input:focus {
border-color: var(--blue);
}
.map-search button {
height: 38px;
padding: 0 14px;
border: 1.5px solid var(--border);
border-left: none;
border-radius: 0 10px 10px 0;
background: #fff;
cursor: pointer;
color: var(--muted);
transition: all 0.2s;
}
.map-search button:hover {
background: var(--blue-light);
color: var(--blue);
border-color: var(--blue);
}
.top-actions {
display: flex;
align-items: center;
gap: 8px;
}
.top-btn {
height: 38px;
padding: 0 14px;
border: 1.5px solid var(--border);
background: #fff;
color: var(--navy);
border-radius: 10px;
display: inline-flex;
align-items: center;
gap: 7px;
font-weight: 700;
font-size: 13px;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
}
.top-btn:hover {
border-color: var(--blue);
background: var(--blue-light);
color: var(--blue);
}
.top-btn.primary {
background: var(--blue);
border-color: var(--blue);
color: #fff;
}
.top-btn.primary:hover {
background: #2563eb;
}
.top-btn.teal {
background: linear-gradient(135deg, #0f766e, #14b8a6);
border-color: transparent;
color: #fff;
}
.top-btn.blue {
background: linear-gradient(135deg, #1d4ed8, #3b82f6);
border-color: transparent;
color: #fff;
}
.icon-btn {
width: 38px;
height: 38px;
border: 1.5px solid var(--border);
background: #fff;
color: var(--navy);
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
transition: all 0.2s;
}
.icon-btn:hover {
border-color: var(--blue);
background: var(--blue-light);
color: var(--blue);
}
.ib-badge {
position: absolute;
top: -6px;
right: -6px;
min-width: 16px;
height: 16px;
border-radius: 999px;
padding: 0 4px;
background: var(--red);
color: #fff;
font-size: 10px;
font-weight: 800;
display: grid;
place-items: center;
}
.tb-divider {
width: 1px;
height: 24px;
background: var(--border);
}
.user-avatar-btn {
height: 38px;
padding: 0 12px;
border: 1.5px solid var(--border);
background: #fff;
color: var(--navy);
border-radius: 10px;
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.user-avatar-btn:hover {
border-color: var(--blue);
}
.user-avatar {
width: 26px;
height: 26px;
border-radius: 8px;
display: grid;
place-items: center;
background: linear-gradient(135deg, #1d4ed8, #3b82f6);
color: #fff;
font-size: 11px;
font-weight: 800;
}
.user-caret {
font-size: 10px;
color: var(--muted);
}
.add-menu,
.user-menu {
position: relative;
}
.add-dropdown,
.user-dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 220px;
background: #fff;
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: 0 18px 50px rgba(15, 23, 42, .14);
padding: 8px;
display: none;
z-index: 1500;
animation: riseIn 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.add-menu.open .add-dropdown,
.user-menu.open .user-dropdown {
display: block;
}
.add-dropdown-head {
padding: 8px 10px;
color: var(--muted);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.add-item,
.ud-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
border-radius: 9px;
color: var(--navy);
text-decoration: none;
cursor: pointer;
font-size: 13px;
font-weight: 700;
transition: background 0.15s;
}
.add-item:hover,
.ud-item:hover {
background: var(--panel2);
}
.add-item small {
display: block;
color: var(--muted);
font-weight: 500;
margin-top: 2px;
}
.ai-icon {
width: 32px;
height: 32px;
border-radius: 9px;
display: grid;
place-items: center;
flex: 0 0 auto;
}
.ud-header {
padding: 10px;
}
.ud-name {
font-weight: 800;
color: var(--navy);
font-size: 14px;
}
.ud-role {
color: var(--muted);
font-size: 12px;
margin-top: 2px;
}
.ud-sep {
height: 1px;
background: var(--border);
margin: 4px 0;
}
.ud-item.danger {
color: #b91c1c;
}
/* ── PUBLIC BANNER ── */
#public-banner {
display: none;
align-items: center;
gap: 10px;
background: linear-gradient(90deg, #1e3a8a, #1d4ed8);
color: #fff;
font-size: 13px;
font-weight: 600;
padding: 10px 20px;
z-index: 910;
flex-shrink: 0;
}
#public-banner.show {
display: flex;
}
#public-banner a {
color: #93c5fd;
font-weight: 800;
}
body.public-mode #sidebar {
display: none;
}
/* ── MAP WRAPPER ── */
#map-wrapper {
flex: 1;
position: relative;
overflow: hidden;
}
#map {
width: 100%;
height: 100%;
z-index: 1;
background: #e2e8f0;
}
/* ── STATS STRIP ── */
.stats-strip {
position: absolute;
top: 14px;
left: 14px;
display: flex;
gap: 8px;
flex-wrap: wrap;
z-index: 500;
pointer-events: none;
}
.stat-pill {
display: flex;
align-items: center;
gap: 8px;
background: rgba(255, 255, 255, .94);
border: 1px solid var(--border);
border-radius: 12px;
padding: 8px 12px;
box-shadow: 0 4px 18px rgba(15, 23, 42, .08);
pointer-events: auto;
}
.stat-pill i {
width: 30px;
height: 30px;
border-radius: 8px;
display: grid;
place-items: center;
color: #fff;
font-size: 13px;
}
.stat-pill strong {
display: block;
color: var(--navy);
font-size: 15px;
line-height: 1;
}
.stat-pill small {
display: block;
color: var(--muted);
font-size: 10px;
margin-top: 2px;
}
.map-chip {
position: absolute;
left: 14px;
bottom: 16px;
z-index: 500;
border: 1px solid var(--border);
background: #fff;
color: var(--navy);
border-radius: 999px;
padding: 8px 14px;
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: 13px;
cursor: pointer;
box-shadow: 0 4px 18px rgba(15, 23, 42, .1);
transition: all 0.2s;
}
.map-chip:hover {
box-shadow: 0 8px 24px rgba(15, 23, 42, .15);
transform: translateY(-1px);
}
/* ── SIDE PANEL ── */
.side-panel,
.locked-panel {
position: absolute;
top: 14px;
right: 14px;
bottom: 14px;
width: min(360px, calc(100% - 28px));
background: #fff;
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: 0 18px 50px rgba(15, 23, 42, .14);
z-index: 700;
display: none;
overflow: hidden;
}
.side-panel.show,
.side-panel.open {
display: flex;
flex-direction: column;
}
.locked-panel.show {
display: block;
}
.panel-head {
padding: 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.panel-head h3 {
margin: 0;
display: flex;
align-items: center;
justify-content: space-between;
color: var(--navy);
font-size: 16px;
font-weight: 800;
}
.panel-head p {
margin: 4px 0 0;
color: var(--muted);
font-size: 12px;
}
.close-panel-btn {
border: 0;
background: var(--panel2);
width: 28px;
height: 28px;
border-radius: 8px;
cursor: pointer;
color: var(--navy);
display: grid;
place-items: center;
}
.close-panel-btn:hover {
background: var(--red-light);
color: var(--red);
}
.panel-list {
padding: 12px;
overflow-y: auto;
flex: 1;
}
.panel-list::-webkit-scrollbar {
width: 4px;
}
.panel-list::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 4px;
}
/* ── LOCKED PANEL ── */
.lp-header {
padding: 24px 20px;
text-align: center;
background: linear-gradient(140deg, #1e3a8a 0%, #2563eb 60%, #3b82f6 100%);
color: #fff;
}
.lp-icon-wrap {
width: 48px;
height: 48px;
margin: 0 auto 12px;
border-radius: 14px;
display: grid;
place-items: center;
background: rgba(255, 255, 255, .18);
font-size: 20px;
}
.lp-divider {
height: 1px;
background: var(--border);
}
.lp-title {
font-size: 20px;
font-weight: 800;
}
.lp-header-sub {
font-size: 12px;
opacity: .82;
margin-top: 4px;
}
.lp-body {
padding: 18px;
overflow-y: auto;
}
.lp-desc {
color: var(--muted);
line-height: 1.55;
margin: 0 0 14px;
font-size: 13px;
}
.lp-features {
padding: 0;
margin: 0 0 16px;
list-style: none;
display: grid;
gap: 10px;
}
.lp-features li {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
color: var(--navy);
font-size: 13px;
}
.feat-icon {
width: 32px;
height: 32px;
border-radius: 9px;
display: grid;
place-items: center;
flex-shrink: 0;
}
.lp-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 12px;
background: var(--navy);
color: #fff;
text-decoration: none;
padding: 12px 16px;
font-weight: 800;
font-size: 14px;
transition: all 0.2s;
}
.lp-btn:hover {
background: #1e293b;
transform: translateY(-1px);
}
.lp-note {
color: var(--muted);
font-size: 12px;
text-align: center;
margin-top: 10px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.lp-note i {
color: var(--green);
}
/* ── KK CARD (SIDE PANEL) ── */
.kk-card {
background: #fff;
border: 1.5px solid var(--border);
border-radius: 12px;
padding: 14px;
margin-bottom: 10px;
transition: all 0.2s;
animation: riseIn 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
.kk-card:hover {
border-color: var(--blue);
box-shadow: 0 6px 20px rgba(59, 130, 246, .12);
transform: translateY(-1px);
}
.kk-top {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.kk-avatar {
width: 38px;
height: 38px;
border-radius: 10px;
background: linear-gradient(135deg, #f1f5f9, #e2e8f0);
color: var(--muted);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex-shrink: 0;
}
.kk-name {
font-weight: 800;
font-size: 14px;
color: var(--navy);
line-height: 1.2;
}
.kk-sub {
font-size: 11px;
color: var(--muted);
margin-top: 2px;
}
.kk-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px dashed var(--border);
}
.kk-row:last-of-type {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.kk-row span {
color: var(--muted);
font-weight: 600;
}
.kk-row strong {
color: var(--navy);
font-weight: 700;
text-align: right;
}
.kk-badge {
font-size: 10px;
font-weight: 800;
padding: 3px 8px;
border-radius: 6px;
}
.badge-extreme {
background: var(--red-light);
color: #991b1b;
}
.badge-poor {
background: #ffedd5;
color: #9a3412;
}
.badge-vulnerable {
background: #fef3c7;
color: #92400e;
}
.badge-handled {
background: var(--green-light);
color: #065f46;
}
.badge-pending {
background: #f1f5f9;
color: var(--muted);
}
.blank-warning {
color: #dc2626 !important;
font-weight: 800 !important;
background: var(--red-light);
padding: 4px 8px;
border-radius: 6px;
display: inline-block;
margin-top: 6px;
border-bottom: none;
}
.kk-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.kk-actions select {
flex: 1;
padding: 6px 10px;
border: 1.5px solid var(--border);
border-radius: 8px;
font: 600 11px 'Inter', sans-serif;
color: var(--navy);
outline: none;
background: #fff;
cursor: pointer;
transition: 0.2s;
}
.kk-actions select:focus {
border-color: var(--blue);
}
.mini-btn {
width: 32px;
height: 32px;
border: 1.5px solid var(--border);
border-radius: 8px;
background: #fff;
color: var(--muted);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: 0.2s;
}
.mini-btn:hover {
border-color: var(--blue);
background: var(--blue-light);
color: var(--blue);
}
/* ── CARD ── */
.card {
background: #fff;
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px 14px;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.2s;
animation: riseIn 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
.card:hover {
border-color: var(--blue);
box-shadow: 0 4px 16px rgba(59, 130, 246, .1);
transform: translateY(-1px);
}
.card.active {
border-color: var(--blue);
box-shadow: 0 4px 16px rgba(59, 130, 246, .15);
}
.card-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 4px;
}
.card-name {
font-weight: 800;
font-size: 13px;
color: var(--navy);
}
.card-sub {
font-size: 11px;
color: var(--muted);
margin-top: 2px;
}
.card-badge {
font-size: 10px;
font-weight: 700;
padding: 3px 8px;
border-radius: 6px;
flex-shrink: 0;
}
/* ── LEAFLET OVERRIDES ── */
.leaflet-control-zoom a {
background: #fff !important;
color: var(--navy) !important;
border: 1px solid var(--border) !important;
font-weight: 700 !important;
}
.leaflet-bar {
box-shadow: 0 4px 16px rgba(0, 0, 0, .08);
border: none !important;
border-radius: 10px !important;
overflow: hidden;
}
.leaflet-control-attribution {
font-size: 10px !important;
background: rgba(255, 255, 255, .8) !important;
border-top-left-radius: 6px;
}
.leaflet-popup {
animation: popupIn 0.22s cubic-bezier(0.16, 1, 0.3, 1);
}
.leaflet-control-layers-base {
display: none !important;
}
.leaflet-control-layers-separator {
display: none !important;
}
.leaflet-control-layers {
border: 1px solid var(--border) !important;
border-radius: 14px !important;
box-shadow: 0 8px 30px rgba(0, 0, 0, .08) !important;
overflow: hidden;
}
.leaflet-control-layers-expanded {
padding: 16px !important;
background: rgba(255, 255, 255, .97) !important;
}
.leaflet-control-layers-overlays {
display: flex;
flex-direction: column;
gap: 6px;
}
.leaflet-control-layers label {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: var(--navy);
font-weight: 700;
margin: 0;
padding: 8px 10px;
background: #f8fafc;
border-radius: 9px;
cursor: pointer;
transition: all 0.2s;
}
.leaflet-control-layers label:hover {
background: var(--blue-light);
}
.leaflet-control-layers-overlays input[type=checkbox] {
width: 16px;
height: 16px;
cursor: pointer;
}
.lc-group-title {
color: var(--muted);
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: .5px;
margin: 0 0 6px;
padding: 0 2px;
}
.lc-item {
display: inline-flex;
align-items: center;
gap: 8px;
}
.lc-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.lc-dot-kemiskinan {
background: var(--amber);
}
.lc-dot-masjid {
background: var(--teal);
}
.lc-text strong {
display: block;
font-size: 12px;
}
.lc-text small {
display: block;
color: var(--muted);
font-size: 10px;
}
/* ── HINT ── */
#hint {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(15, 23, 42, .9);
color: #fff;
padding: 10px 18px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
z-index: 2000;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
white-space: nowrap;
}
#hint.show {
opacity: 1;
}
/* ── MODERN MARKER ── */
.modern-marker {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
}
.modern-marker .pulse {
position: absolute;
width: 100%;
height: 100%;
background: var(--marker-color);
border-radius: 50%;
opacity: 0.4;
animation: markerPulse 2s infinite ease-out;
}
.modern-marker .pin-body {
position: relative;
width: 32px;
height: 32px;
background: var(--marker-color);
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
z-index: 2;
border: 2px solid #fff;
}
.modern-marker .icon-container {
transform: rotate(45deg);
color: #fff;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
}
.modern-marker .pin-label {
position: absolute;
top: -24px;
left: 50%;
transform: translateX(-50%);
background: rgba(255, 255, 255, 0.95);
color: var(--navy);
padding: 3px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
pointer-events: none;
opacity: 0;
transition: opacity 0.2s, top 0.2s;
border: 1px solid var(--border);
z-index: 3;
}
.modern-marker:hover .pin-label {
opacity: 1;
top: -30px;
}
.path-glow { filter: drop-shadow(0px 2px 4px rgba(0,0,0,0.35)); }
.parsil-glow { filter: drop-shadow(0px 2px 6px rgba(0,0,0,0.25)); }
@keyframes markerPulse {
0% {
transform: scale(0.8);
opacity: 0.6;
}
100% {
transform: scale(1.6);
opacity: 0;
}
}
/* ── POPUP ── */
.km-popup {
min-width: 260px;
font: 13px 'Inter', sans-serif;
}
.km-title {
font-size: 15px;
font-weight: 800;
color: var(--navy);
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 8px;
}
.km-grid {
display: grid;
gap: 6px;
margin-bottom: 10px;
}
.km-row {
display: flex;
gap: 6px;
}
.km-label {
width: 110px;
font-weight: 700;
color: var(--muted);
font-size: 11px;
flex-shrink: 0;
}
.km-val {
flex: 1;
font-weight: 600;
color: var(--navy);
font-size: 12px;
}
.km-field {
margin-bottom: 10px;
}
.km-field label {
display: block;
font-weight: 700;
font-size: 11px;
color: var(--muted);
margin-bottom: 4px;
}
.km-field select {
width: 100%;
padding: 7px 10px;
border: 1.5px solid var(--border);
border-radius: 8px;
font: 600 12px 'Inter', sans-serif;
outline: none;
}
.km-field select:focus {
border-color: var(--blue);
}
.km-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.pbtn {
flex: 1;
min-width: 60px;
padding: 8px 8px;
border: none;
border-radius: 8px;
font: 700 11px 'Inter', sans-serif;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
transition: all 0.2s;
}
.pbtn-g {
background: var(--green-light);
color: #065f46;
}
.pbtn-g:hover {
background: var(--green);
color: #fff;
}
.pbtn-r {
background: var(--red-light);
color: #991b1b;
}
.pbtn-r:hover {
background: var(--red);
color: #fff;
}
.pbtn-b {
background: var(--blue-light);
color: #1e40af;
}
.pbtn-b:hover {
background: var(--blue);
color: #fff;
}
.pbtn-y {
background: #fef3c7;
color: #92400e;
}
.pbtn-y:hover {
background: var(--amber);
color: #fff;
}
.kk-popup-actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 5px;
padding: 10px 12px 12px;
}
.kk-popup-actions .pbtn {
flex: unset;
min-width: unset;
padding: 7px 4px;
font-size: 11px;
border-radius: 7px;
}
.kk-popup-actions .pbtn:last-child:nth-child(3n+1) {
grid-column: 1 / -1;
}
/* ── LEAFLET POPUP WRAPPER ── */
.leaflet-popup-content-wrapper {
padding: 0 !important;
border-radius: 14px !important;
overflow: hidden !important;
box-shadow: 0 20px 60px rgba(15,23,42,.18) !important;
border: 1px solid var(--border) !important;
}
.leaflet-popup-content {
margin: 0 !important;
width: auto !important;
min-width: 260px !important;
}
.leaflet-popup-tip-container { margin-top: -1px; }
/* Sembunyikan X default Leaflet — semua popup pakai custom close */
.leaflet-popup-close-button {
display: none !important;
}
/* ── POPUP INFO (.pi) ── */
.pi {
font-family: 'Inter', sans-serif;
min-width: 260px;
max-width: 310px;
}
.pi-header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 14px 12px;
background: linear-gradient(135deg, #1e3a8a 0%, #2563eb 100%);
color: #fff;
}
.pi-header-icon {
width: 36px;
height: 36px;
border-radius: 10px;
background: rgba(255,255,255,.18);
display: grid;
place-items: center;
font-size: 15px;
flex-shrink: 0;
}
.pi-header-info { flex: 1; min-width: 0; }
.pi-header-name {
font-weight: 800;
font-size: 14px;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pi-header-sub {
font-size: 11px;
opacity: .8;
margin-top: 2px;
}
.pi-close {
width: 26px;
height: 26px;
border: none;
border-radius: 7px;
background: rgba(255,255,255,.18);
color: #fff;
cursor: pointer;
display: grid;
place-items: center;
font-size: 12px;
transition: background .15s;
flex-shrink: 0;
margin-left: auto;
}
.pi-close:hover { background: rgba(255,255,255,.32); }
.pi-body {
padding: 10px 14px 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.pi-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
padding: 4px 0;
border-bottom: 1px dashed #f1f5f9;
font-size: 12px;
}
.pi-row:last-child { border-bottom: none; }
.pi-k {
color: var(--muted);
font-weight: 600;
flex-shrink: 0;
}
.pi-v {
font-weight: 700;
color: var(--navy);
text-align: right;
word-break: break-word;
}
.pi-badge {
display: inline-block;
font-size: 10px;
font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
letter-spacing: .2px;
}
.pi-badge.extreme { background:#fee2e2; color:#991b1b; }
.pi-badge.poor { background:#ffedd5; color:#9a3412; }
.pi-badge.vulnerable { background:#fef3c7; color:#92400e; }
/* ── ACTION ROW ── */
.act-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
padding: 10px 12px 12px;
border-top: 1px solid #f1f5f9;
}
/* ── MODAL FORM (.kk-modal) ── */
.kk-modal {
font-family: 'Inter', sans-serif;
min-width: 290px;
max-width: 360px;
}
.km-head {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 14px 12px;
background: linear-gradient(135deg, #1e3a8a 0%, #2563eb 100%);
color: #fff;
}
.km-icon {
width: 32px;
height: 32px;
border-radius: 9px;
background: rgba(255,255,255,.2);
display: grid;
place-items: center;
font-size: 14px;
flex-shrink: 0;
}
.km-title {
font-weight: 800;
font-size: 14px;
flex: 1;
line-height: 1.2;
}
.km-close {
width: 26px;
height: 26px;
border: none;
border-radius: 7px;
background: rgba(255,255,255,.18);
color: #fff;
cursor: pointer;
display: grid;
place-items: center;
font-size: 12px;
transition: background .15s;
flex-shrink: 0;
}
.km-close:hover { background: rgba(255,255,255,.3); }
.km-body {
padding: 14px;
max-height: 60vh;
overflow-y: auto;
}
.km-body::-webkit-scrollbar { width: 4px; }
.km-body::-webkit-scrollbar-thumb { background: #e2e8f0; border-radius: 4px; }
.km-section {
font-size: 10px;
font-weight: 800;
color: var(--blue);
text-transform: uppercase;
letter-spacing: 1px;
margin: 12px 0 8px;
padding-bottom: 6px;
border-bottom: 1.5px solid #dbeafe;
}
.km-section:first-child { margin-top: 0; }
.km-field {
margin-bottom: 10px;
}
.km-field label {
display: block;
font-weight: 700;
font-size: 11px;
color: var(--muted);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: .4px;
}
.km-field input,
.km-field textarea,
.km-field select {
width: 100%;
padding: 8px 11px;
border: 1.5px solid var(--border);
border-radius: 9px;
font: 600 12px 'Inter', sans-serif;
color: var(--navy);
outline: none;
background: #fff;
transition: border-color .2s, box-shadow .2s;
}
.km-field textarea {
resize: vertical;
min-height: 56px;
line-height: 1.5;
}
.km-field input:focus,
.km-field textarea:focus,
.km-field select:focus {
border-color: var(--blue);
box-shadow: 0 0 0 3px rgba(59,130,246,.12);
}
.km-field input::placeholder,
.km-field textarea::placeholder { color: #94a3b8; font-weight: 500; }
.km-gps-box {
border-radius: 10px;
overflow: hidden;
margin-bottom: 10px;
background: linear-gradient(135deg, #1e3a8a, #2563eb);
}
.km-gps-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
flex-wrap: wrap;
}
.km-gps-row span { color: rgba(255,255,255,.7); font-size: 11px; font-weight: 600; }
.km-gps-row strong { color: #fff; font-size: 13px; font-weight: 700; }
.km-gps-row i { color: rgba(255,255,255,.8); width: 16px; text-align: center; }
.km-btn {
padding: 9px 16px;
border: none;
border-radius: 9px;
font: 700 12px 'Inter', sans-serif;
cursor: pointer;
transition: all .18s;
}
.km-btn-save {
background: var(--blue);
color: #fff;
flex: 1;
}
.km-btn-save:hover { background: #1d4ed8; transform: translateY(-1px); }
.km-btn-cancel {
background: var(--panel2);
color: var(--navy);
}
.km-btn-cancel:hover { background: #e2e8f0; }
.km-foot {
display: flex;
gap: 8px;
padding: 10px 14px 14px;
border-top: 1px solid var(--border);
}
.req { color: var(--red); margin-left: 2px; }
.km-hint {
color: var(--muted);
font-weight: 500;
font-size: 10px;
margin-left: 4px;
}
.hint-txt {
font-size: 10px;
color: var(--muted);
margin-top: 6px;
font-style: italic;
}
.km-vuln-bar {
background: #f1f5f9;
border-radius: 8px;
padding: 10px 12px;
margin-top: 10px;
display: flex;
align-items: center;
gap: 10px;
}
.km-vuln-score {
font-size: 22px;
font-weight: 900;
color: var(--navy);
min-width: 36px;
text-align: center;
}
.km-vuln-label {
font-size: 11px;
color: var(--muted);
font-weight: 600;
}
.km-vuln-cat {
font-weight: 800;
font-size: 13px;
}
.km-checkbox-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
cursor: pointer;
user-select: none;
}
.km-checkbox-row input[type=checkbox] {
width: 15px;
height: 15px;
cursor: pointer;
accent-color: var(--blue);
}
.km-checkbox-row span {
font-size: 12px;
font-weight: 600;
color: var(--navy);
}
/* ── FORM EXTRAS ── */
.km-checks {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
margin-top: 4px;
}
.km-check {
display: flex;
align-items: center;
gap: 7px;
padding: 6px 8px;
border: 1.5px solid var(--border);
border-radius: 8px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
color: var(--navy);
transition: all .15s;
user-select: none;
background: #fff;
}
.km-check:hover { border-color: var(--blue); background: #eff6ff; }
.km-check input[type=checkbox] {
width: 14px;
height: 14px;
accent-color: var(--blue);
cursor: pointer;
flex-shrink: 0;
}
.km-gps-item {
display: flex;
align-items: center;
gap: 6px;
}
.km-gps-item span { color: rgba(255,255,255,.7); font-size: 10px; font-weight: 600; }
.km-gps-item strong { color: #fff; font-size: 12px; font-weight: 700; font-family: monospace; }
.km-gps-item i { color: rgba(255,255,255,.8); }
/* ── VULNERABILITY BOX ── */
.km-vuln-box {
background: linear-gradient(135deg, #f8fafc, #f1f5f9);
border: 1.5px solid var(--border);
border-radius: 10px;
padding: 10px 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin: 6px 0 10px;
}
.kvb-left { display: flex; flex-direction: column; gap: 3px; }
.kvb-label {
font-size: 10px;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .5px;
}
.kvb-cat {
font-size: 13px;
font-weight: 800;
color: var(--navy);
}
.kvb-score {
font-size: 28px;
font-weight: 900;
color: var(--blue);
line-height: 1;
}
/* ── BLANK SPOT POPUP ── */
.pi h4 {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 800;
color: var(--navy);
padding: 14px 14px 8px;
margin: 0;
border-bottom: 1px solid #f1f5f9;
}
/* ── MASJID FORM GPS MAP ── */
#m-map, #e-m-map, #k-map, #e-k-map {
height: 110px;
border-radius: 8px;
overflow: hidden;
background: #e2e8f0;
}
/* ── GPS BOX INNER TEXT ── */
.km-addr-row {
display: flex;
align-items: flex-start;
gap: 7px;
padding: 8px 12px 10px;
border-top: 1px solid rgba(255,255,255,.12);
}
.km-addr-row span,
.km-addr-row * {
color: #ffffff !important;
font-size: 12px;
font-weight: 500;
line-height: 1.45;
}
.km-gps-lbl {
font-size: 10px !important;
font-weight: 700 !important;
color: rgba(255,255,255,.65) !important;
letter-spacing: .8px;
text-transform: uppercase;
}
.km-gps-val {
font-size: 13px !important;
font-weight: 800 !important;
color: #ffffff !important;
font-family: 'Courier New', monospace;
}
.km-gps-row .km-gps-item {
display: flex;
flex-direction: column;
gap: 1px;
}
/* ── DRAWING CONTROLS ── */
.coord-row {
display: flex;
gap: 8px;
margin-top: 8px;
}
.coord-row input {
flex: 1;
}
.lm-modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, .45);
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
}
.lm-box {
background: #fff;
border-radius: 18px;
width: 460px;
max-width: 94vw;
max-height: 88vh;
display: flex;
flex-direction: column;
box-shadow: 0 24px 60px rgba(0, 0, 0, .18);
overflow: hidden;
animation: riseIn 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.lm-head {
display: flex;
align-items: center;
gap: 12px;
padding: 18px 20px;
border-bottom: 1px solid var(--border);
}
.lm-head-icon {
width: 38px;
height: 38px;
border-radius: 12px;
display: grid;
place-items: center;
font-size: 16px;
flex-shrink: 0;
}
.lm-head-title {
font-size: 16px;
font-weight: 800;
color: var(--navy);
flex: 1;
}
.lm-head-close {
width: 30px;
height: 30px;
border: 0;
background: var(--panel2);
border-radius: 9px;
cursor: pointer;
font-size: 14px;
display: grid;
place-items: center;
}
.lm-head-close:hover {
background: var(--red-light);
color: var(--red);
}
.lm-body {
flex: 1;
overflow-y: auto;
padding: 18px 20px;
}
.lm-field {
margin-bottom: 14px;
}
.lm-field label {
display: block;
font-weight: 700;
font-size: 12px;
color: var(--navy);
margin-bottom: 6px;
}
.lm-field input,
.lm-field select,
.lm-field textarea {
width: 100%;
padding: 10px 12px;
border: 1.5px solid var(--border);
border-radius: 10px;
font: 500 13px 'Inter', sans-serif;
color: var(--navy);
outline: none;
transition: border-color 0.2s;
background: var(--bg);
}
.lm-field input:focus,
.lm-field select:focus,
.lm-field textarea:focus {
border-color: var(--blue);
background: #fff;
}
.lm-field textarea {
min-height: 80px;
resize: vertical;
}
.lm-submit {
width: 100%;
padding: 12px;
border: none;
border-radius: 12px;
background: linear-gradient(135deg, #1d4ed8, #3b82f6);
color: #fff;
font: 700 14px 'Inter', sans-serif;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.2s;
}
.lm-submit:hover {
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(59, 130, 246, .3);
}
.lm-report-card {
border: 1px solid var(--border);
border-radius: 14px;
padding: 12px 14px;
margin-bottom: 10px;
background: #fff;
}
.lm-rc-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 6px;
}
.lm-rc-name {
font-size: 13px;
font-weight: 800;
color: var(--navy);
}
.lm-rc-time {
font-size: 11px;
color: var(--muted);
}
.lm-rc-desc {
font-size: 12px;
color: var(--muted);
line-height: 1.5;
margin-bottom: 8px;
}
.lm-rc-footer {
display: flex;
align-items: center;
justify-content: space-between;
}
.lm-status {
padding: 3px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 800;
}
.lm-status.menunggu {
background: #fef3c7;
color: #d97706;
}
.lm-status.diproses {
background: var(--blue-light);
color: #1d4ed8;
}
.lm-status.selesai {
background: var(--green-light);
color: #065f46;
}
.lm-rc-actions {
display: flex;
gap: 6px;
}
.lm-rc-btn {
border: none;
border-radius: 6px;
padding: 4px 10px;
font: 700 11px 'Inter', sans-serif;
cursor: pointer;
}
.lm-rc-btn.selesai-btn {
background: var(--green-light);
color: #065f46;
}
.lm-rc-btn.selesai-btn:hover {
background: var(--green);
color: #fff;
}
.lm-rc-btn.hapus-btn {
background: var(--red-light);
color: #991b1b;
}
.lm-rc-btn.hapus-btn:hover {
background: var(--red);
color: #fff;
}
/* ── HISTORI BANTUAN MODAL ── */
.hb-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, .55);
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
animation: riseIn .2s ease;
}
.hb-box {
background: #fff;
border-radius: 18px;
width: 460px;
max-width: 95vw;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 24px 60px rgba(0, 0, 0, .18);
overflow: hidden;
}
.hb-head {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
background: linear-gradient(135deg, #1e3a8a, #2563eb);
color: #fff;
}
.hb-head-icon {
width: 36px;
height: 36px;
border-radius: 12px;
background: rgba(255, 255, 255, .18);
display: grid;
place-items: center;
font-size: 16px;
}
.hb-head-title {
font-size: 15px;
font-weight: 800;
flex: 1;
}
.hb-head-close {
width: 28px;
height: 28px;
border-radius: 8px;
border: none;
background: rgba(255, 255, 255, .18);
color: #fff;
cursor: pointer;
display: grid;
place-items: center;
font-size: 13px;
}
.hb-head-close:hover {
background: rgba(255, 255, 255, .32);
}
.hb-body {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.hb-body::-webkit-scrollbar {
width: 4px;
}
.hb-body::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.hb-form {
background: var(--bg);
border: 1.5px solid var(--border);
border-radius: 14px;
padding: 14px;
margin-bottom: 16px;
}
.hb-form-title {
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: .8px;
color: var(--blue);
margin-bottom: 10px;
}
.hb-form input,
.hb-form select,
.hb-form textarea {
width: 100%;
padding: 7px 10px;
border: 1.5px solid var(--border);
border-radius: 8px;
font: 500 12px 'Inter', sans-serif;
color: var(--navy);
background: #fff;
outline: none;
}
.hb-form input:focus,
.hb-form select:focus,
.hb-form textarea:focus {
border-color: var(--blue);
}
.hb-submit {
width: 100%;
padding: 9px;
border: none;
border-radius: 9px;
background: var(--blue);
color: #fff;
font: 700 12px 'Inter', sans-serif;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
margin-top: 8px;
}
.hb-submit:hover {
background: #1d4ed8;
}
.hb-list-title {
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: .8px;
color: var(--muted);
margin-bottom: 10px;
}
.hb-item {
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px 14px;
margin-bottom: 8px;
background: #fff;
}
.hb-item-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 6px;
}
.hb-item-jenis {
font-size: 13px;
font-weight: 700;
color: var(--navy);
}
.hb-item-tgl {
font-size: 11px;
color: var(--muted);
font-weight: 600;
}
.hb-item-desc {
font-size: 12px;
color: var(--muted);
line-height: 1.5;
}
.hb-item-del {
border: none;
background: var(--red-light);
color: #991b1b;
border-radius: 6px;
padding: 4px 9px;
font: 700 11px 'Inter', sans-serif;
cursor: pointer;
margin-top: 8px;
}
.hb-item-del:hover {
background: var(--red);
color: #fff;
}
/* ── WALIKOTA BADGE ── */
.badge-walikota {
background: linear-gradient(135deg, #7c3aed, #a78bfa);
color: #fff;
font-size: 10px;
font-weight: 800;
padding: 3px 8px;
border-radius: 6px;
}
/* ── LAPORAN MASYARAKAT ── */
.lm-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, .6);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
animation: riseIn .25s ease;
backdrop-filter: blur(4px);
}
.lm-box {
background: #fff;
border-radius: 18px;
width: 500px;
max-width: 95vw;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 24px 60px rgba(0, 0, 0, .2);
overflow: hidden;
}
.lm-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid var(--border);
background: #fff;
}
.lm-head h3 {
margin: 0;
font-size: 18px;
font-weight: 800;
color: var(--navy);
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.lm-head h3 i {
color: var(--blue);
}
.lm-head p {
margin: 4px 0 0;
color: var(--muted);
font-size: 13px;
width: calc(100% - 40px);
}
.lm-close-btn {
position: absolute;
top: 20px;
right: 20px;
width: 32px;
height: 32px;
border: none;
background: var(--panel2);
color: var(--muted);
border-radius: 10px;
cursor: pointer;
display: grid;
place-items: center;
transition: 0.2s;
}
.lm-close-btn:hover {
background: var(--red-light);
color: var(--red);
}
.lm-body {
padding: 0;
overflow-y: auto;
flex: 1;
display: flex;
flex-direction: column;
}
.lm-tabs {
display: flex;
padding: 0 20px;
border-bottom: 1px solid var(--border);
background: #f8fafc;
}
.lm-tab {
flex: 1;
padding: 14px;
text-align: center;
border: none;
background: transparent;
font: 600 13px 'Inter', sans-serif;
color: var(--muted);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.lm-tab:hover {
color: var(--navy);
}
.lm-tab.active {
color: var(--blue);
border-bottom-color: var(--blue);
background: #fff;
font-weight: 700;
}
.lm-section {
display: none;
padding: 24px;
}
.lm-section.active {
display: block;
}
.lm-field {
margin-bottom: 16px;
}
.lm-field label {
display: block;
font-weight: 700;
font-size: 12px;
color: var(--navy);
margin-bottom: 6px;
}
.lm-field input,
.lm-field textarea,
.lm-field select {
width: 100%;
padding: 10px 14px;
border: 1.5px solid var(--border);
border-radius: 10px;
font: 500 13px 'Inter', sans-serif;
color: var(--navy);
outline: none;
transition: border-color 0.2s;
background: #fff;
}
.lm-field input:focus,
.lm-field textarea:focus,
.lm-field select:focus {
border-color: var(--blue);
}
.lm-field textarea {
min-height: 100px;
resize: vertical;
}
.lm-submit {
width: 100%;
padding: 14px;
border: none;
border-radius: 12px;
background: var(--blue);
color: #fff;
font: 700 14px 'Inter', sans-serif;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: 0.2s;
margin-top: 10px;
}
.lm-submit:hover {
background: #2563eb;
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(59, 130, 246, .2);
}
/* ── EXPORT MODAL ── */
.exp-modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, .5);
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
animation: riseIn .25s ease;
}
.exp-box {
background: #fff;
border-radius: 18px;
width: 420px;
max-width: 95vw;
box-shadow: 0 24px 60px rgba(0, 0, 0, .2);
overflow: hidden;
animation: riseIn .25s ease;
}
.exp-head {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
background: var(--navy);
color: #fff;
}
.exp-head-icon {
width: 34px;
height: 34px;
border-radius: 10px;
background: rgba(255, 255, 255, .15);
display: grid;
place-items: center;
font-size: 15px;
}
.exp-head-title {
font-size: 15px;
font-weight: 800;
flex: 1;
}
.exp-head-close {
width: 28px;
height: 28px;
border-radius: 8px;
border: none;
background: rgba(255, 255, 255, .15);
color: #fff;
cursor: pointer;
display: grid;
place-items: center;
}
.exp-head-close:hover {
background: rgba(255, 255, 255, .28);
}
.exp-body {
padding: 20px;
display: grid;
gap: 12px;
}
.exp-row {
display: flex;
align-items: center;
gap: 14px;
padding: 14px;
background: var(--bg);
border: 1.5px solid var(--border);
border-radius: 14px;
cursor: pointer;
transition: all 0.2s;
}
.exp-row:hover {
border-color: var(--blue);
background: var(--blue-light);
}
.exp-row-icon {
width: 42px;
height: 42px;
border-radius: 12px;
display: grid;
place-items: center;
font-size: 18px;
flex-shrink: 0;
}
.exp-row-text strong {
display: block;
font-size: 14px;
font-weight: 800;
color: var(--navy);
}
.exp-row-text small {
color: var(--muted);
font-size: 12px;
}
/* ── BLANK SPOT ── */
.blank-spot-circle {
stroke: #64748b;
stroke-width: 1.5;
fill: rgba(100, 116, 139, 0.06);
stroke-dasharray: 6 4;
}
/* ── RESPONSIVE ── */
@media (max-width: 1024px) {
#sidebar {
width: 280px;
min-width: 280px;
}
}
@media (max-width: 760px) {
body {
flex-direction: column;
overflow-y: auto;
height: auto;
}
#sidebar {
width: 100%;
min-width: 0;
}
#main-container {
min-height: 75vh;
}
.stats-strip {
display: none;
}
}
/* ── CHANGE PASSWORD MODAL ── */
#cp-modal {
position: fixed;
inset: 0;
background: rgba(15,23,42,.55);
backdrop-filter: blur(4px);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
animation: riseIn .2s ease;
}
#cp-modal.hidden { display: none; }
.cp-box {
background: #fff;
border-radius: 20px;
width: 420px;
max-width: 95vw;
box-shadow: 0 28px 80px rgba(15,23,42,.25);
overflow: hidden;
animation: riseIn .25s cubic-bezier(.16,1,.3,1);
}
.cp-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 22px;
background: linear-gradient(135deg,#1e3a8a,#2563eb);
color: #fff;
}
.cp-head-left { display:flex; align-items:center; gap:10px; }
.cp-head-icon {
width: 34px; height: 34px; border-radius: 10px;
background: rgba(255,255,255,.15);
display: grid; place-items: center;
font-size: 15px;
}
.cp-head h3 { font-size:15px; font-weight:800; }
.cp-head p { font-size:11px; opacity:.7; margin-top:1px; }
.cp-close {
width:30px; height:30px; border-radius:8px;
background: rgba(255,255,255,.15);
border: none; color: #fff; cursor: pointer;
display: grid; place-items: center; font-size:14px;
transition: background .15s;
}
.cp-close:hover { background: rgba(255,255,255,.3); }
.cp-body { padding: 24px; }
.cp-field { margin-bottom: 16px; }
.cp-field label {
display:block; font-size:11px; font-weight:800;
text-transform:uppercase; letter-spacing:.5px;
color: #0f172a; margin-bottom:6px;
}
.cp-input-wrap { position: relative; }
.cp-input-wrap .cp-ico {
position:absolute; left:13px; top:50%; transform:translateY(-50%);
color: #94a3b8; font-size:13px; pointer-events:none;
}
.cp-input-wrap input {
width:100%; padding:12px 42px;
background:#f1f5f9; border:1.5px solid #e2e8f0;
border-radius:11px; font:500 14px 'Inter',sans-serif;
color:#1e293b; outline:none;
transition: border-color .2s, box-shadow .2s, background .2s;
}
.cp-input-wrap input::placeholder { color:#94a3b8; font-weight:400; }
.cp-input-wrap input:focus {
background:#fff; border-color:#3b82f6;
box-shadow:0 0 0 4px rgba(59,130,246,.1);
}
.cp-eye {
position:absolute; right:12px; top:50%; transform:translateY(-50%);
background:none; border:none; cursor:pointer;
color:#94a3b8; font-size:13px; padding:4px;
transition: color .2s;
}
.cp-eye:hover { color:#1e293b; }
.cp-strength { height:4px; border-radius:4px; background:#e2e8f0; margin-top:8px; overflow:hidden; }
.cp-strength-fill { height:100%; border-radius:4px; width:0; transition:width .3s, background .3s; }
.cp-strength-lbl { font-size:11px; color:#94a3b8; margin-top:4px; font-weight:600; }
.cp-alert {
display:none; align-items:center; gap:9px;
padding:12px 14px; border-radius:11px;
font-size:13px; font-weight:600; margin-bottom:16px;
}
.cp-alert.show { display:flex; }
.cp-alert.err { background:#fff1f2; border:1.5px solid #fecdd3; color:#be123c; }
.cp-alert.ok { background:#f0fdf4; border:1.5px solid #bbf7d0; color:#15803d; }
.cp-submit {
width:100%; padding:13px;
background: linear-gradient(135deg,#1d4ed8,#3b82f6);
border:none; border-radius:11px;
color:#fff; font:700 14px 'Inter',sans-serif;
cursor:pointer; display:flex; align-items:center;
justify-content:center; gap:8px;
box-shadow: 0 6px 18px rgba(37,99,235,.28);
transition: all .25s;
}
.cp-submit:hover { transform:translateY(-2px); box-shadow:0 12px 28px rgba(37,99,235,.38); }
.cp-submit:disabled { opacity:.6; transform:none; cursor:not-allowed; }
.cp-footer {
display:flex; align-items:center; justify-content:center;
gap:6px; margin-top:14px;
font-size:11px; color:#94a3b8;
}
.cp-footer i { color:#10b981; }
/* ══════════════════════════════════════════
UI ENHANCEMENT — SiPetaSos Premium Look
══════════════════════════════════════════ */
/* ── STAT PILLS — Glassmorphism upgrade ── */
.stat-pill {
background: rgba(255,255,255,.96) !important;
border: none !important;
border-radius: 14px !important;
padding: 9px 14px !important;
box-shadow: 0 4px 24px rgba(15,23,42,.1), 0 1px 4px rgba(15,23,42,.06) !important;
gap: 10px !important;
transition: transform .2s, box-shadow .2s;
}
.stat-pill:hover { transform: translateY(-2px); box-shadow: 0 8px 32px rgba(15,23,42,.14) !important; }
.stat-pill i {
width: 34px !important; height: 34px !important;
border-radius: 10px !important;
font-size: 14px !important;
}
.stat-pill strong { font-size: 17px !important; font-weight: 800 !important; }
.stat-pill small { font-size: 10px !important; font-weight: 600 !important; color: #94a3b8 !important; letter-spacing:.3px; }
/* ── HINT BAR — Modern pill ── */
#hint {
background: linear-gradient(135deg, rgba(15,23,42,.92), rgba(30,58,138,.88)) !important;
backdrop-filter: blur(8px) !important;
border: 1px solid rgba(255,255,255,.12) !important;
padding: 12px 22px !important;
border-radius: 999px !important;
font-size: 13px !important;
font-weight: 700 !important;
letter-spacing: .2px;
box-shadow: 0 8px 32px rgba(15,23,42,.25) !important;
}
/* ── POPUP FORM .pf — Complete redesign ── */
.pf {
font-family: 'Inter', sans-serif;
padding: 0;
min-width: 260px;
}
.pf h4 {
display: flex !important;
align-items: center !important;
gap: 9px !important;
font-size: 14px !important;
font-weight: 800 !important;
color: #fff !important;
padding: 13px 16px !important;
margin: 0 !important;
border-bottom: none !important;
border-radius: 0;
}
.pf h4 i { font-size: 15px !important; opacity: .9; }
/* Color the h4 based on icon color */
.pf h4:has(.fa-gas-pump) { background: linear-gradient(135deg,#064e3b,#059669); }
.pf h4:has(.fa-road) { background: linear-gradient(135deg,#1e3a8a,#2563eb); }
.pf h4:has(.fa-vector-square){ background: linear-gradient(135deg,#78350f,#d97706); }
.pf label {
display: block;
font-size: 10px !important;
font-weight: 800 !important;
text-transform: uppercase;
letter-spacing: .6px;
color: #64748b !important;
margin: 12px 16px 5px !important;
}
.pf input, .pf select {
display: block;
width: calc(100% - 32px);
margin: 0 16px;
padding: 9px 12px !important;
border: 1.5px solid #e2e8f0 !important;
border-radius: 9px !important;
font: 600 13px 'Inter',sans-serif !important;
color: #1e293b !important;
background: #f8fafc !important;
outline: none;
transition: border-color .2s, box-shadow .2s, background .2s;
}
.pf input:focus, .pf select:focus {
border-color: #3b82f6 !important;
background: #fff !important;
box-shadow: 0 0 0 3px rgba(59,130,246,.12) !important;
}
.pf select { cursor: pointer; }
.pf .coord-row {
display: flex; gap: 8px;
margin: 0 16px !important;
padding: 0 !important;
}
.pf .coord-row input {
flex: 1; margin: 0 !important;
width: auto !important;
background: #f1f5f9 !important;
color: #64748b !important;
font-family: monospace !important;
font-size: 12px !important;
text-align: center;
cursor: default;
}
.pf .hint-txt {
font-size: 11px !important;
color: #94a3b8 !important;
margin: 6px 16px 0 !important;
display: flex; align-items: center; gap: 5px;
}
.pf .hint-txt::before { content: '⚡'; font-size: 10px; }
.pf .pbtn {
display: block !important;
width: calc(100% - 32px) !important;
margin: 12px 16px 14px !important;
padding: 10px !important;
border-radius: 10px !important;
font: 700 13px 'Inter',sans-serif !important;
border: none !important;
cursor: pointer !important;
text-align: center;
transition: all .2s !important;
}
.pf .pbtn-g {
background: linear-gradient(135deg,#059669,#10b981) !important;
color: #fff !important;
box-shadow: 0 4px 14px rgba(16,185,129,.3);
}
.pf .pbtn-b {
background: linear-gradient(135deg,#1d4ed8,#3b82f6) !important;
color: #fff !important;
box-shadow: 0 4px 14px rgba(37,99,235,.3);
}
.pf .pbtn-y {
background: linear-gradient(135deg,#d97706,#f59e0b) !important;
color: #fff !important;
box-shadow: 0 4px 14px rgba(217,119,6,.3);
}
.pf .pbtn:hover { transform: translateY(-2px) !important; filter: brightness(1.06); }
/* ── LEAFLET LAYER CONTROL — OPSIONAL section ── */
.lc-group-title {
font-size: 10px !important;
font-weight: 900 !important;
letter-spacing: 1px !important;
text-transform: uppercase !important;
color: #1e293b !important;
padding: 10px 10px 4px !important;
border-top: 1px solid #f1f5f9;
margin-top: 4px !important;
}
.lc-group-title:first-child { border-top: none; margin-top: 0; }
/* ── TOPBAR search upgrade ── */
.map-search input {
border-radius: 10px 0 0 10px !important;
font-family: 'Inter', sans-serif !important;
font-size: 13px !important;
}
.map-search button {
border-radius: 0 10px 10px 0 !important;
}
/* ── ADD DROPDOWN upgrade ── */
.add-dropdown { border-radius: 16px !important; }
.add-item {
border-radius: 10px !important;
margin: 2px 4px !important;
transition: background .15s, transform .15s !important;
}
.add-item:hover { transform: translateX(3px) !important; }
.ai-icon { border-radius: 10px !important; font-size: 15px !important; }
.add-item span:last-child { font-size: 13px !important; font-weight: 700 !important; }
.add-item small { color: #94a3b8 !important; font-weight: 500 !important; font-size: 11px !important; }
/* ── SIDEBAR brand upgrade ── */
.brand-title { font-size: 17px !important; }
.brand-sub {
font-size: 10px !important;
color: #94a3b8 !important;
margin-top: 2px !important;
display: block;
font-weight: 500 !important;
}
/* ── USER DROPDOWN upgrade ── */
.user-dropdown { border-radius: 16px !important; min-width: 200px !important; }
.ud-item { border-radius: 10px !important; margin: 1px 4px !important; }
.ud-header { border-bottom: 1px solid #f1f5f9; padding-bottom: 10px !important; }
.ud-name { font-size: 13px !important; }
/* ── SCROLLBAR refinement ── */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #e2e8f0; border-radius: 999px; }
::-webkit-scrollbar-thumb:hover { background: #cbd5e1; }
/* ── FULL TABLE MODAL CSS ── */
.ftm-overlay {
position: fixed; inset: 0; z-index: 10000;
background: rgba(15, 23, 42, 0.75);
backdrop-filter: blur(8px);
display: flex; align-items: center; justify-content: center;
padding: 20px;
animation: fadeIn .25s ease;
}
.ftm-box {
background: var(--bg); border-radius: 16px;
width: 100%; max-width: 1200px; max-height: 90vh;
display: flex; flex-direction: column;
box-shadow: 0 25px 60px rgba(0,0,0,0.25);
overflow: hidden;
animation: riseIn .3s cubic-bezier(.16,1,.3,1);
}
.ftm-head {
display: flex; align-items: center; justify-content: space-between;
padding: 20px 24px; background: #fff; border-bottom: 1px solid var(--border);
}
.ftm-title { font-size: 20px; font-weight: 700; color: var(--navy); }
.ftm-sub { font-size: 13px; color: var(--muted); margin-top: 4px; }
.ftm-close {
width: 32px; height: 32px; border-radius: 8px; border: none;
background: var(--panel2); color: var(--muted); cursor: pointer;
display: flex; align-items: center; justify-content: center; font-size: 16px;
transition: .2s;
}
.ftm-close:hover { background: #fee2e2; color: #dc2626; }
.ftm-body {
flex: 1; overflow: auto; padding: 24px; background: var(--bg);
}
.ftm-filters {
display: flex; gap: 12px; margin-bottom: 16px; align-items: center;
}
.ftm-filter-select {
padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px;
font-family: inherit; font-size: 13px; background: #fff; outline: none;
}
.ftm-table-card {
background: #fff; border-radius: 12px; border: 1px solid var(--border);
box-shadow: 0 4px 16px rgba(0,0,0,0.03); overflow: auto;
}
.ftm-table-card table { width: 100%; border-collapse: collapse; }
.ftm-table-card th {
text-align: left; padding: 14px 16px; font-size: 12px; font-weight: 700;
color: var(--muted); text-transform: uppercase; border-bottom: 1px solid var(--border);
background: var(--panel-soft); position: sticky; top: 0; z-index: 10;
}
.ftm-table-card td { padding: 14px 16px; font-size: 13px; border-bottom: 1px solid var(--border); }
.ftm-table-card tr:last-child td { border-bottom: none; }
.ftm-table-card tr:hover td { background: var(--panel-soft); }
.ftm-badge { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
.ftm-btn-icon { background: none; border: none; cursor: pointer; color: var(--muted); font-size: 15px; transition: .2s; }
.ftm-btn-icon:hover { color: var(--blue); }
/* ── LOGIN MODAL ── */
.lm-overlay {
position: fixed; inset: 0; z-index: 12000;
background: rgba(15,23,42,.82); backdrop-filter: blur(10px);
display: flex; align-items: center; justify-content: center;
animation: fadeIn .2s ease;
}
.lm-box {
background: #fff; border-radius: 20px; width: 420px; max-width: 95vw;
box-shadow: 0 32px 80px rgba(0,0,0,.3);
overflow: hidden; animation: riseIn .3s cubic-bezier(.16,1,.3,1);
}
.lm-head {
background: linear-gradient(135deg,#1e3a8a,#2563eb);
padding: 28px 32px 24px; text-align: center; color: #fff;
}
.lm-head-icon { font-size: 36px; margin-bottom: 10px; }
.lm-head-title { font-size: 22px; font-weight: 800; }
.lm-head-sub { font-size: 13px; opacity: .8; margin-top: 4px; }
.lm-body { padding: 28px 32px; }
.lm-field { margin-bottom: 16px; }
.lm-field label { display: block; font-size: 12px; font-weight: 700; color: var(--muted); margin-bottom: 6px; letter-spacing: .5px; text-transform: uppercase; }
.lm-input-wrap { position: relative; }
.lm-input-wrap .lm-ico { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); color: var(--muted); font-size: 14px; }
.lm-input-wrap input { width: 100%; padding: 12px 14px 12px 40px; border: 1.5px solid var(--border); border-radius: 10px; font: 500 14px 'Plus Jakarta Sans',sans-serif; outline: none; transition: .2s; box-sizing: border-box; }
.lm-input-wrap input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(37,99,235,.12); }
.lm-eye { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); background: none; border: none; cursor: pointer; color: var(--muted); font-size: 14px; }
.lm-submit { width: 100%; padding: 13px; border: none; border-radius: 12px; background: linear-gradient(135deg,#1e3a8a,#2563eb); color: #fff; font: 700 14px 'Plus Jakarta Sans',sans-serif; cursor: pointer; box-shadow: 0 6px 20px rgba(37,99,235,.3); transition: .2s; margin-top: 4px; }
.lm-submit:hover { transform: translateY(-1px); box-shadow: 0 8px 28px rgba(37,99,235,.4); }
.lm-submit:disabled { opacity: .6; cursor: not-allowed; transform: none; }
.lm-alert { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-radius: 8px; font-size: 13px; font-weight: 600; margin-bottom: 16px; }
.lm-alert.error { background: #fee2e2; color: #dc2626; }
.lm-close-btn { position: absolute; top: 14px; right: 14px; background: rgba(255,255,255,.15); border: none; border-radius: 8px; width: 30px; height: 30px; cursor: pointer; color: #fff; font-size: 14px; display: flex; align-items: center; justify-content: center; }
/* ── PROFIL MODAL ── */
.prf-overlay {
position: fixed; inset: 0; z-index: 12000;
background: rgba(15,23,42,.75); backdrop-filter: blur(8px);
display: flex; align-items: center; justify-content: center; padding: 20px;
animation: fadeIn .2s ease;
}
.prf-box {
background: #fff; border-radius: 20px; width: 480px; max-width: 95vw;
box-shadow: 0 28px 70px rgba(0,0,0,.25);
overflow: hidden; animation: riseIn .3s cubic-bezier(.16,1,.3,1);
}
.prf-head {
background: linear-gradient(135deg,#0f172a,#1e3a8a);
padding: 28px 28px 60px; text-align: center; position: relative;
}
.prf-avatar {
width: 80px; height: 80px; border-radius: 50%; margin: 0 auto 12px;
background: linear-gradient(135deg,#3b82f6,#6366f1);
display: flex; align-items: center; justify-content: center;
font-size: 32px; font-weight: 800; color: #fff;
border: 3px solid rgba(255,255,255,.3);
}
.prf-head-name { font-size: 18px; font-weight: 700; color: #fff; }
.prf-head-role { font-size: 12px; color: rgba(255,255,255,.7); margin-top: 4px; }
.prf-body { padding: 0 28px 28px; margin-top: -30px; }
.prf-card { background: #f8fafc; border: 1px solid var(--border); border-radius: 12px; padding: 20px; margin-bottom: 16px; }
.prf-row { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--border); }
.prf-row:last-child { border-bottom: none; padding-bottom: 0; }
.prf-label { font-size: 12px; color: var(--muted); font-weight: 600; text-transform: uppercase; }
.prf-value { font-size: 14px; font-weight: 600; color: var(--navy); }
.prf-edit-row { display: flex; gap: 8px; align-items: center; }
.prf-input { padding: 8px 12px; border: 1.5px solid var(--border); border-radius: 8px; font: 600 14px 'Plus Jakarta Sans',sans-serif; outline: none; transition: .2s; }
.prf-input:focus { border-color: var(--blue); }
.prf-save-btn { padding: 8px 14px; background: var(--blue); color: #fff; border: none; border-radius: 8px; font: 700 12px 'Plus Jakarta Sans',sans-serif; cursor: pointer; }
.prf-close { position: absolute; top: 14px; right: 14px; background: rgba(255,255,255,.15); border: none; border-radius: 8px; width: 30px; height: 30px; cursor: pointer; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 14px; }
/* ── KELOLA AKUN MODAL (Users) ── */
.ua-role-badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 10px; border-radius: 999px; font-size: 11px; font-weight: 700; }
.ua-role-admin { background: #dbeafe; color: #1d4ed8; }
.ua-role-operator { background: #f0fdf4; color: #15803d; }
.ua-form { background: #f8fafc; border: 1px solid var(--border); border-radius: 12px; padding: 20px; margin-bottom: 20px; }
.ua-form-title { font-size: 14px; font-weight: 700; color: var(--navy); margin-bottom: 14px; display: flex; align-items: center; gap: 8px; }
.ua-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.ua-field label { display: block; font-size: 11px; font-weight: 700; color: var(--muted); margin-bottom: 5px; text-transform: uppercase; }
.ua-field input, .ua-field select { width: 100%; padding: 9px 12px; border: 1.5px solid var(--border); border-radius: 8px; font: 500 13px 'Plus Jakarta Sans',sans-serif; outline: none; transition: .2s; box-sizing: border-box; }
.ua-field input:focus, .ua-field select:focus { border-color: var(--blue); }
.ua-submit { padding: 10px 20px; background: var(--blue); color: #fff; border: none; border-radius: 8px; font: 700 13px 'Plus Jakarta Sans',sans-serif; cursor: pointer; margin-top: 14px; }
.ua-avatar { width: 32px; height: 32px; border-radius: 8px; display: grid; place-items: center; font: 700 13px 'Space Grotesk',sans-serif; color: #fff; }
</style>
</head>
<body>
<div id="sidebar">
<div class="brand">
<div class="brand-row">
<div class="brand-icon"><i class="fas fa-map-location-dot"></i></div>
<div class="brand-title">SiPeta<span style="color:var(--blue)">Sos</span></div>
</div>
<div class="brand-sub">Sistem Informasi Peta Sosial & Rumah Ibadah</div>
</div>
<div class="list-wrap">
<div style="padding:20px 20px 10px">
<a href="#" onclick="openDataWargaModal(); return false;"
style="display:flex;align-items:center;gap:10px;background:linear-gradient(135deg,#3b82f6,#6366f1);border-radius:14px;padding:20px 20px 10px;text-decoration:none;color:white;font-weight:600;font-size:14px;box-shadow: 0 8px 30px rgba(0,0,0,0.04);transition:0.2s">
<i class="fas fa-users" style="font-size:18px"></i>
<div>
<div>Lihat Data Warga</div>
<div style="font-weight:400;font-size:11px;opacity:0.85;margin-top:2px">Daftar & kelola anggota keluarga
</div>
</div>
<i class="fas fa-arrow-right" style="margin-left:auto;font-size:12px"></i>
</a>
</div>
<div style="padding:0 20px 20px">
<a href="#" onclick="openDataMasjidModal(); return false;"
style="display:flex;align-items:center;gap:10px;background:linear-gradient(135deg,#14b8a6,#0ea5e9);border-radius:14px;padding:20px 20px 10px;text-decoration:none;color:white;font-weight:600;font-size:14px;box-shadow: 0 8px 30px rgba(0,0,0,0.04);transition:0.2s">
<i class="fas fa-place-of-worship" style="font-size:18px"></i>
<div>
<div>Data Rumah Ibadah</div>
<div style="font-weight:400;font-size:11px;opacity:0.85;margin-top:2px">Kelola fasilitas ibadah</div>
</div>
<i class="fas fa-arrow-right" style="margin-left:auto;font-size:12px"></i>
</a>
</div>
<div id="list-masjid" style="display:none;"><span id="cnt-masjid">0</span></div>
<div id="list-kemiskinan" style="display:none;"><span id="cnt-kemiskinan">0</span></div>
</div>
<div class="leg-sec">
<div class="sec-lbl" style="margin-bottom:10px">Legenda Peta</div>
<div class="leg-grid">
<div class="leg-item">
<div class="leg-dot" style="background:var(--amber)"></div>Kemiskinan
</div>
<div class="leg-item">
<div class="leg-dot" style="background:var(--teal)"></div>Rumah Ibadah
</div>
</div>
</div>
</div>
<div id="main-container">
<!-- Public Mode Banner -->
<div id="public-banner">
<i class="fas fa-lock"></i>
<span>Mode Publik &mdash; Data KK miskin tidak ditampilkan.
<a href="#" onclick="openLoginModal(); return false;">Login</a> untuk akses penuh termasuk peta, daftar, dan pencarian data.
</span>
</div>
<div class="app-topbar">
<div class="public-brand public-only">
<span class="brand-icon-mini"><i class="fas fa-map-location-dot"></i></span>
<div class="public-brand-text">
<span class="public-brand-title">SiPeta<span style="color:var(--blue)">Sos</span></span>
<span class="public-brand-sub">Sistem Informasi Peta Sosial &amp; Rumah Ibadah</span>
</div>
</div>
<div class="map-search">
<input id="map-search" placeholder="Cari nama atau alamat..." oninput="renderDashboard()" onkeydown="if(event.key === 'Enter') runSearch()">
<button onclick="runSearch()"><i class="fas fa-search"></i></button>
</div>
<div class="top-actions">
<!-- Add Data dropdown (admin) -->
<div class="add-menu admin-only" id="add-menu">
<button class="top-btn" onclick="toggleAddMenu()">
<i class="fas fa-plus"></i> Tambah
<i class="fas fa-chevron-down" style="font-size:10px;margin-left:2px;"></i>
</button>
<div class="add-dropdown">
<div class="add-dropdown-head">Pilih Jenis Data</div>
<div class="add-item" onclick="setMode('kemiskinan');closeAddMenu()">
<span class="ai-icon" style="background:#fee2e2;color:#ef4444"><i class="fas fa-users"></i></span>
<span>Data KK Miskin <small>Tambah keluarga miskin</small></span>
</div>
<div class="add-item" onclick="setMode('masjid');closeAddMenu()">
<span class="ai-icon" style="background:#d1fae5;color:#059669"><i class="fas fa-place-of-worship"></i></span>
<span>Rumah Ibadah <small>Tambah lokasi ibadah</small></span>
</div>
<div style="height:1px;background:#e2e8f0;margin:4px 4px"></div>
<div style="padding:6px 10px 2px;font-size:10px;font-weight:800;color:#94a3b8;text-transform:uppercase;letter-spacing:.6px">Opsional</div>
<div class="add-item" onclick="setMode('spbu');closeAddMenu()">
<span class="ai-icon" style="background:#dcfce7;color:#16a34a"><i class="fas fa-gas-pump"></i></span>
<span>SPBU <small>Tambah titik SPBU</small></span>
</div>
<div class="add-item" onclick="setMode('jalan');closeAddMenu()">
<span class="ai-icon" style="background:#dbeafe;color:#2563eb"><i class="fas fa-road"></i></span>
<span>Jalan <small>Gambar ruas jalan</small></span>
</div>
<div class="add-item" onclick="setMode('parsil');closeAddMenu()">
<span class="ai-icon" style="background:#fef3c7;color:#d97706"><i class="fas fa-vector-square"></i></span>
<span>Parsil / Lahan <small>Gambar area persil</small></span>
</div>
</div>
</div>
<!-- Icon buttons -->
<button class="icon-btn admin-only" onclick="focusBlankSpot()" title="Blank Spot">
<i class="fas fa-triangle-exclamation"></i>
<span class="ib-badge" id="top-blank-count">0</span>
</button>
<button class="icon-btn admin-only" onclick="downloadReport()" title="Unduh Laporan CSV">
<i class="fas fa-download"></i>
</button>
<button class="icon-btn" onclick="openLaporanModal()" title="Laporan Masyarakat" id="btn-laporan-top">
<i class="fas fa-bullhorn"></i>
<span class="ib-badge" id="laporan-badge" style="display:none">0</span>
</button>
<!-- Data Panel (primary CTA) -->
<button class="top-btn primary admin-only" onclick="togglePanel()">
<i class="fas fa-table-list"></i> Data Panel
</button>
<!-- Divider + User avatar dropdown -->
<div class="tb-divider admin-only"></div>
<div class="user-menu admin-only" id="user-menu">
<button class="user-avatar-btn" onclick="toggleUserMenu()">
<div class="user-avatar" id="topbar-avatar">AD</div>
<span id="topbar-uname">Admin</span>
<i class="fas fa-chevron-down user-caret"></i>
</button>
<div class="user-dropdown">
<div class="ud-header">
<div class="ud-name" id="dd-nama">Admin</div>
<div class="ud-role" id="dd-role">Administrator</div>
</div>
<div class="ud-sep"></div>
<a href="#" onclick="openProfilModal(); return false;" class="ud-item"><i class="fas fa-circle-user"></i> Profil Saya</a>
<div class="ud-item" onclick="openChangePassword()"><i class="fas fa-key"></i> Ganti Sandi</div>
<a href="#" onclick="openKelolaAkunModal(); return false;" class="ud-item" id="dd-users-link"><i class="fas fa-users-gear"></i> Kelola Akun</a>
<div class="ud-sep"></div>
<div class="ud-item danger" onclick="logout()"><i class="fas fa-sign-out-alt"></i> Keluar</div>
</div>
</div>
<!-- Public mode buttons -->
<button class="top-btn public-only" onclick="showLockedPanel()"><i class="fas fa-list"></i> Panel</button>
<a href="#" onclick="openLoginModal(); return false;" class="top-btn primary public-only" id="topbar-login-btn"
style="text-decoration:none;display:none;">
<i class="fas fa-sign-in-alt"></i> Login
</a>
</div>
</div>
<div id="map-wrapper">
<div id="map"></div>
<div class="stats-strip" id="stats-strip"></div>
<button class="map-chip" onclick="toggleSatellite()"><i class="fas fa-globe"></i> <span id="sat-label">Mode
Satelit</span></button>
<aside class="side-panel" id="side-panel">
<div class="panel-head">
<h3><span>Data KK Miskin (<span id="panel-count">0</span>)</span> <button class="close-panel-btn"
onclick="togglePanel()"><i class="fas fa-times"></i></button></h3>
<p>Detail dan status penanganan KK miskin</p>
</div>
<div class="panel-list" id="panel-list"></div>
</aside>
<!-- Locked Panel (public mode) -->
<div class="locked-panel" id="locked-panel">
<!-- Gradient header -->
<div class="lp-header">
<div class="lp-icon-wrap"><i class="fas fa-shield-halved"></i></div>
<div class="lp-title">Area Terproteksi</div>
<div class="lp-header-sub">Login diperlukan untuk akses penuh</div>
</div>
<div class="lp-divider"></div>
<!-- Body -->
<div class="lp-body">
<p class="lp-desc">Daftar KK miskin dan detail informasi hanya dapat diakses oleh pengguna yang telah masuk ke
sistem.</p>
<ul class="lp-features">
<li>
<span class="feat-icon" style="background:#FFEDD5;color:#2563eb"><i class="fas fa-users"></i></span>
Daftar lengkap KK miskin
</li>
<li>
<span class="feat-icon" style="background:#d1fae5;color:#059669"><i class="fas fa-map-pin"></i></span>
Titik peta interaktif
</li>
<li>
<span class="feat-icon" style="background:#ede9fe;color:#7c3aed"><i class="fas fa-file-lines"></i></span>
Ekspor laporan data
</li>
<li>
<span class="feat-icon" style="background:#ffedd5;color:#ea580c"><i
class="fas fa-triangle-exclamation"></i></span>
Monitoring blank spot
</li>
</ul>
<a href="#" onclick="openLoginModal(); return false;" class="lp-btn">
<i class="fas fa-right-to-bracket"></i>&ensp;Masuk ke Sistem
</a>
<p class="lp-note"><i class="fas fa-circle-check"></i>&ensp;Statistik &amp; titik peta dapat dilihat tanpa
login.</p>
</div>
</div>
</div>
</div>
<div id="hint"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/proj4js/2.11.0/proj4.min.js"></script>
<script>
// Auth check
const IS_LOGGED_IN = sessionStorage.getItem('gis_logged_in') === 'true';
if (!IS_LOGGED_IN) {
document.body.classList.add('public-mode');
const banner = document.getElementById('public-banner');
if (banner) banner.classList.add('show');
const lockedPanel = document.getElementById('locked-panel');
if (lockedPanel) lockedPanel.classList.add('show');
const loginBtn = document.getElementById('topbar-login-btn');
if (loginBtn) loginBtn.style.display = 'flex';
} else {
const gisUser = JSON.parse(sessionStorage.getItem('gis_user') || '{}');
const nama = gisUser.nama_lengkap || gisUser.username || 'Admin';
const uname = gisUser.username || 'admin';
const role = gisUser.role || 'admin';
const roleLabel = role === 'walikota' ? 'Walikota Pontianak' : role === 'superadmin' ? 'Super Admin' : 'Administrator';
const initials = nama.split(' ').slice(0, 2).map(w => w[0]).join('').toUpperCase() || 'AD';
const el = id => document.getElementById(id);
if (el('topbar-avatar')) el('topbar-avatar').textContent = initials;
if (el('topbar-uname')) el('topbar-uname').textContent = uname;
if (el('dd-nama')) el('dd-nama').textContent = nama;
if (el('dd-role')) el('dd-role').textContent = roleLabel;
}
function logout() {
sessionStorage.removeItem('gis_logged_in');
sessionStorage.removeItem('gis_user');
window.location.href = 'index.html';
}
function toggleUserMenu() {
const menu = document.getElementById('user-menu');
if (menu) menu.classList.toggle('open');
}
function toggleAddMenu() {
const menu = document.getElementById('add-menu');
if (menu) menu.classList.toggle('open');
}
function closeAddMenu() {
const menu = document.getElementById('add-menu');
if (menu) menu.classList.remove('open');
}
document.addEventListener('click', function (e) {
const userMenu = document.getElementById('user-menu');
if (userMenu && !userMenu.contains(e.target)) userMenu.classList.remove('open');
const addMenu = document.getElementById('add-menu');
if (addMenu && !addMenu.contains(e.target)) addMenu.classList.remove('open');
});
function showLockedPanel() {
const lockedPanel = document.getElementById('locked-panel');
if (lockedPanel) lockedPanel.classList.add('show');
}
/* STATE */
const state = {
mode: null, spbu: [], jalan: [], parsil: [], kemiskinan: [], masjid: [],
featureRefs: { spbu: {}, jalan: {}, parsil: {}, kemiskinan: {}, masjid: {} },
kkLayerRefs: {}, blankRefs: {}, useSatellite: false, geometryEdit: null, activeCard: null,
spbuFilterVis: { open24: true, non24: true }, drawingLine: false, drawingPoly: false,
tempPoints: [], tempMarkers: [], tempPolyline: null, tempPolygon: null
};
/* MAP */
const map = L.map('map', { zoomControl: false, preferCanvas: true }).setView([-0.0263, 109.3425], 14);
L.control.zoom({ position: 'bottomright' }).addTo(map);
const streetLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors', maxZoom: 19
}).addTo(map);
const satelliteLayer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: 'Tiles &copy; Esri', maxZoom: 19
});
const layers = {
kemiskinan: L.layerGroup().addTo(map),
masjid: L.layerGroup().addTo(map),
blankspot: L.layerGroup().addTo(map),
// Layer opsional — OFF by default (tidak .addTo(map))
spbu24: L.layerGroup(),
spbuNon24: L.layerGroup(),
jalan: L.layerGroup(),
parsil: L.layerGroup()
};
const layersControl = L.control.layers(null, {
'<span class="lc-item"><span class="lc-dot lc-dot-kemiskinan"></span><span class="lc-text"><strong>Kemiskinan</strong><small>Data KK miskin</small></span></span>': layers.kemiskinan,
'<span class="lc-item"><span class="lc-dot lc-dot-masjid"></span><span class="lc-text"><strong>Rumah Ibadah</strong><small>Fasilitas ibadah</small></span></span>': layers.masjid,
'<span class="lc-item"><span class="lc-dot" style="background:#64748b"></span><span class="lc-text"><strong>Blank Spot</strong><small>Di luar radius rumah ibadah</small></span></span>': layers.blankspot,
'<span class="lc-item"><span class="lc-dot" style="background:#16a34a"></span><span class="lc-text"><strong>SPBU 24 Jam</strong><small>Buka 24 jam</small></span></span>': layers.spbu24,
'<span class="lc-item"><span class="lc-dot" style="background:#dc2626"></span><span class="lc-text"><strong>SPBU Non-24</strong><small>Tidak 24 jam</small></span></span>': layers.spbuNon24,
'<span class="lc-item"><span class="lc-dot" style="background:#2563eb"></span><span class="lc-text"><strong>Jalan</strong><small>Jaringan jalan</small></span></span>': layers.jalan,
'<span class="lc-item"><span class="lc-dot" style="background:#d97706"></span><span class="lc-text"><strong>Parsil / Lahan</strong><small>Data persil tanah</small></span></span>': layers.parsil,
}, { collapsed: false, position: 'topright' }).addTo(map);
decorateLayersControl();
function decorateLayersControl() {
const container = layersControl.getContainer();
if (!container) return;
const overlays = container.querySelector('.leaflet-control-layers-overlays');
if (!overlays) return;
const labels = Array.from(overlays.querySelectorAll('label'));
if (labels.length < 6) return;
overlays.innerHTML = '';
const addGroup = (title, items, color) => {
const head = document.createElement('div');
head.className = 'lc-group-title';
head.style.cursor = 'pointer';
head.style.display = 'flex';
head.style.justifyContent = 'space-between';
head.style.alignItems = 'center';
head.style.userSelect = 'none';
const titleSpan = document.createElement('span');
titleSpan.textContent = title;
if (color) titleSpan.style.color = color;
const iconSpan = document.createElement('i');
iconSpan.className = 'fa-solid fa-chevron-up';
iconSpan.style.transition = 'transform 0.3s ease';
iconSpan.style.fontSize = '12px';
if (color) iconSpan.style.color = color;
head.appendChild(titleSpan);
head.appendChild(iconSpan);
overlays.appendChild(head);
const content = document.createElement('div');
content.style.overflow = 'hidden';
content.style.transition = 'max-height 0.3s ease';
content.style.maxHeight = '1000px';
items.forEach(item => content.appendChild(item));
overlays.appendChild(content);
let isOpen = true;
head.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
isOpen = !isOpen;
if (isOpen) {
content.style.maxHeight = '1000px';
iconSpan.style.transform = 'rotate(0deg)';
} else {
content.style.maxHeight = '0px';
iconSpan.style.transform = 'rotate(180deg)';
}
});
};
addGroup('Tematik Utama', [labels[0], labels[1], labels[2]]);
addGroup('Opsional', [labels[3], labels[4], labels[5], labels[6]], '#64748b');
}
const statusStoreKey = 'gis-kemiskinan-status';
function getStatusMap() {
try { return JSON.parse(localStorage.getItem(statusStoreKey) || '{}'); }
catch (_) { return {}; }
}
function getKKStatus(id) {
// Cek dari state data terlebih dahulu (dari DB)
const d = state.kemiskinan.find(x => String(x.id) === String(id));
if (d && d.status_penanganan) return d.status_penanganan;
// Fallback ke localStorage
return getStatusMap()[String(id)] || 'Belum Ditangani';
}
function setKKStatus(id, value) {
// Update state
const d = state.kemiskinan.find(x => String(x.id) === String(id));
if (d) d.status_penanganan = value;
// Update localStorage
const mapStatus = getStatusMap();
mapStatus[String(id)] = value;
localStorage.setItem(statusStoreKey, JSON.stringify(mapStatus));
// Simpan ke database
fetch('api/update_status_kemiskinan.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: parseInt(id), status: value })
}).catch(() => console.warn('Gagal simpan status ke DB'));
renderDashboard();
refreshType('kemiskinan');
}
// ── TOAST NOTIFICATION ──
function showToast(msg, type) {
let toast = document.getElementById('gis-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'gis-toast';
toast.style.cssText = 'position:fixed;bottom:28px;left:50%;transform:translateX(-50%);padding:10px 20px;border-radius:999px;font:700 13px Inter,sans-serif;z-index:99999;pointer-events:none;transition:opacity 0.3s;opacity:0;white-space:nowrap;box-shadow:0 4px 20px rgba(0,0,0,.15)';
document.body.appendChild(toast);
}
const colors = {
success: { bg: '#059669', color: '#fff' },
info: { bg: '#2563eb', color: '#fff' },
warning: { bg: '#f59e0b', color: '#fff' }
};
const c = colors[type] || colors.success;
toast.style.background = c.bg;
toast.style.color = c.color;
toast.textContent = msg;
toast.style.opacity = '1';
clearTimeout(toast._t);
toast._t = setTimeout(() => { toast.style.opacity = '0'; }, 2200);
}
function setKKStatusQuick(id, value) {
// Update state
const d2 = state.kemiskinan.find(x => String(x.id) === String(id));
if (d2) d2.status_penanganan = value;
// Update localStorage
const mapStatus = getStatusMap();
mapStatus[String(id)] = value;
localStorage.setItem(statusStoreKey, JSON.stringify(mapStatus));
// Simpan ke database
fetch('api/update_status_kemiskinan.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: parseInt(id), status: value })
}).then(r => r.json()).then(res => {
if (res.status !== 'success') {
console.warn('Gagal simpan status:', res.message);
showToast('⚠️ Gagal simpan ke database', 'warning');
} else {
const label = value === 'Diproses' ? '🔄 Status: Diproses' : value === 'Sudah Ditangani' ? '✅ Status: Sudah Ditangani' : '↩️ Status: Belum Ditangani';
showToast(label, value === 'Diproses' ? 'info' : value === 'Sudah Ditangani' ? 'success' : 'warning');
}
}).catch(() => {
console.warn('Gagal simpan status ke DB');
showToast('⚠️ Tidak terhubung ke database', 'warning');
});
renderDashboard();
const d = state.kemiskinan.find(x => String(x.id) === String(id));
if (!d) return;
const ref = state.featureRefs['kemiskinan'][String(id)];
if (!ref) return;
const status = value;
const done = status === 'Sudah Ditangani';
const blank = isBlankSpot(d);
const markerColor = blank ? '#dc2626' : '#16a34a';
const coverIcon = blank ? '<i class="fas fa-circle-xmark" style="color:#dc2626"></i>' : '<i class="fas fa-circle-check" style="color:#16a34a"></i>';
const nearest = nearestRumahIbadah(d);
const namaRI = nearest ? nearest.nama_masjid : '-';
const jarakRI = nearest ? Math.round(nearest.distance) + ' m' : '-';
const cat = getCategory(d);
const gisUser = JSON.parse(sessionStorage.getItem('gis_user') || '{}');
const role = gisUser.role || '';
const canManage = role !== 'walikota';
const actionsHtml = canManage ? `<div class="act-row kk-popup-actions">
<button class="pbtn pbtn-y" onclick="openEditKemiskinan(${d.id})">Edit</button>
<button class="pbtn pbtn-b" onclick="setKKStatusQuick(${d.id},'Diproses')">Proses</button>
<button class="pbtn ${done ? 'pbtn-b' : 'pbtn-g'}" onclick="setKKStatusQuick(${d.id},&apos;${done ? 'Belum Ditangani' : 'Sudah Ditangani'}&apos;)">
${done ? 'Batal' : 'Selesai'}
</button>
<button class="pbtn" style="background:#ede9fe;color:#7c3aed" title="Data Warga" onclick="openWargaModal(${d.id},'${d.nama_kk.replace(/'/g, '&#39;')}')"><i class="fas fa-users-cog"></i></button>
<button class="pbtn" style="background:#FFEDD5;color:#1e3a8a" title="Histori Bantuan" onclick="openHistoriBantuan(${d.id},'${d.nama_kk.replace(/'/g, '&#39;')}')"><i class="fas fa-history"></i></button>
<button class="pbtn pbtn-r" onclick="deleteFeature('kemiskinan',${d.id})">Hapus</button>
</div>` : `<div class="act-row kk-popup-actions" style="grid-template-columns:1fr 1fr">
<button class="pbtn" style="background:#ede9fe;color:#7c3aed;padding:8px" onclick="openWargaModal(${d.id},'${d.nama_kk.replace(/'/g, '&#39;')}')"><i class="fas fa-users-cog"></i> Data Warga</button>
<button class="pbtn" style="background:#FFEDD5;color:#1e3a8a;padding:8px" onclick="openHistoriBantuan(${d.id},'${d.nama_kk.replace(/'/g, '&#39;')}')"><i class="fas fa-history"></i> Histori Bantuan</button>
</div>`;
const catBadgeClass = cat.key === 'extreme' ? 'extreme' : cat.key === 'poor' ? 'poor' : 'vulnerable';
const statusColor = status === 'Sudah Ditangani' ? '#059669' : status === 'Diproses' ? '#2563eb' : '#64748b';
const privatePopup = `<div class="pi">
<div class="pi-header">
<div class="pi-header-icon"><i class="fas fa-users"></i></div>
<div class="pi-header-info">
<div class="pi-header-name">${d.nama_kk}</div>
<div class="pi-header-sub"><span class="pi-badge ${catBadgeClass}" style="font-size:10px;padding:2px 7px">${cat.label}</span></div>
</div>
<button class="pi-close" onclick="map.closePopup()"><i class="fas fa-times"></i></button>
</div>
<div class="pi-body">
<div class="pi-row"><span class="pi-k">Jumlah Jiwa</span><span class="pi-v">${d.jumlah_kk} orang</span></div>
<div class="pi-row"><span class="pi-k">Alamat</span><span class="pi-v" style="max-width:140px">${d.alamat}</span></div>
<div class="pi-row"><span class="pi-k">Status</span><span class="pi-v" style="color:${statusColor}">${status}</span></div>
<div class="pi-row"><span class="pi-k">Rumah Ibadah</span><span class="pi-v" style="max-width:130px">${namaRI}</span></div>
<div class="pi-row"><span class="pi-k">Jarak</span><span class="pi-v">${jarakRI}</span></div>
<div class="pi-row"><span class="pi-k">Cakupan</span><span class="pi-v">${coverIcon} ${blank ? 'Blank Spot' : 'Terjangkau'}</span></div>
</div>
${actionsHtml}
</div>`;
if (ref.setPopupContent) {
ref.setPopupContent(privatePopup);
}
}
function getCategory(d) {
const jumlah = Number(d.jumlah_kk) || 1;
if (jumlah >= 5) return { key: 'extreme', label: 'Miskin Ekstrem', color: '#dc2626', badge: 'badge-extreme' };
if (jumlah >= 3) return { key: 'poor', label: 'Miskin', color: '#ea580c', badge: 'badge-poor' };
return { key: 'vulnerable', label: 'Rentan Miskin', color: '#16a34a', badge: 'badge-vulnerable' };
}
function nearestRumahIbadah(d) {
if (!state.masjid.length) return null;
const here = L.latLng(d.lat, d.lng);
return state.masjid
.map(m => ({ ...m, distance: here.distanceTo(L.latLng(m.lat, m.lng)) }))
.sort((a, b) => a.distance - b.distance)[0];
}
function isBlankSpot(d) {
const nearest = nearestRumahIbadah(d);
if (!nearest) return true;
return nearest.distance > (Number(nearest.radius) || 500);
}
function rumahIbadahStats(d) {
const center = L.latLng(d.lat, d.lng);
const radius = Number(d.radius) || 500;
const covered = state.kemiskinan
.map(k => ({ ...k, distance: center.distanceTo(L.latLng(k.lat, k.lng)) }))
.filter(k => k.distance <= radius)
.sort((a, b) => a.distance - b.distance);
return {
total: covered.length,
extreme: covered.filter(k => getCategory(k).key === 'extreme').length,
process: covered.filter(k => getKKStatus(k.id) === 'Diproses').length,
handled: covered.filter(k => getKKStatus(k.id) === 'Sudah Ditangani').length,
sample: covered.slice(0, 3)
};
}
function vulnerabilityScore(d) {
const nearest = nearestRumahIbadah(d);
const distanceRisk = nearest && nearest.distance > nearest.radius ? 16 : 0;
return Math.min(100, Math.round(55 + ((Number(d.jumlah_kk) || 1) * 7) + distanceRisk));
}
function filteredKemiskinan() {
const q = (document.getElementById('map-search')?.value || '').trim().toLowerCase();
return state.kemiskinan.filter(d => {
const text = `${d.nama_kk} ${d.alamat}`.toLowerCase();
return !q || text.includes(q);
});
}
function renderBlankSpots() {
layers.blankspot.clearLayers();
state.blankRefs = {};
state.kemiskinan.filter(isBlankSpot).forEach(d => {
const marker = L.circleMarker([d.lat, d.lng], {
radius: 7,
color: '#64748b',
fillColor: '#64748b',
fillOpacity: 0.9,
weight: 2
}).bindPopup(IS_LOGGED_IN
? `<div class="pi"><h4><i class="fas fa-triangle-exclamation" style="color:#64748b"></i>Blank Spot</h4><div class="pi-row"><span class="pi-k">KK</span><span class="pi-v">${d.nama_kk}</span></div><div class="pi-row"><span class="pi-k">Status</span><span class="pi-v">Di luar radius rumah ibadah</span></div></div>`
: `<div class="pi"><h4><i class="fas fa-triangle-exclamation" style="color:#64748b"></i>Blank Spot</h4><div class="pi-row"><span class="pi-k">Status</span><span class="pi-v">Area prioritas</span></div><div class="pi-row"><span class="pi-k">Detail</span><span class="pi-v">Login diperlukan</span></div></div>`,
{ autoPanPaddingTopLeft: [0, 80], autoPanPaddingBottomRight: [0, 20] });
marker.addTo(layers.blankspot);
state.blankRefs[String(d.id)] = marker;
});
}
function renderDashboard() {
renderBlankSpots();
const total = state.kemiskinan.length;
const extreme = state.kemiskinan.filter(d => getCategory(d).key === 'extreme').length;
const handled = state.kemiskinan.filter(d => getKKStatus(d.id) === 'Sudah Ditangani').length;
const blank = state.kemiskinan.filter(isBlankSpot).length;
const stats = [
['fa-users', total, 'Total KK', '#dc2626'],
['fa-fire', extreme, 'Ekstrem', '#ea580c'],
['fa-check', handled, 'Tertangani', '#16a34a'],
['fa-place-of-worship', state.masjid.length, 'Rumah Ibadah', '#2563eb'],
['fa-location-dot', blank, 'Blank Spot', '#64748b']
];
const statsEl = document.getElementById('stats-strip');
if (statsEl) {
statsEl.innerHTML = stats.map(([icon, num, label, color]) => `<div class="stat-pill"><i class="fas ${icon}" style="background:${color}"></i><span><strong>${num}</strong><small>${label}</small></span></div>`).join('');
}
const topBlank = document.getElementById('top-blank-count');
if (topBlank) topBlank.textContent = blank;
const panel = document.getElementById('panel-list');
const count = document.getElementById('panel-count');
if (!panel || !count) return;
const data = filteredKemiskinan();
count.textContent = data.length;
panel.innerHTML = data.map(d => {
const cat = getCategory(d);
const status = getKKStatus(d.id);
const statusClass = status === 'Sudah Ditangani' ? 'badge-handled' : 'badge-pending';
const nearest = nearestRumahIbadah(d);
const worship = nearest ? nearest.nama_masjid : '-';
const blankText = isBlankSpot(d) ? '<div class="kk-row blank-warning">BLANK SPOT</div>' : '';
return `<div class="kk-card">
<div class="kk-top"><div class="kk-avatar"><i class="fas fa-user"></i></div><div><div class="kk-name">${d.nama_kk}</div><div class="kk-sub">${d.alamat} · ${d.jumlah_kk} jiwa</div></div></div>
<div class="kk-row"><span>Kategori</span><strong><span class="kk-badge ${cat.badge}">${cat.label}</span></strong></div>
<div class="kk-row"><span>Skor Kerentanan</span><strong>${vulnerabilityScore(d)}/100</strong></div>
<div class="kk-row"><span>Status</span><strong><span class="kk-badge ${statusClass}">${status}</span></strong></div>
<div class="kk-row"><span>Rumah Ibadah</span><strong>${worship}</strong></div>
${blankText}
<div class="kk-actions">
<button class="mini-btn" onclick="focusFeature('kemiskinan',${d.id})"><i class="fas fa-eye"></i></button>
<select onchange="setKKStatus(${d.id},this.value)">
<option ${status === 'Belum Ditangani' ? 'selected' : ''}>Belum Ditangani</option>
<option ${status === 'Diproses' ? 'selected' : ''}>Diproses</option>
<option ${status === 'Sudah Ditangani' ? 'selected' : ''}>Sudah Ditangani</option>
</select>
</div>
</div>`;
}).join('');
}
function applyLayerFilters() {
refreshType('kemiskinan');
if (document.getElementById('flt-worship')?.checked) map.addLayer(layers.masjid); else map.removeLayer(layers.masjid);
if (document.getElementById('flt-blank')?.checked) map.addLayer(layers.blankspot); else map.removeLayer(layers.blankspot);
}
function runSearch() {
const q = (document.getElementById('map-search')?.value || '').trim().toLowerCase();
if (!q) return;
// 1. Cari Masjid
const fMasjid = state.masjid.find(d => (d.nama_masjid || '').toLowerCase().includes(q));
if (fMasjid) return focusFeature('masjid', fMasjid.id);
// 2. Cari Data Warga (Kemiskinan)
const fWarga = state.kemiskinan.find(d => (`${d.nama_kk} ${d.alamat}`).toLowerCase().includes(q));
if (fWarga) return focusFeature('kemiskinan', fWarga.id);
// 3. Cari SPBU
const fSpbu = state.spbu.find(d => (d.nama || '').toLowerCase().includes(q));
if (fSpbu) return focusFeature('spbu', fSpbu.id);
// 4. Cari Jalan
const fJalan = state.jalan.find(d => (d.nama || '').toLowerCase().includes(q));
if (fJalan) return focusFeature('jalan', fJalan.id);
// 5. Cari Parsil
const fParsil = state.parsil.find(d => (`${d.nama} ${d.pemilik}`).toLowerCase().includes(q));
if (fParsil) return focusFeature('parsil', fParsil.id);
// Jika tidak ketemu
alert('Data "' + q + '" tidak ditemukan.');
}
function focusBlankSpot() {
const first = state.kemiskinan.find(isBlankSpot);
if (first) focusFeature('kemiskinan', first.id);
}
function togglePanel() {
const panel = document.getElementById('side-panel');
if (panel) {
if (panel.classList.contains('open')) {
panel.classList.remove('open');
setTimeout(() => panel.style.pointerEvents = 'none', 400);
} else {
panel.style.pointerEvents = 'auto';
panel.classList.add('open');
}
}
}
function toggleSatellite() {
state.useSatellite = !state.useSatellite;
if (state.useSatellite) {
map.removeLayer(streetLayer);
satelliteLayer.addTo(map);
} else {
map.removeLayer(satelliteLayer);
streetLayer.addTo(map);
}
const label = document.getElementById('sat-label');
if (label) label.textContent = state.useSatellite ? 'Jalan' : 'Satelit';
}
function downloadReport() {
const rows = [
['Nama KK', 'Jumlah Jiwa', 'Alamat', 'Kategori', 'Skor Kerentanan', 'Status', 'Rumah Ibadah Terdekat', 'Jarak Meter', 'Blank Spot']
];
state.kemiskinan.forEach(d => {
const nearest = nearestRumahIbadah(d);
rows.push([
d.nama_kk,
d.jumlah_kk,
d.alamat,
getCategory(d).label,
vulnerabilityScore(d),
getKKStatus(d.id),
nearest ? nearest.nama_masjid : '',
nearest ? Math.round(nearest.distance) : '',
isBlankSpot(d) ? 'Ya' : 'Tidak'
]);
});
const csv = rows.map(row => row.map(v => `"${String(v).replace(/"/g, '""')}"`).join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'laporan-kemiskinan-rumah-ibadah.csv';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
const jColor = { 'Nasional': '#ef4444', 'Provinsi': '#f59e0b', 'Kabupaten': '#3b82f6' };
const pColor = { 'SHM': '#3b82f6', 'HGB': '#8b5cf6', 'HGU': '#f59e0b', 'HP': '#10b981' };
const kecColorMap = {
'Pontianak Timur': '#ef6b73',
'Pontianak Selatan': '#f0c86a',
'Pontianak Utara': '#e8c45d',
'Pontianak Barat': '#8f6cc6',
'Pontianak Kota': '#83be86',
'Pontianak Tenggara': '#eec8d8'
};
const utm49s = '+proj=utm +zone=49 +south +datum=WGS84 +units=m +no_defs';
const wgs84 = 'WGS84';
function utm49sToLngLat(easting, northing) {
// Fallback konversi UTM 49S -> WGS84 ketika proj4 tidak tersedia.
const a = 6378137.0;
const f = 1 / 298.257223563;
const e2 = f * (2 - f);
const ep2 = e2 / (1 - e2);
const k0 = 0.9996;
const falseEasting = 500000;
const falseNorthing = 10000000;
const zone = 49;
const x = Number(easting) - falseEasting;
const y = Number(northing) - falseNorthing;
const m = y / k0;
const mu = m / (a * (1 - e2 / 4 - (3 * e2 * e2) / 64 - (5 * e2 * e2 * e2) / 256));
const e1 = (1 - Math.sqrt(1 - e2)) / (1 + Math.sqrt(1 - e2));
const j1 = (3 * e1 / 2) - (27 * Math.pow(e1, 3) / 32);
const j2 = (21 * e1 * e1 / 16) - (55 * Math.pow(e1, 4) / 32);
const j3 = (151 * Math.pow(e1, 3) / 96);
const j4 = (1097 * Math.pow(e1, 4) / 512);
const fp = mu + j1 * Math.sin(2 * mu) + j2 * Math.sin(4 * mu) + j3 * Math.sin(6 * mu) + j4 * Math.sin(8 * mu);
const sinFp = Math.sin(fp);
const cosFp = Math.cos(fp);
const tanFp = Math.tan(fp);
const c1 = ep2 * cosFp * cosFp;
const t1 = tanFp * tanFp;
const n1 = a / Math.sqrt(1 - e2 * sinFp * sinFp);
const r1 = (a * (1 - e2)) / Math.pow(1 - e2 * sinFp * sinFp, 1.5);
const d = x / (n1 * k0);
const lat = fp - (n1 * tanFp / r1) * (
(d * d) / 2 -
((5 + 3 * t1 + 10 * c1 - 4 * c1 * c1 - 9 * ep2) * Math.pow(d, 4)) / 24 +
((61 + 90 * t1 + 298 * c1 + 45 * t1 * t1 - 252 * ep2 - 3 * c1 * c1) * Math.pow(d, 6)) / 720
);
const lon0 = ((zone - 1) * 6 - 180 + 3) * Math.PI / 180;
const lon = lon0 + (
d -
((1 + 2 * t1 + c1) * Math.pow(d, 3)) / 6 +
((5 - 2 * c1 + 28 * t1 - 3 * c1 * c1 + 8 * ep2 + 24 * t1 * t1) * Math.pow(d, 5)) / 120
) / cosFp;
return [lon * 180 / Math.PI, lat * 180 / Math.PI];
}
function isProjectedCoordinate(coord) {
if (!Array.isArray(coord) || coord.length < 2) return false;
return Math.abs(coord[0]) > 180 || Math.abs(coord[1]) > 90;
}
function convertProjectedPointToLngLat(coord) {
if (!Array.isArray(coord) || coord.length < 2) return coord;
if (isProjectedCoordinate(coord)) {
if (typeof proj4 === 'function') {
try {
const converted = proj4(utm49s, wgs84, [Number(coord[0]), Number(coord[1])]);
if (Array.isArray(converted) && Number.isFinite(converted[0]) && Number.isFinite(converted[1])) {
return [converted[0], converted[1]];
}
} catch (_) {
// fallback ke konversi manual
}
}
return utm49sToLngLat(coord[0], coord[1]);
}
return [Number(coord[0]), Number(coord[1])];
}
function reprojectGeoJSONCoordinates(coords) {
if (!Array.isArray(coords) || coords.length === 0) return coords;
if (typeof coords[0] === 'number') return convertProjectedPointToLngLat(coords);
return coords.map(reprojectGeoJSONCoordinates);
}
function normalizeAdminKecamatanGeoJSON(geojson) {
return {
...geojson,
features: (geojson.features || []).map(feature => ({
...feature,
geometry: {
...feature.geometry,
coordinates: reprojectGeoJSONCoordinates(feature.geometry.coordinates)
}
}))
};
}
function getKecamatanColor(name) {
return kecColorMap[name] || '#d9b26f';
}
function loadAdminKecamatan() {
fetch('Admin_Kecamatan.json')
.then(res => res.json())
.then(raw => {
const geojson = normalizeAdminKecamatanGeoJSON(raw);
const kecLayer = L.geoJSON(geojson, {
interactive: false,
style: feature => {
const name = (feature.properties && feature.properties.Ket) || 'Kecamatan';
const fill = getKecamatanColor(name);
return {
color: '#232831',
weight: 3,
fillColor: fill,
fillOpacity: 0.4
};
}
});
kecLayer.addTo(layers.kecamatan);
kecLayer.bringToBack();
const bounds = kecLayer.getBounds();
if (bounds && bounds.isValid()) {
map.fitBounds(bounds, { padding: [24, 24], maxZoom: 13 });
}
console.log('Layer kecamatan berhasil dimuat:', kecLayer.getLayers().length, 'fitur');
})
.catch(err => console.log('Admin Kecamatan Error:', err));
}
/* ── MODE ── */
function setMode(m) {
cancelDrawing();
state.mode = state.mode === m ? null : m;
['kemiskinan', 'masjid', 'spbu', 'jalan', 'parsil'].forEach(x => {
const el = document.getElementById('btn-' + x);
if (el) el.className = 'mbtn';
});
const hint = document.getElementById('hint');
hint.classList.remove('show');
if (state.mode) {
const msgs = {
kemiskinan: 'Klik peta untuk menambah data Kemiskinan',
masjid: 'Klik peta untuk menambah titik Rumah Ibadah',
spbu: 'Klik peta untuk menambah titik SPBU',
jalan: 'Klik untuk gambar jalan — klik ganda untuk selesai',
parsil: 'Klik untuk gambar parsil — klik titik awal atau dbl-klik selesai',
line: 'Klik untuk gambar jalan — klik ganda untuk selesai',
poly: 'Klik untuk gambar parsil — klik titik awal atau dbl-klik selesai',
};
hint.textContent = msgs[m] || 'Klik peta';
hint.classList.add('show');
map.getContainer().style.cursor = 'crosshair';
} else {
map.getContainer().style.cursor = '';
}
}
/* ── MAP EVENTS ── */
map.on('click', function (e) {
if (window._kkPickingLocation) {
finishKemiskinanLocationPick(e);
return;
}
if (!state.mode) return;
if (state.mode === 'kemiskinan') handleKemiskinanClick(e);
else if (state.mode === 'masjid') handleMasjidClick(e);
else if (state.mode === 'spbu') handlePointClick(e);
else if (state.mode === 'jalan') handleLineClick(e);
else if (state.mode === 'parsil') handlePolyClick(e);
});
map.on('dblclick', function (e) {
if ((state.mode === 'jalan' || state.mode === 'line') && state.drawingLine) finishLine(e);
if ((state.mode === 'parsil' || state.mode === 'poly') && state.drawingPoly) finishPoly(e);
if (['jalan','parsil','line','poly'].includes(state.mode)) L.DomEvent.stopPropagation(e);
});
/* ── MODERN MARKER ICON ── */
function createModernMarker(color, iconClass, label) {
const html = `
<div class="modern-marker" style="--marker-color: ${color}">
<div class="pulse"></div>
<div class="pin-body">
<div class="icon-container">
<i class="${iconClass}"></i>
</div>
</div>
<div class="pin-label">${label}</div>
</div>
`;
return L.divIcon({
html: html,
className: '',
iconSize: [40, 40],
iconAnchor: [20, 38],
popupAnchor: [0, -42]
});
}
/* ── POINT SPBU ── */
window.updateTempMarkerIcon = function() {
if (!window._tmpM) return;
const s = document.getElementById('p-status') ? document.getElementById('p-status').value : 'Ya';
const color = s === 'Ya' ? '#10b981' : '#ef4444';
window._tmpM.setIcon(createModernMarker(color, 'fas fa-gas-pump', 'SPBU Baru'));
};
function handlePointClick(e) {
const icon = createModernMarker('#10b981', 'fas fa-gas-pump', 'SPBU Baru');
const tmpM = L.marker(e.latlng, { icon, draggable: true }).addTo(map);
const upd = ll => {
const a = document.getElementById('p-lat'), b = document.getElementById('p-lng');
if (a) a.value = ll.lat.toFixed(6);
if (b) b.value = ll.lng.toFixed(6);
};
tmpM.on('dragend', ev => upd(ev.target.getLatLng()));
const form = `<div class="pf">
<h4><i class="fas fa-gas-pump" style="color:var(--green)"></i> Tambah SPBU</h4>
<label>Nama SPBU</label>
<input id="p-nama" placeholder="Pertamina Ahmad Yani">
<label>Nomor SPBU</label>
<input id="p-no" placeholder="64.xxx.xx">
<label>Status Operasional</label>
<select id="p-status" onchange="window.updateTempMarkerIcon()">
<option value="Ya">Buka 24 Jam</option>
<option value="Tidak">Tidak Buka 24 Jam</option>
</select>
<div class="coord-row">
<input id="p-lat" value="${e.latlng.lat.toFixed(6)}" readonly>
<input id="p-lng" value="${e.latlng.lng.toFixed(6)}" readonly>
</div>
<div class="hint-txt">* Geser marker untuk menyesuaikan posisi</div>
<button class="pbtn pbtn-g" onclick="savePoint()">Simpan SPBU</button>
</div>`;
tmpM.bindPopup(form, { maxWidth: 280, autoPanPaddingTopLeft: [0, 80], autoPanPaddingBottomRight: [0, 20] }).openPopup();
tmpM.on('popupclose', () => { if (map.hasLayer(tmpM)) map.removeLayer(tmpM); });
window._tmpM = tmpM;
}
function savePoint() {
const nama = document.getElementById('p-nama').value.trim();
const no = document.getElementById('p-no').value.trim();
const status = document.getElementById('p-status').value;
const lat = parseFloat(document.getElementById('p-lat').value);
const lng = parseFloat(document.getElementById('p-lng').value);
if (!nama || !no) {
alert('Lengkapi nama dan nomor SPBU!');
return;
}
fetch("api/save_data.php", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
nama: nama,
no: no,
status: status,
lat: lat,
lng: lng
})
})
.then(res => res.json())
.then(response => {
if (response.status == "success") {
const d = {
id: response.id || Date.now(),
nama,
no,
status,
lat,
lng
};
state.spbu.push(d);
renderSPBU(d);
updateCount('spbu', state.spbu.length);
addCard(
'spbu',
d.id,
nama,
'No: ' + no,
status === 'Ya' ? 'Buka 24 Jam' : 'Tidak Buka 24 Jam',
status === 'Ya' ? '#1a6b3c' : '#b93328'
);
if (window._tmpM) {
map.closePopup();
map.removeLayer(window._tmpM);
window._tmpM = null;
}
setMode(null);
} else {
alert("Gagal simpan database");
}
})
.catch(err => {
console.log(err);
alert("Terjadi error");
});
}
function renderSPBU(d) {
const color = d.status === 'Ya' ? '#10b981' : '#ef4444';
const icon = createModernMarker(color, 'fas fa-gas-pump', d.nama);
const m = L.marker([d.lat, d.lng], { icon, draggable: true });
m.bindPopup(`<div class="pi">
<h4><i class="fas fa-gas-pump" style="color:${color}"></i>${d.nama}</h4>
<div class="pi-row"><span class="pi-k">No. SPBU</span><span class="pi-v">${d.no}</span></div>
<div class="pi-row"><span class="pi-k">Status</span><span class="pi-v" style="color:${color}">${d.status === 'Ya' ? '✓ Buka 24 Jam' : '✗ Tidak Buka 24 Jam'}</span></div>
<div class="pi-row"><span class="pi-k">Koordinat</span><span class="pi-v" style="font-size:11px;font-family:monospace">${d.lat.toFixed(5)}, ${d.lng.toFixed(5)}</span></div>
<div class="act-row">
<button class="pbtn pbtn-y" onclick="openEditSpbu(${d.id})">Edit</button>
<button class="pbtn pbtn-r" onclick="deleteFeature('spbu',${d.id})">Hapus</button>
</div>
</div>`, { autoPanPaddingTopLeft: [0, 80], autoPanPaddingBottomRight: [0, 20] });
m.addTo(d.status === 'Ya' ? layers.spbu24 : layers.spbuNon24);
state.featureRefs.spbu[String(d.id)] = m;
}
/* ── JALAN ── */
function handleLineClick(e) {
if (!state.drawingLine) state.drawingLine = true;
state.tempPoints.push(e.latlng);
const vm = L.circleMarker(e.latlng, { radius: 4, color: '#e74c3c', fillColor: '#e74c3c', fillOpacity: 1, weight: 2 }).addTo(map);
state.tempMarkers.push(vm);
if (state.tempPolyline) map.removeLayer(state.tempPolyline);
state.tempPolyline = L.polyline(state.tempPoints, { color: '#e74c3c', weight: 3, dashArray: '7,5', opacity: 0.8 }).addTo(map);
document.getElementById('hint').textContent = `ðŸ›£ï¸ ${state.tempPoints.length} titik — klik ganda untuk selesai`;
}
function finishLine(e) {
if (state.tempPoints.length < 2) { alert('Minimal 2 titik!'); return; }
const pts = [...state.tempPoints];
const len = calcLen(pts);
clearTemp();
L.popup({ closeOnClick: false, keepInView: true, autoPanPaddingTopLeft: [0, 80], autoPanPaddingBottomRight: [0, 20] })
.setLatLng(pts[Math.floor(pts.length / 2)])
.setContent(`<div class="pf">
<h4><i class="fas fa-road"></i> Tambah Jalan</h4>
<label>Nama Jalan</label>
<input id="j-nama" placeholder="Jl. Ahmad Yani">
<label>Status Jalan</label>
<select id="j-status">
<option value="Nasional">Jalan Nasional</option>
<option value="Provinsi">Jalan Provinsi</option>
<option value="Kabupaten" selected>Jalan Kabupaten</option>
</select>
<div style="display:flex;align-items:center;gap:10px;margin:10px 16px 4px;padding:9px 12px;background:#eff6ff;border-radius:9px;border:1px solid #dbeafe">
<i class="fas fa-ruler-horizontal" style="color:#2563eb;font-size:13px"></i>
<span style="font-size:11px;color:#64748b;font-weight:600">Panjang terukur:</span>
<span style="font-size:14px;font-weight:800;color:#1d4ed8;margin-left:auto">${fmtLen(len)}</span>
</div>
<button class="pbtn pbtn-b" onclick="saveLine(${JSON.stringify(pts).replace(/"/g, "'")},${len})">Simpan Jalan</button>
</div>`).addTo(map);
}
function saveLine(pts, len) {
const nama = document.getElementById('j-nama').value.trim();
const status = document.getElementById('j-status').value;
if (!nama) {
alert('Isi nama jalan!');
return;
}
fetch("api/save_jalan.php", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
nama: nama,
status: status,
panjang: len,
koordinat: pts
})
})
.then(res => res.json())
.then(response => {
if (response.status == "success") {
const d = {
id: response.id || Date.now(),
nama,
status,
pts,
len
};
state.jalan.push(d);
renderJalan(d);
updateCount('jalan', state.jalan.length);
addCard(
'jalan',
d.id,
nama,
status + ' · ' + fmtLen(len),
status,
jColor[status]
);
map.closePopup();
setMode(null);
} else {
console.log(response);
alert("Gagal simpan jalan");
}
})
.catch(err => {
console.log(err);
alert("Error simpan jalan");
});
}
function renderJalan(d) {
const pts = normalizeLinePoints(d.pts);
if (pts.length < 2) {
console.log('Format koordinat jalan tidak valid:', d.pts);
return;
}
const c = jColor[d.status] || '#ef4444';
const w =
d.status === 'Nasional' ? 6 :
d.status === 'Provinsi' ? 5 : 4;
const line = L.polyline(pts, {
color: c,
weight: w,
opacity: 0.95,
lineCap: 'round',
lineJoin: 'round',
className: 'path-glow'
})
.bindPopup(`
<div class="pi">
<h4><i class="fas fa-road" style="color:${c}"></i>${d.nama}</h4>
<div class="pi-row">
<span class="pi-k">Status</span>
<span class="pi-v">${d.status}</span>
</div>
<div class="pi-row">
<span class="pi-k">Panjang</span>
<span class="pi-v">${fmtLen(d.len)}</span>
</div>
<div class="act-row">
<button class="pbtn pbtn-y" onclick="openEditJalan(${d.id})">Edit</button>
<button class="pbtn pbtn-r" onclick="deleteFeature('jalan',${d.id})">Hapus</button>
</div>
</div>
`, { autoPanPaddingTopLeft: [0, 80], autoPanPaddingBottomRight: [0, 20] })
.addTo(layers.jalan);
state.featureRefs.jalan[String(d.id)] = line;
}
function normalizeLinePoints(raw) {
let pts = raw;
if (typeof pts === 'string') {
try {
pts = JSON.parse(pts);
} catch (_) {
return [];
}
}
// Kompatibilitas data lama: koordinat sempat tersimpan sebagai JSON string di dalam JSON string.
if (typeof pts === 'string') {
try {
pts = JSON.parse(pts);
} catch (_) {
return [];
}
}
if (!Array.isArray(pts) || pts.length === 0) return [];
if (Array.isArray(pts[0])) {
return pts
.map(p => [parseFloat(p[0]), parseFloat(p[1])])
.filter(p => Number.isFinite(p[0]) && Number.isFinite(p[1]));
}
if (pts[0] && typeof pts[0] === 'object' && pts[0].lat !== undefined) {
return pts
.map(p => [parseFloat(p.lat), parseFloat(p.lng)])
.filter(p => Number.isFinite(p[0]) && Number.isFinite(p[1]));
}
return [];
}
function normalizePolyPoints(raw) {
let pts = raw;
if (typeof pts === 'string') {
try {
pts = JSON.parse(pts);
} catch (_) {
return [];
}
}
if (typeof pts === 'string') {
try {
pts = JSON.parse(pts);
} catch (_) {
return [];
}
}
if (!Array.isArray(pts) || pts.length === 0) return [];
if (Array.isArray(pts[0])) {
return pts
.map(p => [parseFloat(p[0]), parseFloat(p[1])])
.filter(p => Number.isFinite(p[0]) && Number.isFinite(p[1]));
}
if (pts[0] && typeof pts[0] === 'object' && pts[0].lat !== undefined) {
return pts
.map(p => [parseFloat(p.lat), parseFloat(p.lng)])
.filter(p => Number.isFinite(p[0]) && Number.isFinite(p[1]));
}
return [];
}
/* ── PARSIL ── */
function handlePolyClick(e) {
if (!state.drawingPoly) state.drawingPoly = true;
if (state.tempPoints.length >= 3) {
const dist = map.distance(e.latlng, state.tempPoints[0]);
if (dist < 15) { finishPoly(null); return; }
}
state.tempPoints.push(e.latlng);
const vm = L.circleMarker(e.latlng, { radius: 4, color: '#1a5276', fillColor: '#1a5276', fillOpacity: 1, weight: 2 }).addTo(map);
state.tempMarkers.push(vm);
if (state.tempPolygon) map.removeLayer(state.tempPolygon);
if (state.tempPolyline) map.removeLayer(state.tempPolyline);
if (state.tempPoints.length >= 2)
state.tempPolyline = L.polyline([...state.tempPoints, state.tempPoints[0]],
{ color: '#1a5276', weight: 2, dashArray: '5,4', opacity: 0.7 }).addTo(map);
if (state.tempPoints.length >= 3)
state.tempPolygon = L.polygon(state.tempPoints,
{ color: '#1a5276', fillColor: '#1a5276', fillOpacity: 0.1, weight: 2, dashArray: '5,4' }).addTo(map);
document.getElementById('hint').textContent =
`🗺️ ${state.tempPoints.length} titik — klik titik awal atau dbl-klik selesai`;
}
function finishPoly(e) {
if (state.tempPoints.length < 3) { alert('Minimal 3 titik!'); return; }
const pts = [...state.tempPoints];
const area = calcArea(pts);
clearTemp();
const center = L.polygon(pts).getBounds().getCenter();
L.popup({ closeOnClick: false, keepInView: true })
.setLatLng(center)
.setContent(`<div class="pf">
<h4><i class="fas fa-vector-square"></i> Tambah Parsil / Lahan</h4>
<label>Nama / ID Parsil</label>
<input id="ps-nama" placeholder="Parsil A-01">
<label>Pemilik</label>
<input id="ps-pemilik" placeholder="Nama pemilik">
<label>Status Sertifikat</label>
<select id="ps-status">
<option value="SHM">SHM &mdash; Hak Milik</option>
<option value="HGB">HGB &mdash; Hak Guna Bangunan</option>
<option value="Sewa">Sewa / Kontrak</option>
<option value="Lainnya">Lainnya</option>
</select>
<div style="display:flex;align-items:center;gap:10px;margin:10px 16px 4px;padding:9px 12px;background:#fffbeb;border-radius:9px;border:1px solid #fde68a">
<i class="fas fa-ruler-combined" style="color:#d97706;font-size:13px"></i>
<span style="font-size:11px;color:#64748b;font-weight:600">Luas terukur:</span>
<span style="font-size:14px;font-weight:800;color:#d97706;margin-left:auto">${fmtArea(area)}</span>
</div>
<button class="pbtn pbtn-y" onclick="saveParsil(${JSON.stringify(pts).replace(/"/g, "'")},${area})">Simpan Parsil</button>
</div>`).addTo(map);
}
function saveParsil(pts, area) {
const nama = document.getElementById('ps-nama').value.trim();
const pemilik = document.getElementById('ps-pemilik').value.trim();
const status = document.getElementById('ps-status').value;
if (!nama) {
alert('Isi nama parsil!');
return;
}
fetch("api/save_parsil.php", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
nama: nama,
pemilik: pemilik,
status: status,
luas: area,
koordinat: pts
})
})
.then(res => res.json())
.then(response => {
if (response.status == "success") {
const parsil = {
id: response.id || Date.now(),
nama,
pemilik,
status,
pts,
area
};
state.parsil.push(parsil);
renderParsil(parsil);
updateCount('parsil', state.parsil.length);
addCard(
'parsil',
parsil.id,
parsil.nama,
parsil.status,
parsil.status,
'#1a5276'
);
map.closePopup();
setMode(null);
}
else {
console.log(response);
alert("Gagal simpan parsil");
}
})
.catch(err => console.log("Parsil Error:", err));
}
function renderParsil(d) {
const pts = normalizePolyPoints(d.pts);
if (pts.length < 3) {
console.log('Format koordinat parsil tidak valid:', d.pts);
return;
}
const c = pColor[d.status];
const poly = L.polygon(pts, {
color: c,
fillColor: c,
fillOpacity: 0.25,
weight: 3,
dashArray: '8, 6',
lineCap: 'round',
lineJoin: 'round',
className: 'parsil-glow'
})
.bindPopup(`
<div class="pi">
<h4><i class="fas fa-draw-polygon" style="color:${c}"></i>${d.nama}</h4>
<div class="pi-row">
<span class="pi-k">Pemilik</span>
<span class="pi-v">${d.pemilik || '-'}</span>
</div>
<div class="pi-row">
<span class="pi-k">Status</span>
<span class="pi-v">${d.status}</span>
</div>
<div class="pi-row">
<span class="pi-k">Luas</span>
<span class="pi-v">${fmtArea(d.area)}</span>
</div>
<div class="act-row">
<button class="pbtn pbtn-y" onclick="openEditParsil(${d.id})">Edit</button>
<button class="pbtn pbtn-r" onclick="deleteFeature('parsil',${d.id})">Hapus</button>
</div>
</div>
`)
.addTo(layers.parsil);
state.featureRefs.parsil[String(d.id)] = poly;
}
function getById(type, id) {
return state[type].find(x => String(x.id) === String(id));
}
function pointsToText(points) {
return points.map(p => `${Number(p[0]).toFixed(6)},${Number(p[1]).toFixed(6)}`).join('\n');
}
function textToPoints(text, minPoints) {
const rows = text.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
const pts = [];
for (const row of rows) {
const parts = row.split(',').map(x => x.trim());
if (parts.length !== 2) return null;
const lat = parseFloat(parts[0]);
const lng = parseFloat(parts[1]);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
pts.push([lat, lng]);
}
if (pts.length < minPoints) return null;
return pts;
}
function toLatLngObjects(pts) {
return pts.map(p => ({ lat: parseFloat(p[0]), lng: parseFloat(p[1]) }));
}
function makeVertexIcon() {
return L.divIcon({
className: '',
html: '<div style="width:12px;height:12px;border-radius:50%;background:#fdfdfd;border:2px solid #1b3a5c;box-shadow:0 4px 12px rgba(15,23,42,.25);"></div>',
iconSize: [16, 16],
iconAnchor: [8, 8]
});
}
function syncEditTextarea(type, points) {
const id = type === 'jalan' ? 'e-jalan-pts' : 'e-parsil-pts';
const el = document.getElementById(id);
if (el) el.value = pointsToText(points);
}
function stopGeometryEdit() {
if (!state.geometryEdit) return;
const g = state.geometryEdit;
if (g.overlay && map.hasLayer(g.overlay)) map.removeLayer(g.overlay);
(g.vertexMarkers || []).forEach(m => { if (map.hasLayer(m)) map.removeLayer(m); });
if (g.onMove) map.off('mousemove', g.onMove);
if (g.onUp) map.off('mouseup', g.onUp);
if (g.onMapClick) map.off('click', g.onMapClick);
map.dragging.enable();
const ref = state.featureRefs[g.type][String(g.id)];
if (ref && ref.setStyle) {
if (g.type === 'jalan') ref.setStyle({ opacity: 0.9, interactive: true });
if (g.type === 'parsil') ref.setStyle({ opacity: 1, fillOpacity: 0.18, interactive: true });
}
state.geometryEdit = null;
const hint = document.getElementById('hint');
hint.classList.remove('show');
}
function startGeometryEdit(type, id) {
if (type !== 'jalan' && type !== 'parsil') return;
const d = getById(type, id);
if (!d) return;
const points = type === 'jalan' ? normalizeLinePoints(d.pts) : normalizePolyPoints(d.pts);
const minPts = type === 'jalan' ? 2 : 3;
if (points.length < minPts) {
alert('Koordinat belum valid untuk diedit di peta');
return;
}
stopGeometryEdit();
setMode(null);
ensureLayerVisible(type);
const ref = state.featureRefs[type][String(id)];
if (ref && ref.setStyle) {
if (type === 'jalan') ref.setStyle({ opacity: 0.2, interactive: false });
if (type === 'parsil') ref.setStyle({ opacity: 0.2, fillOpacity: 0.05, interactive: false });
}
const overlay = type === 'jalan'
? L.polyline(points, { color: '#1b3a5c', weight: 4, dashArray: '8,6', opacity: 0.95 }).addTo(map)
: L.polygon(points, { color: '#1b3a5c', weight: 3, dashArray: '8,6', fillColor: '#1b3a5c', fillOpacity: 0.12 }).addTo(map);
const g = {
type,
id,
points: points.map(p => [p[0], p[1]]),
overlay,
vertexMarkers: [],
draggingAll: false,
lastLatLng: null,
onMove: null,
onUp: null,
onMapClick: null
};
const redraw = () => {
if (g.type === 'jalan') g.overlay.setLatLngs(g.points);
else g.overlay.setLatLngs(g.points);
g.vertexMarkers.forEach((m, i) => m.setLatLng(g.points[i]));
syncEditTextarea(g.type, g.points);
};
const icon = makeVertexIcon();
g.points.forEach((p, i) => {
const vm = L.marker(p, { draggable: true, icon, zIndexOffset: 1000 }).addTo(map);
vm.on('drag', ev => {
const ll = ev.target.getLatLng();
g.points[i] = [ll.lat, ll.lng];
redraw();
});
g.vertexMarkers.push(vm);
});
const startDragAll = (latlng) => {
g.draggingAll = true;
g.lastLatLng = latlng;
map.dragging.disable();
};
g.overlay.on('mousedown', e => {
L.DomEvent.stopPropagation(e);
startDragAll(e.latlng);
});
g.onMapClick = (e) => {
if (e.originalEvent && e.originalEvent.shiftKey) {
startDragAll(e.latlng);
}
};
g.onMove = (e) => {
if (!g.draggingAll || !g.lastLatLng) return;
const dLat = e.latlng.lat - g.lastLatLng.lat;
const dLng = e.latlng.lng - g.lastLatLng.lng;
g.points = g.points.map(p => [p[0] + dLat, p[1] + dLng]);
g.lastLatLng = e.latlng;
redraw();
};
g.onUp = () => {
g.draggingAll = false;
g.lastLatLng = null;
map.dragging.enable();
};
map.on('click', g.onMapClick);
map.on('mousemove', g.onMove);
map.on('mouseup', g.onUp);
state.geometryEdit = g;
syncEditTextarea(type, g.points);
const hint = document.getElementById('hint');
hint.textContent = 'Mode edit geometri aktif: drag titik vertex atau drag shape untuk geser keseluruhan.';
hint.classList.add('show');
}
function refreshType(type) {
if (state.geometryEdit && state.geometryEdit.type === type) stopGeometryEdit();
if (type === 'spbu') {
layers.spbu24.clearLayers();
layers.spbuNon24.clearLayers();
} else {
layers[type].clearLayers();
}
state.featureRefs[type] = {};
if (type === 'kemiskinan') state.kkLayerRefs = {};
const list = document.getElementById('list-' + type);
list.querySelectorAll('.dcard').forEach(x => x.remove());
state[type].forEach(d => {
if (type === 'spbu') {
renderSPBU(d);
addCard('spbu', d.id, d.nama, 'No: ' + d.no, d.status === 'Ya' ? 'Buka 24 Jam' : 'Tidak Buka 24 Jam', d.status === 'Ya' ? '#1a6b3c' : '#b93328');
return;
}
if (type === 'jalan') {
renderJalan(d);
addCard('jalan', d.id, d.nama, d.status + ' · ' + fmtLen(d.len), d.status, jColor[d.status] || '#e74c3c');
return;
}
if (type === 'kemiskinan') {
renderKemiskinan(d);
addCard('kemiskinan', d.id, d.nama_kk, 'Jumlah KK: ' + d.jumlah_kk, 'Kemiskinan', '#9f6500');
return;
}
if (type === 'masjid') {
renderMasjid(d);
addCard('masjid', d.id, d.nama_masjid, d.pic_masjid, 'Rumah Ibadah', '#0f6250');
return;
}
renderParsil(d);
addCard('parsil', d.id, d.nama, d.status, d.status, '#1a5276');
});
updateCount(type, state[type].length);
renderDashboard();
}
function openEditSpbu(id) {
const d = getById('spbu', id);
if (!d) return;
const ref = state.featureRefs.spbu[String(id)];
const ll = ref && ref.getLatLng ? ref.getLatLng() : { lat: d.lat, lng: d.lng };
const html = `<div class="pf">
<h4><i class="fas fa-edit" style="color:var(--amber)"></i> Edit SPBU</h4>
<label>Nama SPBU</label>
<input id="e-spbu-nama" value="${(d.nama || '').replace(/"/g, '&quot;')}">
<label>Nomor SPBU</label>
<input id="e-spbu-no" value="${(d.no || '').replace(/"/g, '&quot;')}">
<label>Status Operasional</label>
<select id="e-spbu-status">
<option value="Ya" ${d.status === 'Ya' ? 'selected' : ''}>Buka 24 Jam</option>
<option value="Tidak" ${d.status === 'Tidak' ? 'selected' : ''}>Tidak Buka 24 Jam</option>
</select>
<label>Latitude</label>
<input id="e-spbu-lat" value="${Number(ll.lat).toFixed(6)}">
<label>Longitude</label>
<input id="e-spbu-lng" value="${Number(ll.lng).toFixed(6)}">
<button class="pbtn pbtn-y" onclick="saveEditSpbu(${id})">Simpan Perubahan</button>
</div>`;
map.closePopup();
L.popup({ maxWidth: 290 }).setLatLng([ll.lat, ll.lng]).setContent(html).openOn(map);
}
function saveEditSpbu(id) {
const nama = document.getElementById('e-spbu-nama').value.trim();
const no = document.getElementById('e-spbu-no').value.trim();
const status = document.getElementById('e-spbu-status').value;
const lat = parseFloat(document.getElementById('e-spbu-lat').value);
const lng = parseFloat(document.getElementById('e-spbu-lng').value);
if (!nama || !no || !Number.isFinite(lat) || !Number.isFinite(lng)) {
alert('Data SPBU belum valid');
return;
}
fetch('api/update_data.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, nama, no, status, lat, lng })
})
.then(res => res.json())
.then(resp => {
if (resp.status !== 'success') {
alert('Gagal update SPBU');
return;
}
const d = getById('spbu', id);
if (!d) return;
d.nama = nama; d.no = no; d.status = status; d.lat = lat; d.lng = lng;
refreshType('spbu');
map.closePopup();
focusFeature('spbu', id);
})
.catch(() => alert('Error update SPBU'));
}
function openEditJalan(id) {
if (state.geometryEdit && (state.geometryEdit.type !== 'jalan' || String(state.geometryEdit.id) !== String(id))) {
stopGeometryEdit();
}
const d = getById('jalan', id);
if (!d) return;
const pts = normalizeLinePoints(d.pts);
const ref = state.featureRefs.jalan[String(id)];
const center = ref && ref.getBounds ? ref.getBounds().getCenter() : map.getCenter();
const html = `<div class="pf" style="width:290px">
<h4><i class="fas fa-edit" style="color:var(--amber)"></i> Edit Jalan</h4>
<label>Nama Jalan</label>
<input id="e-jalan-nama" value="${(d.nama || '').replace(/"/g, '&quot;')}">
<label>Status Jalan</label>
<select id="e-jalan-status">
<option value="Nasional" ${d.status === 'Nasional' ? 'selected' : ''}>Jalan Nasional</option>
<option value="Provinsi" ${d.status === 'Provinsi' ? 'selected' : ''}>Jalan Provinsi</option>
<option value="Kabupaten" ${d.status === 'Kabupaten' ? 'selected' : ''}>Jalan Kabupaten</option>
</select>
<label>Koordinat Lokasi (satu baris: lat,lng)</label>
<textarea id="e-jalan-pts">${pointsToText(pts)}</textarea>
<button class="pbtn pbtn-b" onclick="startGeometryEdit('jalan',${id})">Edit Vertex / Drag di Peta</button>
<button class="pbtn pbtn-y" onclick="saveEditJalan(${id})">Simpan Perubahan</button>
</div>`;
map.closePopup();
L.popup({ maxWidth: 330 }).setLatLng(center).setContent(html).openOn(map);
}
function saveEditJalan(id) {
if (state.geometryEdit && state.geometryEdit.type === 'jalan' && String(state.geometryEdit.id) === String(id)) {
syncEditTextarea('jalan', state.geometryEdit.points);
}
const nama = document.getElementById('e-jalan-nama').value.trim();
const status = document.getElementById('e-jalan-status').value;
const pts = textToPoints(document.getElementById('e-jalan-pts').value, 2);
if (!nama || !pts) {
alert('Data jalan atau koordinat belum valid');
return;
}
const len = calcLen(pts);
fetch('api/update_jalan.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, nama, status, panjang: len, koordinat: toLatLngObjects(pts) })
})
.then(res => res.json())
.then(resp => {
if (resp.status !== 'success') {
alert('Gagal update jalan');
return;
}
const d = getById('jalan', id);
if (!d) return;
d.nama = nama; d.status = status; d.len = len; d.pts = pts;
stopGeometryEdit();
refreshType('jalan');
map.closePopup();
focusFeature('jalan', id);
})
.catch(() => alert('Error update jalan'));
}
function openEditParsil(id) {
if (state.geometryEdit && (state.geometryEdit.type !== 'parsil' || String(state.geometryEdit.id) !== String(id))) {
stopGeometryEdit();
}
const d = getById('parsil', id);
if (!d) return;
const pts = normalizePolyPoints(d.pts);
const ref = state.featureRefs.parsil[String(id)];
const center = ref && ref.getBounds ? ref.getBounds().getCenter() : map.getCenter();
const html = `<div class="pf" style="width:290px">
<h4><i class="fas fa-edit" style="color:var(--amber)"></i> Edit Parsil</h4>
<label>Nama / ID Parsil</label>
<input id="e-parsil-nama" value="${(d.nama || '').replace(/"/g, '&quot;')}">
<label>Pemilik</label>
<input id="e-parsil-pemilik" value="${(d.pemilik || '').replace(/"/g, '&quot;')}">
<label>Status Sertifikat</label>
<select id="e-parsil-status">
<option value="SHM" ${d.status === 'SHM' ? 'selected' : ''}>SHM – Hak Milik</option>
<option value="HGB" ${d.status === 'HGB' ? 'selected' : ''}>HGB – Hak Guna Bangunan</option>
<option value="HGU" ${d.status === 'HGU' ? 'selected' : ''}>HGU – Hak Guna Usaha</option>
<option value="HP" ${d.status === 'HP' ? 'selected' : ''}>HP – Hak Pakai</option>
</select>
<label>Koordinat Lokasi (satu baris: lat,lng)</label>
<textarea id="e-parsil-pts">${pointsToText(pts)}</textarea>
<button class="pbtn pbtn-b" onclick="startGeometryEdit('parsil',${id})">Edit Vertex / Drag di Peta</button>
<button class="pbtn pbtn-y" onclick="saveEditParsil(${id})">Simpan Perubahan</button>
</div>`;
map.closePopup();
L.popup({ maxWidth: 330 }).setLatLng(center).setContent(html).openOn(map);
}
function saveEditParsil(id) {
if (state.geometryEdit && state.geometryEdit.type === 'parsil' && String(state.geometryEdit.id) === String(id)) {
syncEditTextarea('parsil', state.geometryEdit.points);
}
const nama = document.getElementById('e-parsil-nama').value.trim();
const pemilik = document.getElementById('e-parsil-pemilik').value.trim();
const status = document.getElementById('e-parsil-status').value;
const pts = textToPoints(document.getElementById('e-parsil-pts').value, 3);
if (!nama || !pts) {
alert('Data parsil atau koordinat belum valid');
return;
}
const area = calcArea(toLatLngObjects(pts));
fetch('api/update_parsil.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, nama, pemilik, status, luas: area, koordinat: toLatLngObjects(pts) })
})
.then(res => res.json())
.then(resp => {
if (resp.status !== 'success') {
alert('Gagal update parsil');
return;
}
const d = getById('parsil', id);
if (!d) return;
d.nama = nama; d.pemilik = pemilik; d.status = status; d.area = area; d.pts = pts;
stopGeometryEdit();
refreshType('parsil');
map.closePopup();
focusFeature('parsil', id);
})
.catch(() => alert('Error update parsil'));
}
function deleteFeature(type, id) {
if (state.geometryEdit && state.geometryEdit.type === type && String(state.geometryEdit.id) === String(id)) {
stopGeometryEdit();
}
const label = type === 'spbu' ? 'SPBU' : type === 'jalan' ? 'Jalan' : type === 'kemiskinan' ? 'Kemiskinan' : type === 'masjid' ? 'Rumah Ibadah' : 'Parsil';
if (!confirm(`Hapus data ${label} ini?`)) return;
const ep = {
spbu: 'api/delete_data.php',
jalan: 'api/delete_jalan.php',
parsil: 'api/delete_parsil.php',
kemiskinan: 'api/delete_kemiskinan.php',
masjid: 'api/delete_masjid.php'
};
fetch(ep[type], {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
})
.then(res => res.json())
.then(resp => {
if (resp.status !== 'success') {
alert('Gagal menghapus data');
return;
}
state[type] = state[type].filter(x => String(x.id) !== String(id));
if (state.activeCard === 'card-' + type + '-' + id) state.activeCard = null;
refreshType(type);
map.closePopup();
})
.catch(() => alert('Error hapus data'));
}
/* ── HELPERS ── */
function calcLen(pts) {
let t = 0;
for (let i = 1; i < pts.length; i++)t += L.latLng(pts[i - 1]).distanceTo(L.latLng(pts[i]));
return t;
}
function fmtLen(m) { return m >= 1000 ? (m / 1000).toFixed(2) + ' km' : Math.round(m) + ' m' }
function calcArea(pts) {
let a = 0; const n = pts.length;
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
const la1 = pts[i].lat * Math.PI / 180, la2 = pts[j].lat * Math.PI / 180;
const dl = (pts[j].lng - pts[i].lng) * Math.PI / 180;
a += Math.sin(dl) * (2 + Math.sin(la1) + Math.sin(la2));
}
return Math.abs(a * 6378137 * 6378137 / 2);
}
function fmtArea(m) { return m >= 10000 ? (m / 10000).toFixed(2) + ' ha' : Math.round(m) + ' m²' }
function clearTemp() {
state.tempPoints = []; state.drawingLine = false; state.drawingPoly = false;
state.tempMarkers.forEach(m => map.removeLayer(m)); state.tempMarkers = [];
if (state.tempPolyline) { map.removeLayer(state.tempPolyline); state.tempPolyline = null; }
if (state.tempPolygon) { map.removeLayer(state.tempPolygon); state.tempPolygon = null; }
}
function cancelDrawing() {
clearTemp();
if (window._tmpM) { map.removeLayer(window._tmpM); window._tmpM = null; }
if (window._tmpC) { map.removeLayer(window._tmpC); window._tmpC = null; }
}
function ensureLayerVisible(type, id) {
if (type === 'spbu') {
const d = id !== undefined ? getById('spbu', id) : null;
if (d) {
const targetLayer = d.status === 'Ya' ? layers.spbu24 : layers.spbuNon24;
map.addLayer(targetLayer);
} else {
if (state.spbuFilterVis.open24) map.addLayer(layers.spbu24);
if (state.spbuFilterVis.non24) map.addLayer(layers.spbuNon24);
}
return;
}
map.addLayer(layers[type]);
}
function setActiveCard(type, id) {
if (state.activeCard) {
const prev = document.getElementById(state.activeCard);
if (prev) prev.style.boxShadow = '';
}
const cardId = 'card-' + type + '-' + id;
const cur = document.getElementById(cardId);
if (cur) {
cur.style.boxShadow = '0 0 0 2px rgba(27,58,92,0.28), 0 2px 8px rgba(0,0,0,0.1)';
state.activeCard = cardId;
}
}
function focusFeature(type, id) {
const ref = state.featureRefs[type][String(id)];
if (!ref) return;
ensureLayerVisible(type, id);
setActiveCard(type, id);
if (ref.getLatLng) {
map.flyTo(ref.getLatLng(), 17, { duration: 0.7 });
} else if (ref.getBounds) {
map.fitBounds(ref.getBounds(), { padding: [40, 40], maxZoom: 17 });
}
if (ref.openPopup) {
setTimeout(() => ref.openPopup(), 800);
}
}
function updateCount(type, n) {
const el = document.getElementById('cnt-' + type);
if (el) el.textContent = n;
}
function addCard(type, id, name, sub, badge, color) {
const c = document.getElementById('list-' + type);
const el = document.createElement('div');
el.className = 'dcard'; el.id = 'card-' + type + '-' + id;
el.tabIndex = 0;
el.innerHTML = `<div style="position:absolute;left:0;top:0;bottom:0;width:3px;background:${color};border-radius:8px 0 0 8px"></div>
<div class="dc-name">${name}</div>
<div class="dc-sub">${sub}</div>
<div class="dc-badge" style="background:${color}20;color:${color}">${badge}</div>`;
el.onclick = () => focusFeature(type, id);
el.onkeydown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
focusFeature(type, id);
}
};
c.appendChild(el);
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
stopGeometryEdit();
cancelDrawing();
setMode(null);
}
});
document.addEventListener("DOMContentLoaded", function () {
loadSPBU();
loadJalan();
loadParsil();
// Kemiskinan butuh data Masjid untuk hitung Blank Spot (merah/hijau)
// Jadi pastikan loadMasjid selesai dulu baru loadKemiskinan
loadMasjid().then(() => {
loadKemiskinan();
});
});
function loadSPBU() {
fetch("api/read_data.php")
.then(res => res.json())
.then(data => {
data.forEach(d => {
const spbu = {
id: d.id,
nama: d.nama,
no: d.no,
status: d.status,
lat: parseFloat(d.lat),
lng: parseFloat(d.lng)
};
state.spbu.push(spbu);
renderSPBU(spbu);
updateCount('spbu', state.spbu.length);
addCard(
'spbu',
spbu.id,
spbu.nama,
'No: ' + spbu.no,
spbu.status === 'Ya' ? 'Buka 24 Jam' : 'Tidak Buka 24 Jam',
spbu.status === 'Ya' ? '#1a6b3c' : '#b93328'
);
});
})
.catch(err => console.log("SPBU Error:", err));
}
function loadJalan() {
fetch("api/read_jalan.php")
.then(res => res.json())
.then(data => {
data.forEach(d => {
const jalan = {
id: d.id,
nama: d.nama,
status: d.status,
len: parseFloat(d.panjang),
pts: d.koordinat
};
state.jalan.push(jalan);
renderJalan(jalan);
updateCount('jalan', state.jalan.length);
addCard(
'jalan',
jalan.id,
jalan.nama,
jalan.status,
jalan.status,
'#e74c3c'
);
});
})
.catch(err => console.log("Jalan Error:", err));
}
function loadParsil() {
fetch("api/read_parsil.php")
.then(res => res.json())
.then(data => {
data.forEach(d => {
const parsil = {
id: d.id,
nama: d.nama,
pemilik: d.pemilik,
status: d.status,
area: parseFloat(d.luas),
pts: d.koordinat
};
state.parsil.push(parsil);
renderParsil(parsil);
updateCount('parsil', state.parsil.length);
addCard(
'parsil',
parsil.id,
parsil.nama,
parsil.status,
parsil.status,
'#1a5276'
);
});
})
.catch(err => console.log("Parsil Error:", err));
}
/* ── KEMISKINAN ── */
function getKemiskinanDraft() {
return {
nama_kk: document.getElementById('k-nama')?.value || '',
jumlah_kk: document.getElementById('k-jumlah')?.value || '1',
alamat: document.getElementById('k-alamat')?.value || ''
};
}
function escapeAttr(value) {
return String(value || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function escapeText(value) {
return String(value || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function setKemiskinanLatLng(ll) {
const lat = document.getElementById('k-lat');
const lng = document.getElementById('k-lng');
const latDisp = document.getElementById('k-lat-display');
const lngDisp = document.getElementById('k-lng-display');
if (lat) lat.value = ll.lat.toFixed(6);
if (lng) lng.value = ll.lng.toFixed(6);
if (latDisp) latDisp.textContent = ll.lat.toFixed(6);
if (lngDisp) lngDisp.textContent = ll.lng.toFixed(6);
}
function setKemiskinanAddress(text) {
const alamat = document.getElementById('k-alamat');
const addrLabel = document.getElementById('k-addr-label');
const val = text || '';
if (alamat) alamat.value = val;
if (addrLabel && val) addrLabel.textContent = val;
}
function reverseGeocodeKemiskinan(ll) {
const alamat = document.getElementById('k-alamat');
const addrLabel = document.getElementById('k-addr-label');
const latDisp = document.getElementById('k-lat-display');
const lngDisp = document.getElementById('k-lng-display');
if (latDisp) latDisp.textContent = ll.lat.toFixed(6);
if (lngDisp) lngDisp.textContent = ll.lng.toFixed(6);
if (addrLabel) addrLabel.textContent = 'Mengambil alamat...';
if (alamat && !alamat.value.trim()) alamat.value = 'Mengambil alamat...';
const url = `https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${encodeURIComponent(ll.lat)}&lon=${encodeURIComponent(ll.lng)}&zoom=18&addressdetails=1`;
fetch(url, { headers: { 'Accept': 'application/json' } })
.then(res => res.ok ? res.json() : Promise.reject())
.then(data => setKemiskinanAddress(data.display_name || `${ll.lat.toFixed(6)}, ${ll.lng.toFixed(6)}`))
.catch(() => {
if (alamat && alamat.value === 'Mengambil alamat...') alamat.value = '';
alert('Alamat otomatis belum bisa diambil. Silakan isi alamat manual.');
});
}
function startKemiskinanLocationPick() {
window._kkDraft = getKemiskinanDraft();
window._kkPickingLocation = true;
if (window._tmpM && map.hasLayer(window._tmpM)) map.removeLayer(window._tmpM);
window._tmpM = null;
map.closePopup();
map.getContainer().style.cursor = 'crosshair';
const hint = document.getElementById('hint');
if (hint) {
hint.textContent = 'Klik lokasi KK di peta untuk mengisi latitude, longitude, dan alamat.';
hint.classList.add('show');
}
}
function finishKemiskinanLocationPick(e) {
window._kkPickingLocation = false;
const draft = window._kkDraft || {};
window._kkDraft = null;
const hint = document.getElementById('hint');
if (hint) hint.classList.remove('show');
handleKemiskinanClick(e, draft, true);
}
function handleKemiskinanClick(e, draft = {}, autoAddress = false) {
const icon = createModernMarker('#f59e0b', 'fas fa-users', 'KK Miskin Baru');
const tmpM = L.marker(e.latlng, { icon, draggable: true }).addTo(map);
const upd = ll => {
setKemiskinanLatLng(ll);
setKemiskinanAddress('');
reverseGeocodeKemiskinan(ll);
};
tmpM.on('dragend', ev => upd(ev.target.getLatLng()));
// Vulnerability estimator (called on input change)
window._kkVulnUpdate = function () {
const jumlah = parseInt(document.getElementById('k-jumlah')?.value || 1);
const kondisi = document.getElementById('k-kondisi')?.value || 'Sedang';
const airBersih = document.getElementById('k-air')?.checked ? 0 : 8;
const listrik = document.getElementById('k-listrik')?.checked ? 0 : 6;
const kesehatan = document.getElementById('k-kesehatan')?.checked ? 0 : 7;
const sekolah = document.getElementById('k-sekolah')?.checked ? 0 : 5;
const kondisiScore = { Baik: 0, Sedang: 5, Buruk: 12, 'Tidak Layak': 20 }[kondisi] || 5;
const jmlScore = Math.min(jumlah * 5, 25);
const score = Math.min(100, kondisiScore + jmlScore + airBersih + listrik + kesehatan + sekolah);
const cat = score >= 70 ? { label: 'Miskin Ekstrem', color: '#dc2626' } : score >= 45 ? { label: 'Miskin', color: '#ea580c' } : { label: 'Rentan Miskin', color: '#16a34a' };
const el = document.getElementById('k-vuln-score');
const cl = document.getElementById('k-vuln-cat');
if (el) el.textContent = score;
if (cl) { cl.textContent = cat.label; cl.style.color = cat.color; }
};
const form = `<div class="kk-modal">
<div class="km-head">
<span class="km-icon"><i class="fas fa-user-plus"></i></span>
<span class="km-title">Tambah KK Miskin</span>
<button type="button" class="km-close" onclick="map.closePopup()"><i class="fas fa-times"></i></button>
</div>
<div class="km-body">
<div class="km-section">DATA KEPALA KELUARGA</div>
<div class="km-field">
<label>Nama Kepala Keluarga <span class="req">*</span></label>
<input id="k-nama" placeholder="Nama lengkap" value="${escapeAttr(draft.nama_kk)}">
</div>
<div class="km-field">
<label>Jumlah Jiwa <span class="req">*</span></label>
<input type="number" id="k-jumlah" value="${escapeAttr(draft.jumlah_kk || '1')}" min="1" oninput="window._kkVulnUpdate()">
</div>
<div class="km-field">
<label>Alamat <span class="req">*</span></label>
<input id="k-alamat" placeholder="Alamat" value="${escapeAttr(draft.alamat)}">
</div>
<div class="km-section">KONDISI TEMPAT TINGGAL &amp; LAYANAN DASAR</div>
<div class="km-field">
<label>Kondisi Rumah</label>
<select id="k-kondisi" onchange="window._kkVulnUpdate()">
<option value="Baik">Baik</option>
<option value="Sedang" selected>Sedang</option>
<option value="Buruk">Buruk</option>
<option value="Tidak Layak">Tidak Layak</option>
</select>
</div>
<div class="km-field">
<label>Akses Layanan Dasar <span class="km-hint">(centang jika tersedia)</span></label>
<div class="km-checks">
<label class="km-check"><input type="checkbox" id="k-air" onchange="window._kkVulnUpdate()"> Air Bersih</label>
<label class="km-check"><input type="checkbox" id="k-listrik" onchange="window._kkVulnUpdate()"> Listrik</label>
<label class="km-check"><input type="checkbox" id="k-kesehatan" onchange="window._kkVulnUpdate()"> Layanan Kesehatan</label>
<label class="km-check"><input type="checkbox" id="k-sekolah" onchange="window._kkVulnUpdate()"> Anak Putus Sekolah</label>
</div>
</div>
<div class="km-section">LOKASI GPS</div>
<div class="km-gps-box">
<div class="km-gps-row">
<div class="km-gps-item">
<span class="km-gps-lbl">LAT</span>
<span class="km-gps-val" id="k-lat-display">${e.latlng.lat.toFixed(6)}</span>
</div>
<div class="km-gps-sep"></div>
<div class="km-gps-item">
<span class="km-gps-lbl">LNG</span>
<span class="km-gps-val" id="k-lng-display">${e.latlng.lng.toFixed(6)}</span>
</div>
</div>
<div class="km-addr-row" id="k-addr-row">
<i class="fas fa-location-dot" style="color:#EA580C;font-size:13px;flex-shrink:0;margin-top:1px;"></i>
<span id="k-addr-label" style="font-size:12px;color:#ffffff !important;line-height:1.45;font-weight:500;letter-spacing:0.1px;">Mengambil alamat...</span>
</div>
</div>
<input type="hidden" id="k-lat" value="${e.latlng.lat.toFixed(7)}">
<input type="hidden" id="k-lng" value="${e.latlng.lng.toFixed(7)}">
<button type="button" class="km-loc-btn" onclick="startKemiskinanLocationPick()">
<i class="fas fa-map-pin"></i> Pilih Lokasi di Peta
</button>
<div class="km-field" style="margin-top:14px">
<label>Riwayat Penyakit</label>
<textarea id="k-riwayat" placeholder="Contoh: Hipertensi, Diabetes, dll." rows="2"></textarea>
</div>
<div class="km-field">
<label>Surveyor</label>
<input id="k-surveyor" placeholder="Nama petugas survei">
</div>
<div class="km-field">
<label>Keterangan Tambahan</label>
<textarea id="k-keterangan" placeholder="Catatan khusus..." rows="2"></textarea>
</div>
<div class="km-vuln-box">
<div class="kvb-left">
<span class="kvb-label">Estimasi Indeks Kerentanan:</span>
<span class="kvb-cat" id="k-vuln-cat">Rentan Miskin</span>
</div>
<span class="kvb-score" id="k-vuln-score">0</span>
</div>
<div class="km-actions">
<button type="button" class="km-btn km-btn-cancel" onclick="map.closePopup()">Batal</button>
<button type="button" class="km-btn km-btn-save" onclick="saveKemiskinan()">
<i class="fas fa-floppy-disk"></i> Simpan Data
</button>
</div>
</div>
</div>`;
tmpM.bindPopup(form, { maxWidth: 280, autoPanPaddingTopLeft: [0, 80], autoPanPaddingBottomRight: [0, 20] }).openPopup();
tmpM.on('popupclose', () => { if (map.hasLayer(tmpM)) map.removeLayer(tmpM); });
window._tmpM = tmpM;
if (autoAddress || !draft.alamat) reverseGeocodeKemiskinan(e.latlng);
}
function saveKemiskinan() {
const nama_kk = (document.getElementById('k-nama')?.value || '').trim();
const jumlah_kk = (document.getElementById('k-jumlah')?.value || '1').trim();
const alamat = (document.getElementById('k-alamat')?.value || '').trim();
const lat = parseFloat(document.getElementById('k-lat')?.value || 0);
const lng = parseFloat(document.getElementById('k-lng')?.value || 0);
const kondisi_rumah = document.getElementById('k-kondisi')?.value || 'Sedang';
const akses_air_bersih = document.getElementById('k-air')?.checked ? 1 : 0;
const akses_listrik = document.getElementById('k-listrik')?.checked ? 1 : 0;
const akses_kesehatan = document.getElementById('k-kesehatan')?.checked ? 1 : 0;
const anak_putus_sekolah = document.getElementById('k-sekolah')?.checked ? 1 : 0;
const surveyor = (document.getElementById('k-surveyor')?.value || '').trim();
const keterangan = (document.getElementById('k-keterangan')?.value || '').trim();
const tanggal_lahir = (document.getElementById('k-tgl-lahir')?.value || '').trim();
const pendidikan = (document.getElementById('k-pendidikan')?.value || '').trim();
const riwayat_penyakit = (document.getElementById('k-riwayat')?.value || '').trim();
if (!nama_kk || !jumlah_kk || !alamat) {
alert('Lengkapi data: Nama, Jumlah Jiwa, dan Alamat wajib diisi!');
return;
}
fetch("api/save_kemiskinan.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
nama_kk, jumlah_kk, alamat, lat, lng,
kondisi_rumah, akses_air_bersih, akses_listrik, akses_kesehatan,
anak_putus_sekolah, surveyor, keterangan,
tanggal_lahir, pendidikan, riwayat_penyakit
})
})
.then(res => res.json())
.then(response => {
if (response.status == "success") {
const d = {
id: response.id || Date.now(), nama_kk, jumlah_kk: parseInt(jumlah_kk),
alamat, lat, lng, kondisi_rumah, akses_air_bersih, akses_listrik,
akses_kesehatan, anak_putus_sekolah, surveyor, keterangan,
tanggal_lahir, pendidikan, riwayat_penyakit
};
state.kemiskinan.push(d);
renderKemiskinan(d);
updateCount('kemiskinan', state.kemiskinan.length);
addCard('kemiskinan', d.id, nama_kk, 'Jumlah KK: ' + jumlah_kk, 'Kemiskinan', '#9f6500');
renderDashboard();
if (window._tmpM) { map.closePopup(); map.removeLayer(window._tmpM); window._tmpM = null; }
setMode(null);
} else {
alert("Gagal simpan: " + (response.message || 'Error'));
}
})
.catch(err => { console.log(err); alert("Terjadi error"); });
}
function renderKemiskinan(d) {
const status = getKKStatus(d.id);
const done = status === 'Sudah Ditangani';
// Warna marker: HIJAU = dalam radius masjid, MERAH = blank spot
const blank = isBlankSpot(d);
const markerColor = blank ? '#dc2626' : '#16a34a';
const coverIcon = blank
? '<i class="fas fa-circle-xmark" style="color:#dc2626"></i>'
: '<i class="fas fa-circle-check" style="color:#16a34a"></i>';
const coverLabel = blank ? 'Di luar radius rumah ibadah' : 'Dalam radius rumah ibadah';
const nearest = nearestRumahIbadah(d);
const namaRI = nearest ? nearest.nama_masjid : '-';
const jarakRI = nearest ? Math.round(nearest.distance) + ' m' : '-';
const cat = getCategory(d);
const visible = {
extreme: document.getElementById('flt-extreme')?.checked !== false,
poor: document.getElementById('flt-poor')?.checked !== false,
vulnerable: document.getElementById('flt-vulnerable')?.checked !== false
};
if (!visible[cat.key]) return;
const icon = createModernMarker(markerColor, 'fas fa-users', d.nama_kk);
const m = L.marker([d.lat, d.lng], { icon, draggable: IS_LOGGED_IN });
const gisUser = JSON.parse(sessionStorage.getItem('gis_user') || '{}');
const role = gisUser.role || '';
const canManage = role !== 'walikota';
const actionsHtml = canManage ? `<div class="act-row kk-popup-actions">
<button class="pbtn pbtn-y" onclick="openEditKemiskinan(${d.id})">Edit</button>
<button class="pbtn pbtn-b" onclick="setKKStatusQuick(${d.id},'Diproses')">Proses</button>
<button class="pbtn ${done ? 'pbtn-b' : 'pbtn-g'}" onclick="setKKStatusQuick(${d.id},&apos;${done ? 'Belum Ditangani' : 'Sudah Ditangani'}&apos;)">
${done ? 'Batal' : 'Selesai'}
</button>
<button class="pbtn" style="background:#ede9fe;color:#7c3aed" title="Data Warga" onclick="openWargaModal(${d.id},'${d.nama_kk.replace(/'/g, '&#39;')}')"><i class="fas fa-users-cog"></i></button>
<button class="pbtn" style="background:#FFEDD5;color:#1e3a8a" title="Histori Bantuan" onclick="openHistoriBantuan(${d.id},'${d.nama_kk.replace(/'/g, '&#39;')}')"><i class="fas fa-history"></i></button>
<button class="pbtn pbtn-r" onclick="deleteFeature('kemiskinan',${d.id})">Hapus</button>
</div>` : `<div class="act-row kk-popup-actions" style="grid-template-columns:1fr 1fr">
<button class="pbtn" style="background:#ede9fe;color:#7c3aed;padding:8px" onclick="openWargaModal(${d.id},'${d.nama_kk.replace(/'/g, '&#39;')}')"><i class="fas fa-users-cog"></i> Data Warga</button>
<button class="pbtn" style="background:#FFEDD5;color:#1e3a8a;padding:8px" onclick="openHistoriBantuan(${d.id},'${d.nama_kk.replace(/'/g, '&#39;')}')"><i class="fas fa-history"></i> Histori Bantuan</button>
</div>`;
const catBadgeClass = cat.key === 'extreme' ? 'extreme' : cat.key === 'poor' ? 'poor' : 'vulnerable';
const statusColor = status === 'Sudah Ditangani' ? '#059669' : status === 'Diproses' ? '#2563eb' : '#64748b';
const privatePopup = `<div class="pi">
<div class="pi-header">
<div class="pi-header-icon"><i class="fas fa-users"></i></div>
<div class="pi-header-info">
<div class="pi-header-name">${d.nama_kk}</div>
<div class="pi-header-sub"><span class="pi-badge ${catBadgeClass}" style="font-size:10px;padding:2px 7px">${cat.label}</span></div>
</div>
<button class="pi-close" onclick="map.closePopup()"><i class="fas fa-times"></i></button>
</div>
<div class="pi-body">
<div class="pi-row"><span class="pi-k">Jumlah Jiwa</span><span class="pi-v">${d.jumlah_kk} orang</span></div>
<div class="pi-row"><span class="pi-k">Alamat</span><span class="pi-v" style="max-width:140px">${d.alamat}</span></div>
<div class="pi-row"><span class="pi-k">Status</span><span class="pi-v" style="color:${statusColor}">${status}</span></div>
<div class="pi-row"><span class="pi-k">Rumah Ibadah</span><span class="pi-v" style="max-width:130px">${namaRI}</span></div>
<div class="pi-row"><span class="pi-k">Jarak</span><span class="pi-v">${jarakRI}</span></div>
<div class="pi-row"><span class="pi-k">Cakupan</span><span class="pi-v">${coverIcon} ${blank ? 'Blank Spot' : 'Terjangkau'}</span></div>
</div>
${actionsHtml}
</div>`;
const publicPopup = `<div class="pi">
<div class="pi-header" style="background:var(--navy-light)">
<div class="pi-header-icon"><i class="fas fa-lock"></i></div>
<div class="pi-header-info">
<div class="pi-header-name">Data KK Terbatas</div>
<div class="pi-header-sub">Login diperlukan</div>
</div>
<button class="pi-close" onclick="map.closePopup()"><i class="fas fa-times"></i></button>
</div>
<div class="pi-body">
<div class="pi-row"><span class="pi-k">Kategori</span><span class="pi-v">${cat.label}</span></div>
<div class="pi-row"><span class="pi-k">Cakupan</span><span class="pi-v">${coverIcon} ${blank ? 'Blank Spot' : 'Terjangkau'}</span></div>
</div>
<div class="act-row">
<a class="pbtn pbtn-b" style="text-decoration:none;text-align:center" href="#" onclick="openLoginModal(); return false;"><i class="fas fa-sign-in-alt"></i> Login Admin</a>
</div>
</div>`;
m.bindPopup(IS_LOGGED_IN ? privatePopup : publicPopup, { autoPanPaddingTopLeft: [0, 80], autoPanPaddingBottomRight: [0, 20] });
m.addTo(layers.kemiskinan);
state.featureRefs.kemiskinan[String(d.id)] = m;
state.kkLayerRefs[String(d.id)] = m;
}
function loadKemiskinan() {
return fetch("api/read_kemiskinan.php").then(res => res.json()).then(data => {
data.forEach(d => {
const k = { id: d.id, nama_kk: d.nama_kk, jumlah_kk: parseInt(d.jumlah_kk), alamat: d.alamat, status_penanganan: d.status_penanganan || 'Belum Ditangani', lat: parseFloat(d.lat), lng: parseFloat(d.lng), kondisi_rumah: d.kondisi_rumah, akses_air_bersih: parseInt(d.akses_air_bersih || 0), akses_listrik: parseInt(d.akses_listrik || 0), akses_kesehatan: parseInt(d.akses_kesehatan || 0), anak_putus_sekolah: parseInt(d.anak_putus_sekolah || 0), tanggal_lahir: d.tanggal_lahir || '', pendidikan: d.pendidikan || '', riwayat_penyakit: d.riwayat_penyakit || '' };
state.kemiskinan.push(k);
renderKemiskinan(k);
updateCount('kemiskinan', state.kemiskinan.length);
addCard('kemiskinan', k.id, k.nama_kk, 'Jumlah KK: ' + k.jumlah_kk, 'Kemiskinan', '#9f6500');
});
renderDashboard();
if (state.masjid.length) refreshType('masjid');
}).catch(err => console.log("Kemiskinan Error:", err));
}
function openEditKemiskinan(id) {
const d = getById('kemiskinan', id);
if (!d) return;
const pendOpts = ['', 'Tidak Sekolah', 'SD/Sederajat', 'SMP/Sederajat', 'SMA/Sederajat', 'D3/S1', 'S2/S3'];
const pendSelects = pendOpts.map(v => `<option value="${v}" ${d.pendidikan === v ? 'selected' : ''}>${v || '-- Pilih Pendidikan --'}</option>`).join('');
const html = `<div class="pf" style="width:300px">
<h4><i class="fas fa-edit" style="color:var(--amber)"></i> Edit KK Miskin</h4>
<label>Nama Kepala Keluarga</label>
<input id="e-k-nama" value="${(d.nama_kk || '').replace(/"/g, '&quot;')}">
<label>Jumlah Jiwa</label>
<input type="number" id="e-k-jumlah" value="${d.jumlah_kk}" min="1">
<label>Tanggal Lahir</label>
<input type="date" id="e-k-tgl-lahir" value="${d.tanggal_lahir || ''}">
<label>Pendidikan Terakhir</label>
<select id="e-k-pendidikan">${pendSelects}</select>
<label>Alamat</label>
<textarea id="e-k-alamat">${(d.alamat || '').replace(/</g, '&lt;')}</textarea>
<label>Riwayat Penyakit</label>
<textarea id="e-k-riwayat" rows="2">${(d.riwayat_penyakit || '').replace(/</g, '&lt;')}</textarea>
<label>Latitude</label>
<input id="e-k-lat" value="${Number(d.lat).toFixed(6)}">
<label>Longitude</label>
<input id="e-k-lng" value="${Number(d.lng).toFixed(6)}">
<label>Status Penanganan</label>
<select id="e-k-status">
<option ${getKKStatus(id) === 'Belum Ditangani' ? 'selected' : ''}>Belum Ditangani</option>
<option ${getKKStatus(id) === 'Diproses' ? 'selected' : ''}>Diproses</option>
<option ${getKKStatus(id) === 'Sudah Ditangani' ? 'selected' : ''}>Sudah Ditangani</option>
</select>
<button class="pbtn pbtn-y" onclick="saveEditKemiskinan(${id})">Simpan Perubahan</button>
</div>`;
map.closePopup();
L.popup({ maxWidth: 330 }).setLatLng([d.lat, d.lng]).setContent(html).openOn(map);
}
function saveEditKemiskinan(id) {
const nama_kk = document.getElementById('e-k-nama').value.trim();
const jumlah_kk = document.getElementById('e-k-jumlah').value.trim();
const alamat = document.getElementById('e-k-alamat').value.trim();
const lat = parseFloat(document.getElementById('e-k-lat').value);
const lng = parseFloat(document.getElementById('e-k-lng').value);
const status = document.getElementById('e-k-status')?.value || getKKStatus(id);
const tanggal_lahir = document.getElementById('e-k-tgl-lahir')?.value || '';
const pendidikan = document.getElementById('e-k-pendidikan')?.value || '';
const riwayat_penyakit = document.getElementById('e-k-riwayat')?.value.trim() || '';
if (!nama_kk || !jumlah_kk || !alamat || !Number.isFinite(lat) || !Number.isFinite(lng)) {
alert('Data belum valid'); return;
}
fetch('api/update_kemiskinan.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, nama_kk, jumlah_kk, alamat, lat, lng, tanggal_lahir, pendidikan, riwayat_penyakit })
}).then(res => res.json()).then(resp => {
if (resp.status !== 'success') { alert('Gagal update'); return; }
const d = getById('kemiskinan', id); if (!d) return;
d.nama_kk = nama_kk; d.jumlah_kk = parseInt(jumlah_kk); d.alamat = alamat; d.lat = lat; d.lng = lng;
d.tanggal_lahir = tanggal_lahir; d.pendidikan = pendidikan; d.riwayat_penyakit = riwayat_penyakit;
d.status_penanganan = status;
const mapStatus = getStatusMap();
mapStatus[String(id)] = status;
localStorage.setItem(statusStoreKey, JSON.stringify(mapStatus));
// Simpan status ke database
fetch('api/update_status_kemiskinan.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: parseInt(id), status })
}).catch(() => console.warn('Gagal simpan status ke DB'));
renderDashboard();
refreshType('kemiskinan');
map.closePopup();
focusFeature('kemiskinan', id);
}).catch(() => alert('Error update data'));
}
/* ── MASJID ── */
function setMasjidAddress(text) {
const alamat = document.getElementById('m-alamat');
const addrLabel = document.getElementById('m-addr-label');
const val = text || '';
if (alamat) alamat.value = val;
if (addrLabel && val) addrLabel.textContent = val;
}
function reverseGeocodeMasjid(ll) {
const alamat = document.getElementById('m-alamat');
const addrLabel = document.getElementById('m-addr-label');
const latDisp = document.getElementById('m-lat-display');
const lngDisp = document.getElementById('m-lng-display');
if (latDisp) latDisp.textContent = ll.lat.toFixed(6);
if (lngDisp) lngDisp.textContent = ll.lng.toFixed(6);
if (addrLabel) addrLabel.textContent = 'Mengambil alamat...';
if (alamat && !alamat.value.trim()) alamat.value = 'Mengambil alamat...';
const url = `https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${encodeURIComponent(ll.lat)}&lon=${encodeURIComponent(ll.lng)}&zoom=18&addressdetails=1`;
fetch(url, { headers: { 'Accept': 'application/json' } })
.then(res => res.ok ? res.json() : Promise.reject())
.then(data => setMasjidAddress(data.display_name || `${ll.lat.toFixed(6)}, ${ll.lng.toFixed(6)}`))
.catch(() => {
if (alamat && alamat.value === 'Mengambil alamat...') alamat.value = '';
if (addrLabel) addrLabel.textContent = 'Gagal mengambil alamat otomatis';
});
}
function handleMasjidClick(e, draft = {}, autoAddress = false) {
const icon = createModernMarker('#0f6250', 'fas fa-mosque', 'Masjid Baru');
const tmpM = L.marker(e.latlng, { icon, draggable: true }).addTo(map);
const tmpC = L.circle(e.latlng, { radius: 500, color: '#0f6250', fillColor: '#0f6250', fillOpacity: 0.1, weight: 1 }).addTo(map);
const upd = ll => {
const a = document.getElementById('m-lat'), b = document.getElementById('m-lng');
const latDisp = document.getElementById('m-lat-display'), lngDisp = document.getElementById('m-lng-display');
if (a) a.value = ll.lat.toFixed(6);
if (b) b.value = ll.lng.toFixed(6);
if (latDisp) latDisp.textContent = ll.lat.toFixed(6);
if (lngDisp) lngDisp.textContent = ll.lng.toFixed(6);
tmpC.setLatLng(ll);
setMasjidAddress('');
reverseGeocodeMasjid(ll);
};
tmpM.on('dragend', ev => upd(ev.target.getLatLng()));
const form = `<div class="kk-modal">
<div class="km-head">
<span class="km-icon" style="background:#D1FAE5;color:#0f6250"><i class="fas fa-place-of-worship"></i></span>
<span class="km-title">Tambah Rumah Ibadah</span>
<button type="button" class="km-close" onclick="map.closePopup()"><i class="fas fa-times"></i></button>
</div>
<div class="km-body">
<div class="km-section" style="color:#0f6250;border-color:#ccfbf1;">PROFIL RUMAH IBADAH</div>
<div class="km-field">
<label>Nama Rumah Ibadah <span class="req">*</span></label>
<input id="m-nama" placeholder="Contoh: Masjid Raya Mujahidin" value="${draft.nama_masjid || ''}">
</div>
<div class="km-field">
<label>Penanggung Jawab <span class="req">*</span></label>
<input id="m-pic" placeholder="Nama PIC/Pengurus" value="${draft.pic_masjid || ''}">
</div>
<div class="km-section" style="color:#0f6250;border-color:#ccfbf1;">ALAMAT &amp; JANGKAUAN</div>
<div class="km-field">
<label>Alamat Lengkap <span class="req">*</span></label>
<textarea id="m-alamat" placeholder="Alamat lengkap">${draft.alamat_masjid || ''}</textarea>
</div>
<div class="km-field">
<label>Radius Pelayanan (Meter): <span id="m-rad-val" style="color:#0f6250;font-weight:800;">500</span>m</label>
<input type="range" id="m-radius" min="100" max="5000" step="100" value="500" style="width:100%;margin-bottom:8px;accent-color:#0f6250;" oninput="document.getElementById('m-rad-val').innerText=this.value; window._tmpC.setRadius(parseInt(this.value));">
</div>
<div class="km-section" style="color:#0f6250;border-color:#ccfbf1;">LOKASI GPS</div>
<div class="km-gps-box" style="background:linear-gradient(135deg,#064e3b,#0f6250);">
<div class="km-gps-row">
<div class="km-gps-item">
<span class="km-gps-lbl">LAT</span>
<span class="km-gps-val" id="m-lat-display">${e.latlng.lat.toFixed(6)}</span>
</div>
<div class="km-gps-sep"></div>
<div class="km-gps-item">
<span class="km-gps-lbl">LNG</span>
<span class="km-gps-val" id="m-lng-display">${e.latlng.lng.toFixed(6)}</span>
</div>
</div>
<div class="km-addr-row" id="m-addr-row">
<i class="fas fa-location-dot" style="color:#6ee7b7;font-size:13px;flex-shrink:0;margin-top:1px;"></i>
<span id="m-addr-label" style="font-size:12px;color:#ffffff !important;line-height:1.45;font-weight:500;letter-spacing:0.1px;">Mengambil alamat...</span>
</div>
</div>
<input type="hidden" id="m-lat" value="${e.latlng.lat.toFixed(6)}">
<input type="hidden" id="m-lng" value="${e.latlng.lng.toFixed(6)}">
<div class="hint-txt" style="margin-top:4px;text-align:center;">* Geser marker untuk update alamat otomatis</div>
<div class="km-actions">
<button type="button" class="km-btn km-btn-cancel" onclick="map.closePopup()">Batal</button>
<button type="button" class="km-btn km-btn-save" style="background:linear-gradient(135deg,#0f6250,#14b8a6);" onclick="saveMasjid()">
<i class="fas fa-floppy-disk"></i> Simpan Data
</button>
</div>
</div>
</div>`;
tmpM.bindPopup(form, { maxWidth: 340, autoPanPaddingTopLeft: [0, 80], autoPanPaddingBottomRight: [0, 20] }).openPopup();
tmpM.on('popupclose', () => {
if (map.hasLayer(tmpM)) map.removeLayer(tmpM);
if (map.hasLayer(tmpC)) map.removeLayer(tmpC);
});
window._tmpM = tmpM;
window._tmpC = tmpC;
if (autoAddress || !draft.alamat_masjid) reverseGeocodeMasjid(e.latlng);
}
function saveMasjid() {
const nama_masjid = document.getElementById('m-nama').value.trim();
const pic_masjid = document.getElementById('m-pic').value.trim();
const alamat_masjid = document.getElementById('m-alamat').value.trim();
const radius = document.getElementById('m-radius').value;
const lat = parseFloat(document.getElementById('m-lat').value);
const lng = parseFloat(document.getElementById('m-lng').value);
if (!nama_masjid || !pic_masjid || !alamat_masjid) {
alert('Lengkapi semua data Rumah Ibadah!');
return;
}
fetch("api/save_masjid.php", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nama_masjid, pic_masjid, alamat_masjid, radius, lat, lng })
}).then(res => res.json()).then(response => {
if (response.status == "success") {
const d = { id: response.id || Date.now(), nama_masjid, pic_masjid, alamat_masjid, radius: parseInt(radius), lat, lng };
state.masjid.push(d);
renderMasjid(d);
updateCount('masjid', state.masjid.length);
addCard('masjid', d.id, nama_masjid, d.pic_masjid, 'Rumah Ibadah', '#0f6250');
renderDashboard();
if (window._tmpM) { map.closePopup(); map.removeLayer(window._tmpM); window._tmpM = null; }
if (window._tmpC) { map.removeLayer(window._tmpC); window._tmpC = null; }
setMode(null);
} else { alert("Gagal simpan database"); }
}).catch(err => { console.log(err); alert("Terjadi error"); });
}
function renderMasjid(d) {
const color = '#0f6250';
const summary = rumahIbadahStats(d);
const icon = createModernMarker(color, 'fas fa-place-of-worship', d.nama_masjid);
const m = L.marker([d.lat, d.lng], { icon, draggable: IS_LOGGED_IN });
const circle = L.circle([d.lat, d.lng], { radius: d.radius, color: color, fillColor: color, fillOpacity: 0.1, weight: 1 });
const grp = L.featureGroup([circle, m]);
const gisUser = JSON.parse(sessionStorage.getItem('gis_user') || '{}');
const role = gisUser.role || '';
const canManage = IS_LOGGED_IN && role !== 'walikota';
const adminActions = canManage ? `<div class="act-row">
<button class="pbtn pbtn-y" onclick="openEditMasjid(${d.id})">Edit</button>
<button class="pbtn pbtn-r" onclick="deleteFeature('masjid',${d.id})">Hapus</button>
</div>` : '';
m.bindPopup(`<div class="pi">
<div class="pi-header" style="background:linear-gradient(135deg,#064e3b,#0f6250)">
<div class="pi-header-icon"><i class="fas fa-place-of-worship"></i></div>
<div class="pi-header-info">
<div class="pi-header-name">${d.nama_masjid}</div>
<div class="pi-header-sub"><i class="fas fa-user" style="font-size:10px"></i> ${d.pic_masjid} &bull; Radius ${d.radius} m</div>
</div>
<button class="pi-close" onclick="map.closePopup()"><i class="fas fa-times"></i></button>
</div>
<div class="pi-body">
<div class="pi-row"><span class="pi-k">Alamat</span><span class="pi-v" style="max-width:140px">${d.alamat_masjid}</span></div>
<div class="pi-row"><span class="pi-k">Penanggung Jawab</span><span class="pi-v">${d.pic_masjid}</span></div>
<div class="pi-row"><span class="pi-k">Radius Layanan</span><span class="pi-v">${d.radius} m</span></div>
</div>
` + (IS_LOGGED_IN ? `
<div style="padding:8px 12px 4px;font-size:10px;font-weight:800;color:#0f6250;text-transform:uppercase;letter-spacing:.6px;border-top:1px solid #e2e8f0;">
<i class="fas fa-users" style="margin-right:4px;"></i>KK Miskin Dalam Radius
</div>
<div class="pi-body" style="padding-top:4px">
<div class="pi-row"><span class="pi-k">Total KK</span><span class="pi-v">${summary.total}</span></div>
<div class="pi-row"><span class="pi-k" style="color:#dc2626">Miskin Ekstrem</span><span class="pi-v" style="color:#dc2626">${summary.extreme}</span></div>
<div class="pi-row"><span class="pi-k" style="color:#2563eb">Sedang Diproses</span><span class="pi-v" style="color:#2563eb">${summary.process}</span></div>
<div class="pi-row"><span class="pi-k" style="color:#059669">Sudah Ditangani</span><span class="pi-v" style="color:#059669">${summary.handled}</span></div>
` + (summary.sample.length > 0
? '<div style="font-size:10px;font-weight:700;color:#64748b;margin:6px 0 2px;text-transform:uppercase;letter-spacing:.5px;">KK Terdekat</div>'
+ summary.sample.map(k => '<div class="pi-row"><span class="pi-k">' + k.nama_kk + '</span><span class="pi-v" style="color:#64748b">' + Math.round(k.distance) + ' m</span></div>').join('')
: '<div style="font-size:12px;color:#94a3b8;padding:4px 0;text-align:center;">Tidak ada KK dalam radius ini</div>'
) + `
</div>` : '') + `
${adminActions}
</div>`);
grp.addTo(layers.masjid);
state.featureRefs.masjid[String(d.id)] = grp;
}
function loadMasjid() {
return fetch("api/read_masjid.php").then(res => res.json()).then(data => {
data.forEach(d => {
const m = { id: d.id, nama_masjid: d.nama_masjid, pic_masjid: d.pic_masjid, alamat_masjid: d.alamat_masjid, radius: parseInt(d.radius), lat: parseFloat(d.lat), lng: parseFloat(d.lng) };
state.masjid.push(m);
renderMasjid(m);
updateCount('masjid', state.masjid.length);
addCard('masjid', m.id, m.nama_masjid, m.pic_masjid, 'Rumah Ibadah', '#0f6250');
});
renderDashboard();
}).catch(err => console.log("Masjid Error:", err));
}
function openEditMasjid(id) {
const d = getById('masjid', id);
if (!d) return;
const html = `<div class="kk-modal">
<div class="km-head">
<span class="km-icon" style="background:#D1FAE5;color:#0f6250"><i class="fas fa-edit"></i></span>
<span class="km-title">Edit Rumah Ibadah</span>
<button type="button" class="km-close" onclick="map.closePopup()"><i class="fas fa-times"></i></button>
</div>
<div class="km-body">
<div class="km-section" style="color:#0f6250;border-color:#ccfbf1;">PROFIL RUMAH IBADAH</div>
<div class="km-field">
<label>Nama Rumah Ibadah <span class="req">*</span></label>
<input id="e-m-nama" value="${(d.nama_masjid || '').replace(/"/g, '&quot;')}">
</div>
<div class="km-field">
<label>Penanggung Jawab <span class="req">*</span></label>
<input id="e-m-pic" value="${(d.pic_masjid || '').replace(/"/g, '&quot;')}">
</div>
<div class="km-section" style="color:#0f6250;border-color:#ccfbf1;">ALAMAT &amp; JANGKAUAN</div>
<div class="km-field">
<label>Alamat Lengkap <span class="req">*</span></label>
<textarea id="e-m-alamat">${(d.alamat_masjid || '').replace(/</g, '&lt;')}</textarea>
</div>
<div class="km-field">
<label>Radius Pelayanan (Meter): <span id="e-m-rad-val" style="color:#0f6250;font-weight:800;">${d.radius}</span>m</label>
<input type="range" id="e-m-radius" min="100" max="5000" step="100" value="${d.radius}" style="width:100%;margin-bottom:8px;accent-color:#0f6250;" oninput="document.getElementById('e-m-rad-val').innerText=this.value">
</div>
<div class="km-section" style="color:#0f6250;border-color:#ccfbf1;">LOKASI GPS</div>
<div class="km-gps-box" style="background:linear-gradient(135deg,#064e3b,#0f6250);">
<div class="km-gps-row" style="padding:10px 12px;">
<div class="km-gps-item">
<span class="km-gps-lbl">LATITUDE</span>
<input id="e-m-lat" value="${Number(d.lat).toFixed(6)}" style="background:transparent;border:none;color:#fff;font-family:'Space Grotesk',sans-serif;font-size:14px;font-weight:700;text-align:center;width:100%;outline:none;">
</div>
<div class="km-gps-sep" style="height:32px;"></div>
<div class="km-gps-item">
<span class="km-gps-lbl">LONGITUDE</span>
<input id="e-m-lng" value="${Number(d.lng).toFixed(6)}" style="background:transparent;border:none;color:#fff;font-family:'Space Grotesk',sans-serif;font-size:14px;font-weight:700;text-align:center;width:100%;outline:none;">
</div>
</div>
</div>
<div class="km-actions">
<button type="button" class="km-btn km-btn-cancel" onclick="map.closePopup()">Batal</button>
<button type="button" class="km-btn km-btn-save" style="background:linear-gradient(135deg,#0f6250,#14b8a6);" onclick="saveEditMasjid(${id})">
<i class="fas fa-check"></i> Simpan
</button>
</div>
</div>
</div>`;
map.closePopup();
L.popup({ maxWidth: 340 }).setLatLng([d.lat, d.lng]).setContent(html).openOn(map);
}
function saveEditMasjid(id) {
const nama_masjid = document.getElementById('e-m-nama').value.trim();
const pic_masjid = document.getElementById('e-m-pic').value.trim();
const alamat_masjid = document.getElementById('e-m-alamat').value.trim();
const radius = document.getElementById('e-m-radius').value;
const lat = parseFloat(document.getElementById('e-m-lat').value);
const lng = parseFloat(document.getElementById('e-m-lng').value);
if (!nama_masjid || !pic_masjid || !alamat_masjid || !Number.isFinite(lat) || !Number.isFinite(lng)) {
alert('Data belum valid'); return;
}
fetch('api/update_masjid.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, nama_masjid, pic_masjid, alamat_masjid, radius, lat, lng })
}).then(res => res.json()).then(resp => {
if (resp.status !== 'success') { alert('Gagal update'); return; }
const d = getById('masjid', id); if (!d) return;
d.nama_masjid = nama_masjid; d.pic_masjid = pic_masjid; d.alamat_masjid = alamat_masjid; d.radius = parseInt(radius); d.lat = lat; d.lng = lng;
refreshType('masjid');
map.closePopup();
focusFeature('masjid', id);
}).catch(() => alert('Error update data'));
}
/* ── HISTORI BANTUAN MODAL ── */
function openHistoriBantuan(kkId, kkNama) {
const existing = document.getElementById('hb-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.className = 'hb-overlay';
overlay.id = 'hb-overlay';
overlay.innerHTML = `
<div class="hb-box">
<div class="hb-head">
<div class="hb-head-icon"><i class="fas fa-hand-holding-heart"></i></div>
<div style="flex:1;min-width:0">
<div class="hb-head-title">Histori Bantuan</div>
<div style="font-size:11px;opacity:.75;margin-top:1px">${kkNama}</div>
</div>
<button class="hb-head-close" onclick="document.getElementById('hb-overlay').remove()"><i class="fas fa-times"></i></button>
</div>
<div class="hb-body">
<div class="hb-form" id="hb-form-tambah" style="display:none">
<div class="hb-form-title">+ Tambah Bantuan Baru</div>
<div class="km-field" style="margin-bottom:8px">
<label>Jenis Bantuan *</label>
<input id="hb-jenis" placeholder="Contoh: Sembako, PKH, BPNT, dll">
</div>
<div class="km-field" style="margin-bottom:8px">
<label>Tanggal *</label>
<input type="date" id="hb-tanggal" value="${new Date().toISOString().split('T')[0]}">
</div>
<div class="km-field" style="margin-bottom:8px">
<label>Keterangan</label>
<textarea id="hb-ket" rows="2" placeholder="Catatan tambahan..."></textarea>
</div>
<button class="hb-submit" onclick="saveHistoriBantuan(${kkId})">
<i class="fas fa-plus"></i> Simpan Bantuan
</button>
</div>
<div class="hb-list-title">Riwayat Bantuan</div>
<div id="hb-list"><div class="hb-empty"><i class="fas fa-spinner fa-spin"></i> Memuat...</div></div>
</div>
</div>
`;
document.body.appendChild(overlay);
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
const gisUser = JSON.parse(sessionStorage.getItem('gis_user') || '{}');
if (gisUser.role !== 'walikota') {
document.getElementById('hb-form-tambah').style.display = 'block';
}
loadHistoriBantuan(kkId);
}
function loadHistoriBantuan(kkId) {
const gisUser = JSON.parse(sessionStorage.getItem('gis_user') || '{}');
const canManage = gisUser.role !== 'walikota';
fetch(`api/read_histori_bantuan.php?kemiskinan_id=${kkId}`)
.then(res => res.json())
.then(data => {
const list = document.getElementById('hb-list');
if (!list) return;
if (!Array.isArray(data) || data.length === 0) {
list.innerHTML = '<div class="hb-empty"><i class="fas fa-inbox"></i><br>Belum ada histori bantuan</div>';
return;
}
list.innerHTML = data.map(h => `
<div class="hb-item" id="hb-item-${h.id}">
<div class="hb-item-top">
<div>
<div class="hb-item-jenis">${h.jenis_bantuan}</div>
<div class="hb-item-tgl"><i class="fas fa-calendar-alt"></i> ${h.tanggal}</div>
</div>
${canManage ? `<button class="hb-item-del" onclick="deleteHistoriBantuan(${h.id},${kkId})"><i class="fas fa-trash"></i></button>` : ''}
</div>
${h.keterangan ? `<div class="hb-item-ket">${h.keterangan}</div>` : ''}
</div>
`).join('');
})
.catch(() => {
const list = document.getElementById('hb-list');
if (list) list.innerHTML = '<div class="hb-empty" style="color:#ef4444">Gagal memuat data</div>';
});
}
function saveHistoriBantuan(kkId) {
const jenis = (document.getElementById('hb-jenis')?.value || '').trim();
const tanggal = (document.getElementById('hb-tanggal')?.value || '').trim();
const ket = (document.getElementById('hb-ket')?.value || '').trim();
if (!jenis || !tanggal) {
alert('Jenis bantuan dan tanggal wajib diisi!');
return;
}
fetch('api/save_histori_bantuan.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kemiskinan_id: kkId, jenis_bantuan: jenis, tanggal, keterangan: ket })
}).then(res => res.json()).then(resp => {
if (resp.status !== 'success') { alert('Gagal simpan: ' + (resp.message || 'Error')); return; }
document.getElementById('hb-jenis').value = '';
document.getElementById('hb-ket').value = '';
loadHistoriBantuan(kkId);
// update status KK menjadi Diproses jika masih belum ditangani
if (getKKStatus(kkId) === 'Belum Ditangani') setKKStatus(kkId, 'Diproses');
}).catch(() => alert('Error menyimpan histori'));
}
function deleteHistoriBantuan(histId, kkId) {
if (!confirm('Hapus histori bantuan ini?')) return;
fetch('api/delete_histori_bantuan.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: histId })
}).then(res => res.json()).then(resp => {
if (resp.status !== 'success') { alert('Gagal hapus'); return; }
const el = document.getElementById('hb-item-' + histId);
if (el) el.remove();
// cek apakah list kosong
const list = document.getElementById('hb-list');
if (list && !list.querySelector('.hb-item')) {
list.innerHTML = '<div class="hb-empty"><i class="fas fa-inbox"></i><br>Belum ada histori bantuan</div>';
}
}).catch(() => alert('Error hapus histori'));
}
/* ── LAPORAN MASYARAKAT MODAL ── */
let _laporanCache = [];
function openLaporanModal() {
const existing = document.getElementById('lm-overlay');
if (existing) existing.remove();
const isAdmin = IS_LOGGED_IN;
const gisUser = JSON.parse(sessionStorage.getItem('gis_user') || '{}');
const role = gisUser.role || '';
const canManage = isAdmin && role !== 'walikota';
const overlay = document.createElement('div');
overlay.className = 'lm-overlay';
overlay.id = 'lm-overlay';
overlay.innerHTML = `
<div class="lm-box">
<div class="lm-head">
<h3><i class="fas fa-bullhorn"></i> Laporan Masyarakat</h3>
<p>Jalur pelaporan cepat masalah kemiskinan di Kota Pontianak</p>
<button class="lm-close-btn" onclick="document.getElementById('lm-overlay').remove()"><i class="fas fa-times"></i></button>
</div>
<div class="lm-body">
<div class="lm-tabs">
<button class="lm-tab active" onclick="switchLaporanTab('form',this)">📝 Kirim Laporan</button>
${isAdmin ? `<button class="lm-tab" onclick="switchLaporanTab('daftar',this)">📋 Daftar Laporan</button>` : ''}
</div>
<div class="lm-section active" id="lm-tab-form">
<div class="lm-field">
<label>Nama Pelapor <span style="color:#ef4444">*</span></label>
<input id="lm-nama" placeholder="Nama lengkap Anda">
</div>
<div class="lm-field">
<label>Nomor Kontak (opsional)</label>
<input id="lm-kontak" placeholder="No. HP / WhatsApp">
</div>
<div class="lm-field">
<label>Deskripsi Laporan <span style="color:#ef4444">*</span></label>
<textarea id="lm-deskripsi" placeholder="Ceritakan kondisi keluarga miskin yang perlu diperhatikan, lokasi, dll..."></textarea>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
<div class="lm-field">
<label>Latitude</label>
<input id="lm-lat" placeholder="Klik peta lalu salin" type="number" step="any">
</div>
<div class="lm-field">
<label>Longitude</label>
<input id="lm-lng" placeholder="Klik peta lalu salin" type="number" step="any">
</div>
</div>
<p style="font-size:11px;color:var(--muted);margin-bottom:14px;">
<i class="fas fa-info-circle" style="color:var(--blue)"></i>
Koordinat opsional. Klik kanan di peta untuk mendapatkan koordinat lokasi.
</p>
<button class="lm-submit" onclick="submitLaporan()">
<i class="fas fa-paper-plane"></i> Kirim Laporan
</button>
<div id="lm-success" style="display:none;margin-top:14px;padding:12px 14px;background:#d1fae5;border-radius:14px;color:#065f46;font-size:13px;font-weight:600;text-align:center;">
<i class="fas fa-check-circle"></i> Laporan berhasil dikirim! Terima kasih.
</div>
</div>
${isAdmin ? `<div class="lm-section" id="lm-tab-daftar">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<span style="font-size:12px;font-weight:700;color:var(--muted)" id="lm-count-label">Memuat...</span>
<button onclick="loadDaftarLaporan()" style="border:1px solid var(--border);background:#fff;border-radius:8px;padding:5px 10px;font-size:11px;font-weight:700;cursor:pointer;color:var(--navy)"><i class="fas fa-sync"></i> Refresh</button>
</div>
<div id="lm-daftar-list"><div style="text-align:center;padding:20px;color:var(--muted)"><i class="fas fa-spinner fa-spin"></i> Memuat...</div></div>
</div>` : ''}
</div>
</div>
`;
document.body.appendChild(overlay);
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
if (isAdmin) loadDaftarLaporan();
}
function switchLaporanTab(tab, btn) {
document.querySelectorAll('.lm-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.lm-section').forEach(s => s.classList.remove('active'));
if (btn) btn.classList.add('active');
const sec = document.getElementById('lm-tab-' + tab);
if (sec) sec.classList.add('active');
if (tab === 'daftar') loadDaftarLaporan();
}
function submitLaporan() {
const nama_pelapor = (document.getElementById('lm-nama')?.value || '').trim();
const kontak = (document.getElementById('lm-kontak')?.value || '').trim();
const deskripsi_laporan = (document.getElementById('lm-deskripsi')?.value || '').trim();
const lat = parseFloat(document.getElementById('lm-lat')?.value || 0);
const lng = parseFloat(document.getElementById('lm-lng')?.value || 0);
if (!nama_pelapor || !deskripsi_laporan) {
alert('Nama pelapor dan deskripsi laporan wajib diisi!');
return;
}
fetch('api/save_laporan.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nama_pelapor, kontak, deskripsi_laporan, lat, lng })
}).then(res => res.json()).then(resp => {
if (resp.status !== 'success') { alert('Gagal kirim laporan: ' + (resp.message || '')); return; }
document.getElementById('lm-nama').value = '';
document.getElementById('lm-kontak').value = '';
document.getElementById('lm-deskripsi').value = '';
document.getElementById('lm-lat').value = '';
document.getElementById('lm-lng').value = '';
const sukses = document.getElementById('lm-success');
if (sukses) { sukses.style.display = 'block'; setTimeout(() => sukses.style.display = 'none', 4000); }
updateLaporanBadge();
}).catch(() => alert('Terjadi kesalahan saat mengirim laporan'));
}
function loadDaftarLaporan() {
const list = document.getElementById('lm-daftar-list');
const label = document.getElementById('lm-count-label');
if (!list) return;
list.innerHTML = '<div style="text-align:center;padding:20px;color:var(--muted)"><i class="fas fa-spinner fa-spin"></i> Memuat...</div>';
fetch('api/read_laporan.php')
.then(res => res.json())
.then(data => {
_laporanCache = data;
if (label) label.textContent = `${data.length} laporan ditemukan`;
const gisUser = JSON.parse(sessionStorage.getItem('gis_user') || '{}');
const canManage = gisUser.role !== 'walikota';
if (!data.length) {
list.innerHTML = '<div style="text-align:center;padding:24px;color:var(--muted)"><i class="fas fa-inbox"></i><br>Belum ada laporan masuk</div>';
return;
}
list.innerHTML = data.map(r => `
<div class="lm-report-card">
<div class="lm-rc-top">
<div>
<div class="lm-rc-name"><i class="fas fa-user-circle" style="color:var(--blue)"></i> ${r.nama_pelapor}</div>
<div class="lm-rc-time">${r.kontak ? '📞 ' + r.kontak + ' · ' : ''}${r.created_at}</div>
</div>
<span class="lm-status ${r.status}">${r.status}</span>
</div>
<div class="lm-rc-desc">${r.deskripsi_laporan}</div>
${r.lat && r.lat != 0 ? `<div style="font-size:11px;color:var(--muted);margin-bottom:8px"><i class="fas fa-map-pin"></i> ${parseFloat(r.lat).toFixed(5)}, ${parseFloat(r.lng).toFixed(5)}</div>` : ''}
<div class="lm-rc-footer">
<div class="lm-rc-actions">
${canManage && r.status !== 'selesai' ? `<button class="lm-rc-btn selesai-btn" onclick="updateStatusLaporan(${r.id},'selesai')"><i class="fas fa-check"></i> Selesai</button>` : ''}
${canManage && r.status === 'menunggu' ? `<button class="lm-rc-btn" style="background:#FFEDD5;color:#2563eb" onclick="updateStatusLaporan(${r.id},'diproses')"><i class="fas fa-cog"></i> Proses</button>` : ''}
${canManage ? `<button class="lm-rc-btn hapus-btn" onclick="hapusLaporan(${r.id})"><i class="fas fa-trash"></i></button>` : ''}
</div>
</div>
</div>
`).join('');
updateLaporanBadge();
})
.catch(() => {
list.innerHTML = '<div style="text-align:center;padding:20px;color:#ef4444">Gagal memuat laporan</div>';
});
}
function updateStatusLaporan(id, status) {
fetch('api/update_laporan.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, status })
}).then(res => res.json()).then(resp => {
if (resp.status !== 'success') { alert('Gagal update status'); return; }
loadDaftarLaporan();
}).catch(() => alert('Error update status laporan'));
}
function hapusLaporan(id) {
if (!confirm('Hapus laporan ini?')) return;
fetch('api/delete_laporan.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
}).then(res => res.json()).then(resp => {
if (resp.status !== 'success') { alert('Gagal hapus'); return; }
loadDaftarLaporan();
}).catch(() => alert('Error hapus laporan'));
}
function updateLaporanBadge() {
const badge = document.getElementById('laporan-badge');
if (!badge || !IS_LOGGED_IN) return;
fetch('api/read_laporan.php').then(r => r.json()).then(data => {
const menunggu = data.filter(r => r.status === 'menunggu').length;
if (menunggu > 0) {
badge.textContent = menunggu;
badge.style.display = 'flex';
} else {
badge.style.display = 'none';
}
}).catch(() => { });
}
// Load badge saat halaman pertama kali
if (IS_LOGGED_IN) setTimeout(updateLaporanBadge, 1500);
/* ── DATA WARGA MODAL ── */
function openWargaModal(kkId, kkNama) {
const existing = document.getElementById('dw-overlay');
if (existing) existing.remove();
const gisUser = JSON.parse(sessionStorage.getItem('gis_user') || '{}');
const role = gisUser.role || '';
const canManage = role !== 'walikota' && IS_LOGGED_IN;
const overlay = document.createElement('div');
overlay.className = 'hb-overlay';
overlay.id = 'dw-overlay';
overlay.innerHTML = `
<div class="hb-box" style="max-width:700px">
<div class="hb-head">
<div class="hb-head-icon"><i class="fas fa-users-cog"></i></div>
<div style="flex:1;min-width:0">
<div class="hb-head-title">Data Anggota Keluarga</div>
<div style="font-size:11px;opacity:.75;margin-top:1px">${kkNama}</div>
</div>
<button class="hb-head-close" onclick="document.getElementById('dw-overlay').remove()"><i class="fas fa-times"></i></button>
</div>
<div class="hb-body">
${canManage ? `
<div class="hb-form" id="dw-form-tambah" style="display:none;grid-template-columns:1fr 1fr;gap:10px">
<div style="grid-column:1/-1" class="hb-form-title">+ Tambah/Edit Warga</div>
<input type="hidden" id="dw-id" value="">
<div class="km-field">
<label>NIK</label>
<input id="dw-nik" placeholder="Opsional">
</div>
<div class="km-field">
<label>Nama Warga *</label>
<input id="dw-nama" placeholder="Nama Lengkap">
</div>
<div class="km-field">
<label>Hubungan Keluarga *</label>
<select id="dw-hubungan">
<option value="Kepala Keluarga">Kepala Keluarga</option>
<option value="Istri">Istri</option>
<option value="Anak">Anak</option>
<option value="Lainnya">Lainnya</option>
</select>
</div>
<div class="km-field">
<label>Tanggal Lahir</label>
<input type="date" id="dw-tgl">
</div>
<div class="km-field">
<label>Pendidikan</label>
<input id="dw-pend" placeholder="SD/SMP/SMA/dll">
</div>
<div class="km-field">
<label>Riwayat Penyakit</label>
<input id="dw-peny" placeholder="Asma, Jantung, dll">
</div>
<button style="grid-column:1/-1" class="hb-submit" onclick="saveWarga(${kkId})">
<i class="fas fa-save"></i> Simpan Data Warga
</button>
</div>
<button class="pbtn pbtn-y" style="margin-bottom:15px;width:100%" onclick="
var frm=document.getElementById('dw-form-tambah');
frm.style.display='grid';
document.getElementById('dw-id').value='';
document.getElementById('dw-nik').value='';
document.getElementById('dw-nama').value='';
document.getElementById('dw-hubungan').value='Kepala Keluarga';
document.getElementById('dw-tgl').value='';
document.getElementById('dw-pend').value='';
document.getElementById('dw-peny').value='';
">
<i class="fas fa-plus"></i> Tambah Anggota Keluarga
</button>
` : ''}
<div class="hb-list-title">Daftar Anggota Keluarga</div>
<div id="dw-list"><div class="hb-empty"><i class="fas fa-spinner fa-spin"></i> Memuat...</div></div>
</div>
</div>
`;
document.body.appendChild(overlay);
loadWarga(kkId, canManage);
}
function loadWarga(kkId, canManage) {
const list = document.getElementById('dw-list');
fetch('api/read_warga.php?kemiskinan_id=' + kkId)
.then(res => res.json())
.then(resp => {
if (resp.status !== 'success') { list.innerHTML = '<div class="hb-empty">Gagal memuat data</div>'; return; }
if (resp.data.length === 0) {
list.innerHTML = '<div class="hb-empty"><i class="fas fa-users-slash"></i><br>Belum ada data anggota keluarga</div>';
return;
}
let html = '';
resp.data.forEach(d => {
const hbId = d.id;
const editBtn = canManage ? `<button class="pbtn pbtn-y" style="padding:4px 8px;font-size:12px" onclick="editWarga(${hbId})"><i class="fas fa-pen"></i></button>
<button class="pbtn pbtn-r" style="padding:4px 8px;font-size:12px" onclick="deleteWarga(${hbId}, ${kkId})"><i class="fas fa-trash"></i></button>` : '';
html += `<div class="hb-item" id="dw-item-${hbId}" data-nik="${d.nik || ''}" data-nama="${(d.nama_warga || '').replace(/"/g, '&quot;')}" data-hub="${d.hubungan_keluarga || ''}" data-tgl="${d.tanggal_lahir || ''}" data-pend="${(d.pendidikan || '').replace(/"/g, '&quot;')}" data-peny="${(d.riwayat_penyakit || '').replace(/"/g, '&quot;')}" style="display:flex;justify-content:space-between;align-items:center">
<div style="flex:1">
<div style="font-weight:600;color:var(--dark)">${d.nama_warga} <span class="pi-badge poor" style="font-size:10px">${d.hubungan_keluarga || ''}</span></div>
<div style="font-size:12px;color:#64748b;margin-top:4px">
${d.nik ? 'NIK: ' + d.nik + ' &bull; ' : ''}${d.tanggal_lahir ? 'Lahir: ' + d.tanggal_lahir + ' &bull; ' : ''}${d.pendidikan ? 'Pend: ' + d.pendidikan : ''}
</div>
${d.riwayat_penyakit ? '<div style="font-size:12px;color:#dc2626;margin-top:2px"><i class="fas fa-notes-medical"></i> ' + d.riwayat_penyakit + '</div>' : ''}
</div>
${canManage ? '<div style="display:flex;gap:5px;margin-left:10px">' + editBtn + '</div>' : ''}
</div>`;
});
list.innerHTML = html;
})
.catch(() => list.innerHTML = '<div class="hb-empty">Gagal memuat data</div>');
}
function saveWarga(kkId) {
const id = document.getElementById('dw-id').value;
const nik = document.getElementById('dw-nik').value.trim();
const nama = document.getElementById('dw-nama').value.trim();
const hubungan = document.getElementById('dw-hubungan').value;
const tgl = document.getElementById('dw-tgl').value;
const pend = document.getElementById('dw-pend').value.trim();
const peny = document.getElementById('dw-peny').value.trim();
if (!nama || !hubungan) { alert('Nama dan Hubungan Keluarga wajib diisi!'); return; }
const endpoint = id ? 'api/update_warga.php' : 'api/save_warga.php';
const payload = id
? { id, nik, nama_warga: nama, hubungan_keluarga: hubungan, tanggal_lahir: tgl, pendidikan: pend, riwayat_penyakit: peny }
: { kemiskinan_id: kkId, nik, nama_warga: nama, hubungan_keluarga: hubungan, tanggal_lahir: tgl, pendidikan: pend, riwayat_penyakit: peny };
fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
.then(res => res.json()).then(resp => {
if (resp.status !== 'success') { alert('Gagal simpan: ' + (resp.message || 'Error')); return; }
document.getElementById('dw-form-tambah').style.display = 'none';
const gisUser = JSON.parse(sessionStorage.getItem('gis_user') || '{}');
const canManage = (gisUser.role || '') !== 'walikota' && IS_LOGGED_IN;
loadWarga(kkId, canManage);
}).catch(() => alert('Error menyimpan warga'));
}
function editWarga(id) {
const el = document.getElementById('dw-item-' + id);
if (!el) return;
document.getElementById('dw-form-tambah').style.display = 'grid';
document.getElementById('dw-id').value = id;
document.getElementById('dw-nik').value = el.dataset.nik || '';
document.getElementById('dw-nama').value = el.dataset.nama || '';
document.getElementById('dw-hubungan').value = el.dataset.hub || 'Kepala Keluarga';
document.getElementById('dw-tgl').value = el.dataset.tgl || '';
document.getElementById('dw-pend').value = el.dataset.pend || '';
document.getElementById('dw-peny').value = el.dataset.peny || '';
document.getElementById('dw-form-tambah').scrollIntoView({ behavior: 'smooth' });
}
function deleteWarga(id, kkId) {
if (!confirm('Hapus anggota keluarga ini?')) return;
fetch('api/delete_warga.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) })
.then(res => res.json()).then(resp => {
if (resp.status !== 'success') { alert('Gagal hapus'); return; }
const el = document.getElementById('dw-item-' + id);
if (el) el.remove();
const list = document.getElementById('dw-list');
if (list && !list.querySelector('.hb-item')) {
list.innerHTML = '<div class="hb-empty"><i class="fas fa-users-slash"></i><br>Belum ada data anggota keluarga</div>';
}
}).catch(() => alert('Error hapus warga'));
}
/* ── FULL DATA WARGA MODAL ── */
let _allDataWarga = [];
function openDataWargaModal() {
const existing = document.getElementById('ftm-warga-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.className = 'ftm-overlay';
overlay.id = 'ftm-warga-overlay';
overlay.innerHTML = `
<div class="ftm-box">
<div class="ftm-head">
<div>
<div class="ftm-title">Data Warga</div>
<div class="ftm-sub">Daftar seluruh individu yang terdata dalam program bantuan sosial</div>
</div>
<button class="ftm-close" onclick="document.getElementById('ftm-warga-overlay').remove()"><i class="fas fa-times"></i></button>
</div>
<div class="ftm-body">
<div class="ftm-filters">
<select class="ftm-filter-select" id="ftm-filter-kategori" onchange="renderDataWargaTable()">
<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="ftm-filter-select" id="ftm-filter-penyakit" onchange="renderDataWargaTable()">
<option value="">Semua Kondisi</option>
<option value="Sehat">Sehat / Tidak Ada Penyakit</option>
<option value="Sakit">Ada Penyakit</option>
</select>
</div>
<div class="ftm-table-card">
<table>
<thead>
<tr>
<th>Nama Warga</th>
<th>Kepala Keluarga / Alamat</th>
<th>Kategori KK</th>
<th>Usia / Tgl Lahir</th>
<th>Pendidikan</th>
<th>Penyakit</th>
<th>Bantuan</th>
<th>Aksi</th>
</tr>
</thead>
<tbody id="ftm-warga-tbody">
<tr><td colspan="8" style="text-align:center;padding:40px;color:var(--muted)"><i class="fas fa-spinner fa-spin"></i> Memuat data...</td></tr>
</tbody>
</table>
</div>
</div>
</div>`;
document.body.appendChild(overlay);
fetch('api/read_semua_warga.php')
.then(r => r.json())
.then(res => {
if(res.status === 'success') {
_allDataWarga = res.data;
renderDataWargaTable();
} else {
document.getElementById('ftm-warga-tbody').innerHTML = '<tr><td colspan="8" style="text-align:center;color:red">Gagal memuat data</td></tr>';
}
})
.catch(err => {
document.getElementById('ftm-warga-tbody').innerHTML = '<tr><td colspan="8" style="text-align:center;color:red">Terjadi kesalahan jaringan</td></tr>';
});
}
function renderDataWargaTable() {
const tbody = document.getElementById('ftm-warga-tbody');
if(!tbody) return;
const catFilter = document.getElementById('ftm-filter-kategori').value;
const sickFilter = document.getElementById('ftm-filter-penyakit').value;
let filtered = _allDataWarga.filter(d => {
let matchCat = catFilter ? d.kategori_label === catFilter : true;
let isSakit = d.riwayat_penyakit && d.riwayat_penyakit.trim() !== '' && d.riwayat_penyakit.toLowerCase() !== 'tidak ada';
let matchSick = true;
if(sickFilter === 'Sehat') matchSick = !isSakit;
if(sickFilter === 'Sakit') matchSick = isSakit;
return matchCat && matchSick;
});
if(filtered.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:30px;color:var(--muted)">Tidak ada data ditemukan</td></tr>';
return;
}
let html = '';
filtered.forEach(d => {
const isSakit = d.riwayat_penyakit && d.riwayat_penyakit.trim() !== '' && d.riwayat_penyakit.toLowerCase() !== 'tidak ada';
const healthHtml = isSakit
? `<div style="font-size:13px;color:var(--red)">${d.riwayat_penyakit}</div>`
: `<div style="font-size:13px;color:var(--green)"><i class="fas fa-check"></i> Sehat</div>`;
const pend = (d.pendidikan && d.pendidikan.trim() !== '') ? d.pendidikan : '-';
html += `<tr>
<td>
<div style="font-weight:600;color:var(--navy)">${d.nama_warga} <span style="font-size:10px;color:var(--muted);font-weight:400">(${d.hubungan_keluarga || 'Anggota'})</span></div>
<div style="font-size:12px;color:var(--muted);margin-top:2px">${d.nik || 'NIK Tidak Ada'}</div>
</td>
<td>
<div style="font-size:13px;font-weight:600;color:var(--text)">KK: ${d.nama_kk || '-'}</div>
<div style="font-size:12px;color:var(--muted);margin-top:2px;max-width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${d.alamat || '-'}</div>
</td>
<td>
<span class="ftm-badge" style="color:${d.kategori_color};border:1px solid ${d.kategori_color}33;background:${d.kategori_color}11">${d.kategori_label}</span>
</td>
<td>
<div style="font-size:13px;font-weight:600;color:var(--text)">${d.usia_label}</div>
<div style="font-size:12px;color:var(--muted);margin-top:2px">${d.tgl_lahir_format}</div>
</td>
<td style="font-size:13px">${pend}</td>
<td>${healthHtml}</td>
<td>
<div style="font-size:13px;font-weight:600;color:${d.jumlah_bantuan > 0 ? 'var(--green)' : 'var(--muted)'}">${d.jumlah_bantuan}x</div>
</td>
<td>
<button class="ftm-btn-icon" title="Lihat di Peta" onclick="document.getElementById('ftm-warga-overlay').remove(); focusFeature('kemiskinan', ${d.kemiskinan_id})">
<i class="fas fa-map-marked-alt"></i>
</button>
</td>
</tr>`;
});
tbody.innerHTML = html;
}
/* ── FULL DATA MASJID MODAL ── */
let _allDataMasjid = [];
function openDataMasjidModal() {
const existing = document.getElementById('ftm-masjid-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.className = 'ftm-overlay';
overlay.id = 'ftm-masjid-overlay';
overlay.innerHTML = `
<div class="ftm-box">
<div class="ftm-head">
<div>
<div class="ftm-title">Data Rumah Ibadah</div>
<div class="ftm-sub">Daftar fasilitas ibadah beserta area jangkauannya</div>
</div>
<button class="ftm-close" onclick="document.getElementById('ftm-masjid-overlay').remove()"><i class="fas fa-times"></i></button>
</div>
<div class="ftm-body">
<div class="ftm-filters">
<input type="text" id="ftm-search-masjid" class="ftm-filter-select" placeholder="Cari nama atau pengurus..." onkeyup="renderDataMasjidTable()" style="width:250px">
</div>
<div class="ftm-table-card">
<table>
<thead>
<tr>
<th>Nama Fasilitas</th>
<th>Penanggung Jawab</th>
<th>Alamat Lengkap</th>
<th>Radius Jangkauan</th>
<th>Terakhir Diubah</th>
<th>Aksi</th>
</tr>
</thead>
<tbody id="ftm-masjid-tbody">
<tr><td colspan="6" style="text-align:center;padding:40px;color:var(--muted)"><i class="fas fa-spinner fa-spin"></i> Memuat data...</td></tr>
</tbody>
</table>
</div>
</div>
</div>`;
document.body.appendChild(overlay);
fetch('api/read_masjid.php')
.then(r => r.json())
.then(res => {
if(Array.isArray(res)) _allDataMasjid = res;
else if (res.status === 'success' && res.data) _allDataMasjid = res.data;
else _allDataMasjid = [];
renderDataMasjidTable();
})
.catch(err => {
document.getElementById('ftm-masjid-tbody').innerHTML = '<tr><td colspan="6" style="text-align:center;color:red">Terjadi kesalahan jaringan</td></tr>';
});
}
function renderDataMasjidTable() {
const tbody = document.getElementById('ftm-masjid-tbody');
if(!tbody) return;
const searchVal = document.getElementById('ftm-search-masjid').value.toLowerCase();
let filtered = _allDataMasjid.filter(d => {
if(!searchVal) return true;
return (d.nama_masjid && d.nama_masjid.toLowerCase().includes(searchVal)) ||
(d.pic_masjid && d.pic_masjid.toLowerCase().includes(searchVal));
});
if(filtered.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:30px;color:var(--muted)">Tidak ada data ditemukan</td></tr>';
return;
}
let html = '';
filtered.forEach(d => {
const radiusHtml = `<span class="ftm-badge" style="color:#059669;border:1px solid #10b981;background:#D1FAE5">${d.radius || 0} meter</span>`;
let tgl_format = '-';
if(d.updated_at) {
const date = new Date(d.updated_at);
tgl_format = date.toLocaleDateString('id-ID', { year: 'numeric', month: 'short', day: 'numeric' });
}
html += `<tr>
<td>
<div style="font-weight:600;color:var(--navy)">${d.nama_masjid}</div>
<div style="font-size:12px;color:var(--muted);margin-top:2px"><i class="fas fa-place-of-worship" style="font-size:10px;margin-right:4px"></i>Rumah Ibadah</div>
</td>
<td>
<div style="font-size:13px;font-weight:600;color:var(--text)">${d.pic_masjid || '-'}</div>
</td>
<td>
<div style="font-size:13px;color:var(--muted);max-width:250px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${d.alamat_masjid || ''}">${d.alamat_masjid || '-'}</div>
</td>
<td>${radiusHtml}</td>
<td style="font-size:13px;color:var(--muted)">${tgl_format}</td>
<td>
<button class="ftm-btn-icon" title="Lihat di Peta" onclick="document.getElementById('ftm-masjid-overlay').remove(); focusFeature('masjid', ${d.id})">
<i class="fas fa-map-marked-alt"></i>
</button>
</td>
</tr>`;
});
tbody.innerHTML = html;
}
/* ── LOGIN MODAL ── */
function openLoginModal() {
const existing = document.getElementById('lm-overlay');
if (existing) existing.remove();
const el = document.createElement('div');
el.className = 'lm-overlay'; el.id = 'lm-overlay';
el.innerHTML = `
<div class="lm-box" style="position:relative">
<button class="lm-close-btn" onclick="document.getElementById('lm-overlay').remove()"><i class="fas fa-times"></i></button>
<div class="lm-head">
<div class="lm-head-icon"><i class="fas fa-map-location-dot"></i></div>
<div class="lm-head-title">SiPetaSos</div>
<div class="lm-head-sub">Sistem Informasi Peta Sosial &amp; Rumah Ibadah</div>
</div>
<div class="lm-body">
<div id="lm-alert" style="display:none" class="lm-alert error"><i class="fas fa-circle-exclamation"></i><span id="lm-alert-msg"></span></div>
<div class="lm-field">
<label>Username</label>
<div class="lm-input-wrap">
<i class="fas fa-user lm-ico"></i>
<input type="text" id="lm-user" placeholder="Masukkan username" autocomplete="username">
</div>
</div>
<div class="lm-field">
<label>Password</label>
<div class="lm-input-wrap">
<i class="fas fa-lock lm-ico"></i>
<input type="password" id="lm-pass" placeholder="Masukkan password" autocomplete="current-password" onkeydown="if(event.key==='Enter')doLogin()">
<button type="button" class="lm-eye" onclick="lmToggleEye()"><i class="fas fa-eye" id="lm-eye-icon"></i></button>
</div>
</div>
<button class="lm-submit" id="lm-btn" onclick="doLogin()"><i class="fas fa-right-to-bracket"></i>&ensp;Masuk ke Sistem</button>
</div>
</div>`;
document.body.appendChild(el);
el.addEventListener('click', e => { if (e.target === el) el.remove(); });
setTimeout(() => document.getElementById('lm-user')?.focus(), 100);
}
function lmToggleEye() {
const inp = document.getElementById('lm-pass');
const ico = document.getElementById('lm-eye-icon');
if (!inp) return;
inp.type = inp.type === 'password' ? 'text' : 'password';
ico.className = inp.type === 'password' ? 'fas fa-eye' : 'fas fa-eye-slash';
}
function doLogin() {
const username = document.getElementById('lm-user')?.value.trim();
const password = document.getElementById('lm-pass')?.value;
const btn = document.getElementById('lm-btn');
const alert = document.getElementById('lm-alert');
const alertMsg = document.getElementById('lm-alert-msg');
if (!username || !password) {
alert.style.display = 'flex'; alertMsg.textContent = 'Username dan password wajib diisi!'; return;
}
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>&ensp;Memproses...';
fetch('api/login.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
}).then(r => r.json()).then(res => {
if (res.status === 'success') {
sessionStorage.setItem('gis_logged_in', 'true');
sessionStorage.setItem('gis_user', JSON.stringify(res.user));
document.getElementById('lm-overlay')?.remove();
window.location.reload();
} else {
alert.style.display = 'flex'; alertMsg.textContent = res.message || 'Login gagal';
btn.disabled = false; btn.innerHTML = '<i class="fas fa-right-to-bracket"></i>&ensp;Masuk ke Sistem';
}
}).catch(() => {
alert.style.display = 'flex'; alertMsg.textContent = 'Terjadi kesalahan jaringan';
btn.disabled = false; btn.innerHTML = '<i class="fas fa-right-to-bracket"></i>&ensp;Masuk ke Sistem';
});
}
/* ── PROFIL MODAL ── */
function openProfilModal() {
const existing = document.getElementById('prf-overlay');
if (existing) existing.remove();
const gisUser = JSON.parse(sessionStorage.getItem('gis_user') || '{}');
const nama = gisUser.nama_lengkap || 'User';
const username = gisUser.username || '-';
const role = gisUser.role || '-';
const initial = nama.charAt(0).toUpperCase();
const roleLabel = role === 'admin' ? 'Administrator' : role === 'operator' ? 'Operator' : role === 'walikota' ? 'Walikota' : role;
const el = document.createElement('div');
el.className = 'prf-overlay'; el.id = 'prf-overlay';
el.innerHTML = `
<div class="prf-box">
<div class="prf-head" style="position:relative">
<button class="prf-close" onclick="document.getElementById('prf-overlay').remove()"><i class="fas fa-times"></i></button>
<div class="prf-avatar">${initial}</div>
<div class="prf-head-name" id="prf-disp-name">${nama}</div>
<div class="prf-head-role">${roleLabel}</div>
</div>
<div class="prf-body">
<div class="prf-card">
<div class="prf-row">
<span class="prf-label">Nama Lengkap</span>
<div class="prf-edit-row">
<span class="prf-value" id="prf-nama-val">${nama}</span>
<button class="prf-save-btn" onclick="prfToggleEdit()" id="prf-edit-btn" style="background:var(--panel2);color:var(--navy)"><i class="fas fa-pen"></i></button>
</div>
</div>
<div id="prf-edit-form" style="display:none;padding-top:12px">
<input class="prf-input" id="prf-nama-input" value="${nama}" style="width:100%;box-sizing:border-box" placeholder="Nama lengkap baru">
<div style="display:flex;gap:8px;margin-top:8px">
<button class="prf-save-btn" onclick="prfSaveName()"><i class="fas fa-floppy-disk"></i> Simpan</button>
<button class="prf-save-btn" onclick="prfToggleEdit()" style="background:var(--panel2);color:var(--navy)">Batal</button>
</div>
<div id="prf-save-msg" style="display:none;margin-top:8px;font-size:12px;color:var(--green);font-weight:600"><i class="fas fa-check"></i> Tersimpan!</div>
</div>
<div class="prf-row">
<span class="prf-label">Username</span>
<span class="prf-value">${username}</span>
</div>
<div class="prf-row">
<span class="prf-label">Role</span>
<span class="prf-value">${roleLabel}</span>
</div>
</div>
<button onclick="openChangePassword(); document.getElementById('prf-overlay').remove();" style="width:100%;padding:12px;border:1.5px solid var(--border);border-radius:10px;background:#fff;font:600 13px 'Plus Jakarta Sans',sans-serif;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:8px;color:var(--navy)">
<i class="fas fa-key"></i> Ganti Password
</button>
</div>
</div>`;
document.body.appendChild(el);
el.addEventListener('click', e => { if (e.target === el) el.remove(); });
}
function prfToggleEdit() {
const form = document.getElementById('prf-edit-form');
const msg = document.getElementById('prf-save-msg');
if (!form) return;
const isHidden = form.style.display === 'none';
form.style.display = isHidden ? 'block' : 'none';
if (msg) msg.style.display = 'none';
if (isHidden) document.getElementById('prf-nama-input')?.focus();
}
function prfSaveName() {
const input = document.getElementById('prf-nama-input');
const gisUser = JSON.parse(sessionStorage.getItem('gis_user') || '{}');
const nama = input?.value.trim();
if (!nama) return alert('Nama tidak boleh kosong');
fetch('api/update_profile.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: gisUser.username, nama_lengkap: nama })
}).then(r => r.json()).then(res => {
if (res.status === 'success') {
gisUser.nama_lengkap = nama;
sessionStorage.setItem('gis_user', JSON.stringify(gisUser));
const valEl = document.getElementById('prf-nama-val');
const dispName = document.getElementById('prf-disp-name');
if (valEl) valEl.textContent = nama;
if (dispName) dispName.textContent = nama;
const msgEl = document.getElementById('prf-save-msg');
if (msgEl) msgEl.style.display = 'block';
const nameEls = document.querySelectorAll('#dd-nama, .user-name-display');
nameEls.forEach(el => el.textContent = nama);
setTimeout(() => prfToggleEdit(), 1500);
} else { alert(res.message || 'Gagal menyimpan'); }
}).catch(() => alert('Terjadi kesalahan jaringan'));
}
/* ── KELOLA AKUN MODAL ── */
let _uaUsers = [];
let _uaEditId = null;
function openKelolaAkunModal() {
const existing = document.getElementById('ua-overlay');
if (existing) existing.remove();
const el = document.createElement('div');
el.className = 'ftm-overlay'; el.id = 'ua-overlay';
el.innerHTML = `
<div class="ftm-box" style="max-width:960px">
<div class="ftm-head">
<div>
<div class="ftm-title"><i class="fas fa-users-gear" style="color:var(--blue);margin-right:8px"></i>Kelola Akun Pengguna</div>
<div class="ftm-sub">Tambah, edit, dan hapus akun operator sistem</div>
</div>
<button class="ftm-close" onclick="document.getElementById('ua-overlay').remove()"><i class="fas fa-times"></i></button>
</div>
<div class="ftm-body">
<div class="ua-form">
<div class="ua-form-title"><i class="fas fa-user-plus" style="color:var(--blue)"></i><span id="ua-form-label">Tambah Akun Baru</span></div>
<div class="ua-grid">
<div class="ua-field"><label>Nama Lengkap <span style="color:red">*</span></label><input id="ua-nama" placeholder="Nama lengkap..."></div>
<div class="ua-field"><label>Username <span style="color:red">*</span></label><input id="ua-username" placeholder="Username..."></div>
<div class="ua-field"><label>Password <span id="ua-pass-hint" style="color:var(--muted);font-weight:400;text-transform:none">(wajib diisi)</span></label><input type="password" id="ua-pass" placeholder="Min. 6 karakter"></div>
<div class="ua-field"><label>Role</label>
<select id="ua-role">
<option value="operator">Operator</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
<div style="display:flex;gap:8px;align-items:center;margin-top:2px">
<button class="ua-submit" onclick="uaSaveUser()"><i class="fas fa-floppy-disk"></i> <span id="ua-submit-label">Simpan Akun</span></button>
<button id="ua-cancel-btn" onclick="uaCancelEdit()" style="display:none;padding:10px 16px;border:1.5px solid var(--border);border-radius:8px;background:#fff;font:700 13px 'Plus Jakarta Sans',sans-serif;cursor:pointer"><i class="fas fa-xmark"></i> Batal</button>
</div>
<div id="ua-form-alert" style="display:none;margin-top:10px;font-size:13px;font-weight:600;padding:8px 12px;border-radius:8px"></div>
</div>
<div class="ftm-table-card">
<table>
<thead><tr>
<th>Pengguna</th><th>Username</th><th>Role</th><th>Dibuat</th><th>Aksi</th>
</tr></thead>
<tbody id="ua-tbody">
<tr><td colspan="5" style="text-align:center;padding:32px;color:var(--muted)"><i class="fas fa-spinner fa-spin"></i> Memuat...</td></tr>
</tbody>
</table>
</div>
</div>
</div>`;
document.body.appendChild(el);
el.addEventListener('click', e => { if (e.target === el) el.remove(); });
uaLoadUsers();
}
function uaLoadUsers() {
fetch('api/get_users.php').then(r => r.json()).then(data => {
_uaUsers = data;
uaRenderTable();
}).catch(() => {
const tb = document.getElementById('ua-tbody');
if (tb) tb.innerHTML = '<tr><td colspan="5" style="text-align:center;color:red">Gagal memuat data</td></tr>';
});
}
function uaRenderTable() {
const tb = document.getElementById('ua-tbody');
if (!tb) return;
const gisUser = JSON.parse(sessionStorage.getItem('gis_user') || '{}');
const colors = ['#3b82f6','#8b5cf6','#ec4899','#f59e0b','#10b981'];
if (!_uaUsers.length) {
tb.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:24px;color:var(--muted)">Tidak ada pengguna</td></tr>';
return;
}
tb.innerHTML = _uaUsers.map((u, i) => {
const col = colors[i % colors.length];
const initial = (u.nama_lengkap || u.username).charAt(0).toUpperCase();
const roleHtml = u.role === 'admin'
? `<span class="ua-role-badge ua-role-admin"><i class="fas fa-shield-halved"></i> Admin</span>`
: `<span class="ua-role-badge ua-role-operator"><i class="fas fa-user-gear"></i> Operator</span>`;
const tgl = u.created_at ? new Date(u.created_at).toLocaleDateString('id-ID', { year:'numeric', month:'short', day:'numeric' }) : '-';
const isSelf = u.username === gisUser.username;
return `<tr>
<td><div style="display:flex;align-items:center;gap:10px">
<div class="ua-avatar" style="background:${col}">${initial}</div>
<div>
<div style="font-weight:600;color:var(--navy)">${u.nama_lengkap}${isSelf ? ' <span style="font-size:10px;background:#eff6ff;color:var(--blue);padding:1px 6px;border-radius:99px;font-weight:700">Saya</span>' : ''}</div>
</div>
</div></td>
<td style="font-size:13px;color:var(--muted)">${u.username}</td>
<td>${roleHtml}</td>
<td style="font-size:13px;color:var(--muted)">${tgl}</td>
<td><div style="display:flex;gap:6px">
<button onclick="uaEditUser(${u.id})" style="padding:6px 12px;border:1px solid var(--border);border-radius:6px;background:#fff;font-size:12px;font-weight:700;cursor:pointer;color:var(--navy)"><i class="fas fa-pen"></i></button>
${!isSelf && u.id !== 1 ? `<button onclick="uaDeleteUser(${u.id},'${u.nama_lengkap}')" style="padding:6px 12px;border:none;border-radius:6px;background:#fee2e2;font-size:12px;font-weight:700;cursor:pointer;color:#dc2626"><i class="fas fa-trash"></i></button>` : ''}
</div></td>
</tr>`;
}).join('');
}
function uaEditUser(id) {
const u = _uaUsers.find(x => x.id == id);
if (!u) return;
_uaEditId = id;
document.getElementById('ua-nama').value = u.nama_lengkap;
document.getElementById('ua-username').value = u.username;
document.getElementById('ua-username').disabled = true;
document.getElementById('ua-pass').value = '';
document.getElementById('ua-role').value = u.role;
document.getElementById('ua-form-label').textContent = 'Edit Akun';
document.getElementById('ua-submit-label').textContent = 'Update Akun';
document.getElementById('ua-pass-hint').textContent = '(kosongkan jika tidak ubah)';
document.getElementById('ua-cancel-btn').style.display = 'inline-flex';
document.getElementById('ua-nama').focus();
}
function uaCancelEdit() {
_uaEditId = null;
document.getElementById('ua-nama').value = '';
document.getElementById('ua-username').value = '';
document.getElementById('ua-username').disabled = false;
document.getElementById('ua-pass').value = '';
document.getElementById('ua-role').value = 'operator';
document.getElementById('ua-form-label').textContent = 'Tambah Akun Baru';
document.getElementById('ua-submit-label').textContent = 'Simpan Akun';
document.getElementById('ua-pass-hint').textContent = '(wajib diisi)';
document.getElementById('ua-cancel-btn').style.display = 'none';
document.getElementById('ua-form-alert').style.display = 'none';
}
function uaShowAlert(msg, ok) {
const el = document.getElementById('ua-form-alert');
if (!el) return;
el.style.display = 'block';
el.style.background = ok ? '#f0fdf4' : '#fee2e2';
el.style.color = ok ? '#15803d' : '#dc2626';
el.innerHTML = `<i class="fas fa-${ok ? 'check' : 'circle-exclamation'}"></i> ${msg}`;
if (ok) setTimeout(() => { el.style.display = 'none'; }, 2500);
}
function uaSaveUser() {
const nama = document.getElementById('ua-nama')?.value.trim();
const username = document.getElementById('ua-username')?.value.trim();
const pass = document.getElementById('ua-pass')?.value;
const role = document.getElementById('ua-role')?.value;
if (_uaEditId) {
fetch('api/update_user.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: _uaEditId, nama_lengkap: nama, role, password: pass })
}).then(r => r.json()).then(res => {
if (res.status === 'success') {
uaShowAlert('Akun berhasil diperbarui!', true);
uaCancelEdit(); uaLoadUsers();
} else { uaShowAlert(res.message || 'Gagal update', false); }
}).catch(() => uaShowAlert('Terjadi kesalahan jaringan', false));
} else {
fetch('api/create_user.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nama_lengkap: nama, username, password: pass, role })
}).then(r => r.json()).then(res => {
if (res.status === 'success') {
uaShowAlert('Akun berhasil dibuat!', true);
uaCancelEdit(); uaLoadUsers();
} else { uaShowAlert(res.message || 'Gagal membuat akun', false); }
}).catch(() => uaShowAlert('Terjadi kesalahan jaringan', false));
}
}
function uaDeleteUser(id, nama) {
if (!confirm(`Hapus akun "${nama}"? Tindakan ini tidak dapat dibatalkan.`)) return;
fetch('api/delete_user.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
}).then(r => r.json()).then(res => {
if (res.status === 'success') { uaLoadUsers(); }
else { alert(res.message || 'Gagal menghapus'); }
}).catch(() => alert('Terjadi kesalahan jaringan'));
}
/* ── CHANGE PASSWORD MODAL ── */
function openChangePassword() {
// Close user dropdown first
const menu = document.getElementById('user-menu');
if (menu) menu.classList.remove('open');
// Reset form
const form = document.getElementById('cp-form');
if (form) form.reset();
const fill = document.getElementById('cp-strength-fill');
const lbl = document.getElementById('cp-strength-lbl');
if (fill) { fill.style.width='0'; fill.style.background=''; }
if (lbl) lbl.textContent = '';
document.getElementById('cp-err').classList.remove('show');
document.getElementById('cp-ok').classList.remove('show');
const btn = document.getElementById('cp-submit-btn');
btn.innerHTML = '<i class="fas fa-floppy-disk"></i> Simpan Password Baru';
btn.style.background = '';
btn.disabled = false;
document.getElementById('cp-modal').classList.remove('hidden');
}
function closeChangePassword() {
document.getElementById('cp-modal').classList.add('hidden');
}
function cpToggleEye(inputId, iconId) {
const inp = document.getElementById(inputId);
const ico = document.getElementById(iconId);
if (inp.type === 'password') {
inp.type = 'text';
ico.classList.replace('fa-eye','fa-eye-slash');
} else {
inp.type = 'password';
ico.classList.replace('fa-eye-slash','fa-eye');
}
}
function cpCheckStrength(val) {
const fill = document.getElementById('cp-strength-fill');
const lbl = document.getElementById('cp-strength-lbl');
if (!val) { fill.style.width='0'; lbl.textContent=''; return; }
let s = 0;
if (val.length >= 6) s++;
if (val.length >= 10) s++;
if (/[A-Z]/.test(val)) s++;
if (/[0-9]/.test(val)) s++;
if (/[^A-Za-z0-9]/.test(val)) s++;
const lv = [
{pct:'20%',bg:'#ef4444',txt:'Sangat Lemah'},
{pct:'40%',bg:'#f97316',txt:'Lemah'},
{pct:'60%',bg:'#f59e0b',txt:'Sedang'},
{pct:'80%',bg:'#3b82f6',txt:'Kuat'},
{pct:'100%',bg:'#10b981',txt:'Sangat Kuat'},
][Math.max(0,s-1)];
fill.style.width = lv.pct;
fill.style.background = lv.bg;
lbl.textContent = lv.txt;
lbl.style.color = lv.bg;
}
document.getElementById('cp-form').addEventListener('submit', async function(e) {
e.preventDefault();
const gisUser = JSON.parse(sessionStorage.getItem('gis_user') || '{}');
const uname = gisUser.username || '';
const oldP = document.getElementById('cp-old').value;
const newP = document.getElementById('cp-new').value;
const conP = document.getElementById('cp-con').value;
const err = document.getElementById('cp-err');
const errTxt = document.getElementById('cp-err-text');
const ok = document.getElementById('cp-ok');
const btn = document.getElementById('cp-submit-btn');
err.classList.remove('show'); ok.classList.remove('show');
if (!uname) {
errTxt.textContent = 'Sesi tidak ditemukan. Silakan login ulang.';
return err.classList.add('show');
}
if (newP.length < 6) {
errTxt.textContent = 'Password baru minimal 6 karakter.';
return err.classList.add('show');
}
if (newP !== conP) {
errTxt.textContent = 'Konfirmasi password tidak cocok.';
return err.classList.add('show');
}
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-circle-notch fa-spin"></i> Menyimpan...';
try {
const res = await fetch('api/change_password.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ username: uname, old_password: oldP, new_password: newP })
});
const data = await res.json();
if (data.status === 'success') {
ok.classList.add('show');
document.getElementById('cp-form').reset();
document.getElementById('cp-strength-fill').style.width = '0';
document.getElementById('cp-strength-lbl').textContent = '';
btn.innerHTML = '<i class="fas fa-check"></i> Tersimpan!';
btn.style.background = 'linear-gradient(135deg,#059669,#10b981)';
setTimeout(() => {
closeChangePassword();
}, 1800);
} else {
errTxt.textContent = data.message || 'Gagal mengubah password.';
err.classList.add('show');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-floppy-disk"></i> Simpan Password Baru';
btn.style.background = '';
}
} catch {
errTxt.textContent = 'Tidak dapat terhubung ke server.';
err.classList.add('show');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-floppy-disk"></i> Simpan Password Baru';
btn.style.background = '';
}
});
// Close modal on backdrop click
document.getElementById('cp-modal').addEventListener('click', function(e) {
if (e.target === this) closeChangePassword();
});
</script>
<!-- ── CHANGE PASSWORD MODAL ── -->
<div id="cp-modal" class="hidden">
<div class="cp-box">
<div class="cp-head">
<div class="cp-head-left">
<div class="cp-head-icon"><i class="fas fa-key"></i></div>
<div>
<h3>Ganti Sandi</h3>
<p>Perbarui password akun Anda</p>
</div>
</div>
<button class="cp-close" onclick="closeChangePassword()"><i class="fas fa-times"></i></button>
</div>
<div class="cp-body">
<div class="cp-alert err" id="cp-err"><i class="fas fa-circle-exclamation"></i><span id="cp-err-text">Error</span></div>
<div class="cp-alert ok" id="cp-ok"><i class="fas fa-circle-check"></i><span>Password berhasil diubah!</span></div>
<form id="cp-form" novalidate>
<div class="cp-field">
<label>Password Lama</label>
<div class="cp-input-wrap">
<i class="fas fa-lock cp-ico"></i>
<input type="password" id="cp-old" placeholder="Masukkan password lama" required>
<button type="button" class="cp-eye" onclick="cpToggleEye('cp-old','cp-eye0')"><i class="fas fa-eye" id="cp-eye0"></i></button>
</div>
</div>
<div class="cp-field">
<label>Password Baru <span style="color:#94a3b8;font-size:10px;text-transform:none;letter-spacing:0;font-weight:500;">(min. 6 karakter)</span></label>
<div class="cp-input-wrap">
<i class="fas fa-lock cp-ico"></i>
<input type="password" id="cp-new" placeholder="Password baru" required oninput="cpCheckStrength(this.value)">
<button type="button" class="cp-eye" onclick="cpToggleEye('cp-new','cp-eye1')"><i class="fas fa-eye" id="cp-eye1"></i></button>
</div>
<div class="cp-strength"><div class="cp-strength-fill" id="cp-strength-fill"></div></div>
<div class="cp-strength-lbl" id="cp-strength-lbl"></div>
</div>
<div class="cp-field">
<label>Konfirmasi Password Baru</label>
<div class="cp-input-wrap">
<i class="fas fa-lock cp-ico"></i>
<input type="password" id="cp-con" placeholder="Ulangi password baru" required>
<button type="button" class="cp-eye" onclick="cpToggleEye('cp-con','cp-eye2')"><i class="fas fa-eye" id="cp-eye2"></i></button>
</div>
</div>
<button type="submit" class="cp-submit" id="cp-submit-btn">
<i class="fas fa-floppy-disk"></i> Simpan Password Baru
</button>
</form>
<p class="cp-footer"><i class="fas fa-lock"></i> Perubahan berlaku segera setelah disimpan</p>
</div>
</div>
</div>
</body>
</html>