Initial commit
@@ -0,0 +1,51 @@
|
|||||||
|
# dependencies
|
||||||
|
sistem-km-next/node_modules/
|
||||||
|
sistem-km-next/.pnp
|
||||||
|
sistem-km-next/.pnp.*
|
||||||
|
sistem-km-next/.yarn/*
|
||||||
|
!sistem-km-next/.yarn/patches
|
||||||
|
!sistem-km-next/.yarn/plugins
|
||||||
|
!sistem-km-next/.yarn/releases
|
||||||
|
!sistem-km-next/.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
sistem-km-next/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
sistem-km-next/.next/
|
||||||
|
sistem-km-next/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
sistem-km-next/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (protect credentials)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
sistem-km-next/.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
sistem-km-next/.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
sistem-km-next/*.tsbuildinfo
|
||||||
|
sistem-km-next/next-env.d.ts
|
||||||
|
|
||||||
|
# markdown rules from Next.js project
|
||||||
|
sistem-km-next/*.md
|
||||||
|
!sistem-km-next/README.md
|
||||||
|
sistem-km-next/Laporan
|
||||||
|
sistem-km-next/sistem-km-old
|
||||||
|
|
||||||
|
# general databases dumps
|
||||||
|
*.sql
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<base target="_top">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
||||||
|
<title>Peta Choropleth - Leaflet</title>
|
||||||
|
|
||||||
|
<link rel="shortcut icon" type="image/x-icon" href="docs/images/favicon.ico" />
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#map {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
.info {
|
||||||
|
padding: 6px 8px;
|
||||||
|
font: 14px/16px Arial, Helvetica, sans-serif;
|
||||||
|
background: white;
|
||||||
|
background: rgba(255,255,255,0.8);
|
||||||
|
box-shadow: 0 0 15px rgba(0,0,0,0.2);
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.info h4 { margin: 0 0 5px; color: #777; }
|
||||||
|
.legend { text-align: left; line-height: 18px; color: #555; }
|
||||||
|
.legend i { width: 18px; height: 18px; float: left; margin-right: 8px; opacity: 0.7; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id='map'></div>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
|
||||||
|
const map = L.map('map').setView([-0.03, 109.33], 12);
|
||||||
|
|
||||||
|
const tiles = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
maxZoom: 19,
|
||||||
|
attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
// control that shows area info on hover
|
||||||
|
const info = L.control();
|
||||||
|
|
||||||
|
info.onAdd = function (map) {
|
||||||
|
this._div = L.DomUtil.create('div', 'info');
|
||||||
|
this.update();
|
||||||
|
return this._div;
|
||||||
|
};
|
||||||
|
|
||||||
|
info.update = function (props) {
|
||||||
|
const contents = props
|
||||||
|
? `<b>${props.Ket}</b><br />${Number(props.Penduduk).toLocaleString('id-ID')} jiwa`
|
||||||
|
: 'Arahkan kursor ke kecamatan';
|
||||||
|
this._div.innerHTML = `<h4>Penduduk per Kecamatan</h4>${contents}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
info.addTo(map);
|
||||||
|
|
||||||
|
|
||||||
|
// get color depending on population (Penduduk) value
|
||||||
|
// Ranges:
|
||||||
|
// > 140,000
|
||||||
|
// 120,000 - 140,000
|
||||||
|
// 100,000 - 120,000
|
||||||
|
// 60,000 - 100,000
|
||||||
|
// 0 - 60,000
|
||||||
|
function getColor(d) {
|
||||||
|
return d > 140000 ? '#006d2c' :
|
||||||
|
d > 120000 ? '#238b45' :
|
||||||
|
d > 100000 ? '#41ab5d' :
|
||||||
|
d > 60000 ? '#74c476' : '#c7e9c0';
|
||||||
|
}
|
||||||
|
|
||||||
|
function style(feature) {
|
||||||
|
return {
|
||||||
|
weight: 2,
|
||||||
|
opacity: 1,
|
||||||
|
color: 'white',
|
||||||
|
dashArray: '3',
|
||||||
|
fillOpacity: 0.7,
|
||||||
|
fillColor: getColor(Number(feature.properties.Penduduk) || 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightFeature(e) {
|
||||||
|
const layer = e.target;
|
||||||
|
|
||||||
|
layer.setStyle({
|
||||||
|
weight: 5,
|
||||||
|
color: '#666',
|
||||||
|
dashArray: '',
|
||||||
|
fillOpacity: 0.7
|
||||||
|
});
|
||||||
|
|
||||||
|
layer.bringToFront();
|
||||||
|
|
||||||
|
info.update(layer.feature.properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
let geojson;
|
||||||
|
|
||||||
|
function resetHighlight(e) {
|
||||||
|
if (!geojson) return;
|
||||||
|
geojson.resetStyle(e.target);
|
||||||
|
info.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomToFeature(e) {
|
||||||
|
map.fitBounds(e.target.getBounds());
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEachFeature(feature, layer) {
|
||||||
|
layer.on({
|
||||||
|
mouseover: highlightFeature,
|
||||||
|
mouseout: resetHighlight,
|
||||||
|
click: zoomToFeature
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('Admin_Kecamatan.json')
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) throw new Error(`Gagal memuat Admin_Kecamatan.json: ${response.status}`);
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
geojson = L.geoJson(data, {
|
||||||
|
style,
|
||||||
|
onEachFeature
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
map.fitBounds(geojson.getBounds(), { padding: [10, 10] });
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const legend = L.control({position: 'bottomright'});
|
||||||
|
|
||||||
|
legend.onAdd = function (map) {
|
||||||
|
|
||||||
|
const div = L.DomUtil.create('div', 'info legend');
|
||||||
|
div.innerHTML = [
|
||||||
|
`<h4>Jumlah Penduduk</h4>`,
|
||||||
|
`<i style="background:${getColor(1)}"></i> 0–60.000`,
|
||||||
|
`<i style="background:${getColor(60001)}"></i> 60.000–100.000`,
|
||||||
|
`<i style="background:${getColor(100001)}"></i> 100.000–120.000`,
|
||||||
|
`<i style="background:${getColor(120001)}"></i> 120.000–140.000`,
|
||||||
|
`<i style="background:${getColor(140001)}"></i> Lebih dari 140.000`
|
||||||
|
].join('<br>');
|
||||||
|
return div;
|
||||||
|
};
|
||||||
|
|
||||||
|
legend.addTo(map);
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>WebGIS Portfolio Hub | Pontianak Geo-Spatial Projects</title>
|
||||||
|
|
||||||
|
<!-- Google Fonts -->
|
||||||
|
<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@300;400;500;600&family=Outfit:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Modern Reset and Design Tokens */
|
||||||
|
:root {
|
||||||
|
--bg-color: #0b0f19;
|
||||||
|
--card-bg: rgba(17, 24, 39, 0.7);
|
||||||
|
--card-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--card-border-hover: rgba(99, 102, 241, 0.4);
|
||||||
|
|
||||||
|
--text-primary: #f3f4f6;
|
||||||
|
--text-secondary: #9ca3af;
|
||||||
|
|
||||||
|
--primary: #6366f1;
|
||||||
|
--primary-glow: rgba(99, 102, 241, 0.15);
|
||||||
|
--secondary: #10b981;
|
||||||
|
--accent-purple: #a855f7;
|
||||||
|
--accent-orange: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at 10% 20%, rgba(99, 102, 241, 0.1) 0%, transparent 40%),
|
||||||
|
radial-gradient(circle at 90% 80%, rgba(168, 85, 247, 0.1) 0%, transparent 40%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container & Layout */
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 3rem 1.5rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero Section */
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 4rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-top {
|
||||||
|
display: inline-block;
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||||
|
color: #818cf8;
|
||||||
|
padding: 0.35rem 1rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 800;
|
||||||
|
background: linear-gradient(135deg, #ffffff 40%, #c7d2fe 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.15rem;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Project Grid */
|
||||||
|
.project-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
margin-bottom: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glassmorphic Project Cards */
|
||||||
|
.project-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
padding: 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: radial-gradient(circle at top right, var(--primary-glow), transparent 60%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.4s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card:hover {
|
||||||
|
transform: translateY(-6px);
|
||||||
|
border-color: var(--card-border-hover);
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3), 0 0 30px rgba(99, 102, 241, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge and Category inside Cards */
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proj-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-1 { background: rgba(16, 185, 129, 0.1); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.2); }
|
||||||
|
.badge-2 { background: rgba(245, 158, 11, 0.1); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.2); }
|
||||||
|
.badge-3 { background: rgba(168, 85, 247, 0.1); color: #c084fc; border: 1px solid rgba(168, 85, 247, 0.2); }
|
||||||
|
.badge-4 { background: rgba(99, 102, 241, 0.1); color: #818cf8; border: 1px solid rgba(99, 102, 241, 0.2); }
|
||||||
|
|
||||||
|
.card-icon {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card:hover .card-icon {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 0 15px rgba(99, 102, 241, 0.4);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Content */
|
||||||
|
h2 {
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-desc {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.925rem;
|
||||||
|
font-weight: 300;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
height: 4.5rem;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tech Badges List */
|
||||||
|
.tech-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-tag {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action Buttons */
|
||||||
|
.card-action {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-launch {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-launch svg {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card:hover .btn-launch {
|
||||||
|
background: linear-gradient(135deg, var(--primary) 0%, #4f46e5 100%);
|
||||||
|
border-color: transparent;
|
||||||
|
box-shadow: 0 10px 20px rgba(99, 102, 241, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card:hover .btn-launch svg {
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.03);
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer a {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer a:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle responsive adjustments */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
h1 {
|
||||||
|
font-size: 2.25rem;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<!-- Hero Header -->
|
||||||
|
<header>
|
||||||
|
<span class="badge-top">Geographic Information Systems</span>
|
||||||
|
<h1>WebGIS Portfolio Portal</h1>
|
||||||
|
<p class="subtitle">A compilation of spatial visualization mapping applications representing Pontianak, West Kalimantan.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Project Grid -->
|
||||||
|
<main class="project-grid">
|
||||||
|
|
||||||
|
<!-- Card 1: SPBU Marker System -->
|
||||||
|
<article class="project-card">
|
||||||
|
<div>
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="proj-badge badge-1">Project 1</span>
|
||||||
|
<div class="card-icon">
|
||||||
|
<!-- Map Pin Icon -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2>Pontianak SPBU Mapper</h2>
|
||||||
|
<p class="project-desc">Interactive point mapping application for spatial distribution of petrol stations (SPBU) across Pontianak, complete with info window popups and marker listings.</p>
|
||||||
|
<div class="tech-list">
|
||||||
|
<span class="tech-tag">PHP</span>
|
||||||
|
<span class="tech-tag">MySQL</span>
|
||||||
|
<span class="tech-tag">LeafletJS</span>
|
||||||
|
<span class="tech-tag">HTML/CSS</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-action">
|
||||||
|
<a href="./spbu-map/" class="btn-launch">
|
||||||
|
Launch Application
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<!-- Card 2: Road & Land Certificates (Polylines & Polygons) -->
|
||||||
|
<article class="project-card">
|
||||||
|
<div>
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="proj-badge badge-2">Project 2</span>
|
||||||
|
<div class="card-icon">
|
||||||
|
<!-- Layers Icon -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3-10 5L12 13l10-5-10-5Z"/><path d="m2 17 10 5 10-5"/><path d="m2 12 10 5 10-5"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2>Parsil Tanah & Jalan</h2>
|
||||||
|
<p class="project-desc">Mapping portal for road networks (Polylines) and land certificate coordinates (Polygons) featuring automatic length, perimeter, and area calculations.</p>
|
||||||
|
<div class="tech-list">
|
||||||
|
<span class="tech-tag">PHP</span>
|
||||||
|
<span class="tech-tag">MySQL</span>
|
||||||
|
<span class="tech-tag">LeafletJS</span>
|
||||||
|
<span class="tech-tag">HTML/CSS</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-action">
|
||||||
|
<a href="./parsil-tanah/" class="btn-launch">
|
||||||
|
Launch Application
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<!-- Card 3: Chloropleth Map -->
|
||||||
|
<article class="project-card">
|
||||||
|
<div>
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="proj-badge badge-3">Project 3</span>
|
||||||
|
<div class="card-icon">
|
||||||
|
<!-- Pie Chart / Globe Grid -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2>Choropleth Map Portal</h2>
|
||||||
|
<p class="project-desc">A thematic map where geographical subareas of Pontianak are colored or shaded in proportion to demographic data, density, or other statistics.</p>
|
||||||
|
<div class="tech-list">
|
||||||
|
<span class="tech-tag">PHP</span>
|
||||||
|
<span class="tech-tag">MySQL</span>
|
||||||
|
<span class="tech-tag">LeafletJS</span>
|
||||||
|
<span class="tech-tag">Thematic Color</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-action">
|
||||||
|
<a href="./chloropleth/" class="btn-launch">
|
||||||
|
Launch Application
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<!-- Card 4: Sistem KM (NextJS) -->
|
||||||
|
<article class="project-card">
|
||||||
|
<div>
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="proj-badge badge-4">Final Project</span>
|
||||||
|
<div class="card-icon">
|
||||||
|
<!-- Terminal / Code Window -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 17 10 11 4 5"/><path d="M12 19h8"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2>Sistem WebGIS Poverty Map - Sinergi Umat</h2>
|
||||||
|
<p class="project-desc">Flagship Knowledge Management mapping platform with multi-role dashboards, heatmaps, interactive widgets, spatial queries, and authentication.</p>
|
||||||
|
<div class="tech-list">
|
||||||
|
<span class="tech-tag">Next.js 16</span>
|
||||||
|
<span class="tech-tag">React 19</span>
|
||||||
|
<span class="tech-tag">Supabase SSR</span>
|
||||||
|
<span class="tech-tag">Leaflet & Heatmaps</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-action">
|
||||||
|
<a id="nextjs-link" href="./sistem-km-next" class="btn-launch">
|
||||||
|
Launch Application
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer>
|
||||||
|
<p>© 2026 WebGIS Portfolio Portal. Developed for Geographic Information Systems Course.</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- Smart Redirect Script for Next.js Dev Server vs Production Proxy -->
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const nextjsLink = document.getElementById('nextjs-link');
|
||||||
|
// If accessing locally (localhost or .test local domains),
|
||||||
|
// point the Next.js card directly to the running development port (3000)
|
||||||
|
if (
|
||||||
|
window.location.hostname === 'localhost' ||
|
||||||
|
window.location.hostname === '127.0.0.1' ||
|
||||||
|
window.location.hostname.endsWith('.test')
|
||||||
|
) {
|
||||||
|
nextjsLink.href = 'http://localhost:3000/sistem-km-next';
|
||||||
|
} else {
|
||||||
|
// In production/campus server deployment, use the relative subfolder path
|
||||||
|
nextjsLink.href = './sistem-km-next';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
$host = 'localhost';
|
||||||
|
$dbname = 'spbu_db'; // Change if your database name is different
|
||||||
|
$username = 'root'; // Default XAMPP username
|
||||||
|
$password = ''; // Default XAMPP password is empty
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = new PDO("mysql:host=$host;dbname=$dbname;charset=utf8mb4", $username, $password);
|
||||||
|
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
die("Database connection failed: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
require 'db.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Method not allowed.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $_POST['id'] ?? null;
|
||||||
|
if ($id === null) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Missing id.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "DELETE FROM tb_jalan WHERE id = ?";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
|
||||||
|
if ($stmt->execute([(int)$id])) {
|
||||||
|
echo json_encode(['status' => 'success', 'message' => 'Jalan deleted.']);
|
||||||
|
} else {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Failed to delete jalan.']);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
require 'db.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Method not allowed.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $_POST['id'] ?? null;
|
||||||
|
if ($id === null) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Missing id.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "DELETE FROM tb_parsil WHERE id = ?";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
|
||||||
|
if ($stmt->execute([(int)$id])) {
|
||||||
|
echo json_encode(['status' => 'success', 'message' => 'Parsil deleted.']);
|
||||||
|
} else {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Failed to delete parsil.']);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
require 'db.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
if ($_SERVER["REQUEST_METHOD"] == "POST" && isset($_POST['id'])) {
|
||||||
|
$id = $_POST['id'] ?? null;
|
||||||
|
|
||||||
|
if ($id === null) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(["status" => "error", "message" => "Missing id."]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "DELETE FROM SPBU WHERE id = ?";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
|
||||||
|
if ($stmt->execute([$id])) {
|
||||||
|
echo json_encode(["status" => "success", "message" => "Data deleted!"]);
|
||||||
|
} else {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(["status" => "error", "message" => "Failed to delete data."]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(["status" => "error", "message" => "Method not allowed."]);
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
require 'db.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$sql = "SELECT id, nama_jalan, status_jalan, panjang_meter, koordinat FROM tb_jalan ORDER BY id DESC";
|
||||||
|
$stmt = $pdo->query($sql);
|
||||||
|
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
echo json_encode($results);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
require 'db.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$sql = "SELECT id, nama_pemilik, status_kepemilikan, luas_m2, koordinat FROM tb_parsil ORDER BY id DESC";
|
||||||
|
$stmt = $pdo->query($sql);
|
||||||
|
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
echo json_encode($results);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
require 'db.php';
|
||||||
|
|
||||||
|
$sql = "SELECT * FROM SPBU";
|
||||||
|
$stmt = $pdo->query($sql);
|
||||||
|
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode($results);
|
||||||
|
?>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
require 'db.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Method not allowed.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$nama_jalan = $_POST['nama_jalan'] ?? null;
|
||||||
|
$status_jalan = $_POST['status_jalan'] ?? null;
|
||||||
|
$panjang_meter = $_POST['panjang_meter'] ?? null;
|
||||||
|
$koordinat = $_POST['koordinat'] ?? null;
|
||||||
|
|
||||||
|
if ($nama_jalan === null || $status_jalan === null || $panjang_meter === null || $koordinat === null) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Missing required fields.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($koordinat, true);
|
||||||
|
if ($decoded === null) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Invalid koordinat JSON.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "INSERT INTO tb_jalan (nama_jalan, status_jalan, panjang_meter, koordinat) VALUES (?, ?, ?, ?)";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
|
||||||
|
if ($stmt->execute([$nama_jalan, $status_jalan, (float)$panjang_meter, $koordinat])) {
|
||||||
|
echo json_encode(['status' => 'success', 'message' => 'Jalan saved.']);
|
||||||
|
} else {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Failed to save jalan.']);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
require 'db.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Method not allowed.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$nama_pemilik = $_POST['nama_pemilik'] ?? null;
|
||||||
|
$status_kepemilikan = $_POST['status_kepemilikan'] ?? null;
|
||||||
|
$luas_m2 = $_POST['luas_m2'] ?? null;
|
||||||
|
$koordinat = $_POST['koordinat'] ?? null;
|
||||||
|
|
||||||
|
if ($nama_pemilik === null || $status_kepemilikan === null || $luas_m2 === null || $koordinat === null) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Missing required fields.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($koordinat, true);
|
||||||
|
if ($decoded === null) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Invalid koordinat JSON.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "INSERT INTO tb_parsil (nama_pemilik, status_kepemilikan, luas_m2, koordinat) VALUES (?, ?, ?, ?)";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
|
||||||
|
if ($stmt->execute([$nama_pemilik, $status_kepemilikan, (float)$luas_m2, $koordinat])) {
|
||||||
|
echo json_encode(['status' => 'success', 'message' => 'Parsil saved.']);
|
||||||
|
} else {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Failed to save parsil.']);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
require 'db.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
if ($_SERVER["REQUEST_METHOD"] == "POST") {
|
||||||
|
$nama = $_POST['nama'] ?? null;
|
||||||
|
$nomor = $_POST['nomor'] ?? null;
|
||||||
|
$status = $_POST['status'] ?? null;
|
||||||
|
$lat = $_POST['latitude'] ?? null;
|
||||||
|
$lng = $_POST['longitude'] ?? null;
|
||||||
|
|
||||||
|
if ($nama === null || $nomor === null || $status === null || $lat === null || $lng === null) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(["status" => "error", "message" => "Missing required fields."]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "INSERT INTO SPBU (nama, nomor, status, Latitude, Longitude) VALUES (?, ?, ?, ?, ?)";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
|
||||||
|
if ($stmt->execute([$nama, $nomor, $status, $lat, $lng])) {
|
||||||
|
echo json_encode(["status" => "success", "message" => "SPBU Data Saved!"]);
|
||||||
|
} else {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(["status" => "error", "message" => "Failed to save data."]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(["status" => "error", "message" => "Method not allowed."]);
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
require 'db.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Method not allowed.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $_POST['id'] ?? null;
|
||||||
|
$nama_jalan = $_POST['nama_jalan'] ?? null;
|
||||||
|
$status_jalan = $_POST['status_jalan'] ?? null;
|
||||||
|
$panjang_meter = $_POST['panjang_meter'] ?? null;
|
||||||
|
$koordinat = $_POST['koordinat'] ?? null;
|
||||||
|
|
||||||
|
if ($id === null || $nama_jalan === null || $status_jalan === null || $panjang_meter === null || $koordinat === null) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Missing required fields.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($koordinat, true);
|
||||||
|
if ($decoded === null) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Invalid koordinat JSON.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "UPDATE tb_jalan SET nama_jalan = ?, status_jalan = ?, panjang_meter = ?, koordinat = ? WHERE id = ?";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
|
||||||
|
if ($stmt->execute([$nama_jalan, $status_jalan, (float)$panjang_meter, $koordinat, (int)$id])) {
|
||||||
|
echo json_encode(['status' => 'success', 'message' => 'Jalan updated.']);
|
||||||
|
} else {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Failed to update jalan.']);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
require 'db.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Method not allowed.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $_POST['id'] ?? null;
|
||||||
|
$nama_pemilik = $_POST['nama_pemilik'] ?? null;
|
||||||
|
$status_kepemilikan = $_POST['status_kepemilikan'] ?? null;
|
||||||
|
$luas_m2 = $_POST['luas_m2'] ?? null;
|
||||||
|
$koordinat = $_POST['koordinat'] ?? null;
|
||||||
|
|
||||||
|
if ($id === null || $nama_pemilik === null || $status_kepemilikan === null || $luas_m2 === null || $koordinat === null) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Missing required fields.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($koordinat, true);
|
||||||
|
if ($decoded === null) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Invalid koordinat JSON.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "UPDATE tb_parsil SET nama_pemilik = ?, status_kepemilikan = ?, luas_m2 = ?, koordinat = ? WHERE id = ?";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
|
||||||
|
if ($stmt->execute([$nama_pemilik, $status_kepemilikan, (float)$luas_m2, $koordinat, (int)$id])) {
|
||||||
|
echo json_encode(['status' => 'success', 'message' => 'Parsil updated.']);
|
||||||
|
} else {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Failed to update parsil.']);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
require 'db.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
if ($_SERVER["REQUEST_METHOD"] == "POST" && isset($_POST['id'])) {
|
||||||
|
$id = $_POST['id'] ?? null;
|
||||||
|
$nama = $_POST['nama'] ?? null;
|
||||||
|
$nomor = $_POST['nomor'] ?? null;
|
||||||
|
$status = $_POST['status'] ?? null;
|
||||||
|
$lat = $_POST['latitude'] ?? null;
|
||||||
|
$lng = $_POST['longitude'] ?? null;
|
||||||
|
|
||||||
|
if ($id === null || $nama === null || $nomor === null || $status === null) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(["status" => "error", "message" => "Missing required fields."]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$params = [$nama, $nomor, $status];
|
||||||
|
$sql = "UPDATE SPBU SET nama = ?, nomor = ?, status = ?";
|
||||||
|
|
||||||
|
// Optional location update (used for drag-to-move)
|
||||||
|
if ($lat !== null && $lng !== null && is_numeric($lat) && is_numeric($lng)) {
|
||||||
|
$sql .= ", Latitude = ?, Longitude = ?";
|
||||||
|
$params[] = $lat;
|
||||||
|
$params[] = $lng;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= " WHERE id = ?";
|
||||||
|
$params[] = $id;
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
|
||||||
|
if ($stmt->execute($params)) {
|
||||||
|
echo json_encode(["status" => "success", "message" => "Data updated!"]);
|
||||||
|
} else {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(["status" => "error", "message" => "Failed to update data."]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(["status" => "error", "message" => "Method not allowed."]);
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>View Maps</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
<!-- Google Fonts for premium typography -->
|
||||||
|
<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&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
#map { height: 100vh; width: 100vw; }
|
||||||
|
#legend {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 1000;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
width: 280px;
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
#legend h3 {
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
#legend p {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
#legend ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
#legend li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
/* Custom styled indicators for the read-only legend */
|
||||||
|
.legend-indicator {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.indicator-spbu-active { background-color: #10b981; }
|
||||||
|
.indicator-spbu-inactive { background-color: #ef4444; }
|
||||||
|
.indicator-jalan-nas { background-color: #ef4444; }
|
||||||
|
.indicator-jalan-prov { background-color: #10b981; }
|
||||||
|
.indicator-jalan-kab { background-color: #3b82f6; }
|
||||||
|
.indicator-parsil-shm { background-color: #f59e0b; }
|
||||||
|
.indicator-parsil-hgb { background-color: #8b5cf6; }
|
||||||
|
.indicator-parsil-hgu { background-color: #f97316; }
|
||||||
|
.indicator-parsil-hp { background-color: #6b7280; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="map"></div>
|
||||||
|
<div id="legend">
|
||||||
|
<h3>Legend</h3>
|
||||||
|
<p>Halaman ini hanya untuk melihat data peta (read-only).</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<span class="legend-indicator indicator-spbu-active"></span>
|
||||||
|
<span>SPBU Buka 24 Jam</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="legend-indicator indicator-spbu-inactive"></span>
|
||||||
|
<span>SPBU Buka < 24 Jam</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="legend-indicator indicator-jalan-nas"></span>
|
||||||
|
<span>Jalan Nasional</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="legend-indicator indicator-jalan-prov"></span>
|
||||||
|
<span>Jalan Provinsi</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="legend-indicator indicator-jalan-kab"></span>
|
||||||
|
<span>Jalan Kabupaten</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="legend-indicator indicator-parsil-shm"></span>
|
||||||
|
<span>Parsil SHM (Milik)</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="legend-indicator indicator-parsil-hgb"></span>
|
||||||
|
<span>Parsil HGB (Bangunan)</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="legend-indicator indicator-parsil-hgu"></span>
|
||||||
|
<span>Parsil HGU (Usaha)</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="legend-indicator indicator-parsil-hp"></span>
|
||||||
|
<span>Parsil HP (Pakai)</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const map = L.map('map').setView([-0.055, 109.34], 13);
|
||||||
|
|
||||||
|
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
maxZoom: 19,
|
||||||
|
attribution: '© OpenStreetMap'
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
const markersLayer = L.layerGroup().addTo(map);
|
||||||
|
const roadsLayer = L.layerGroup().addTo(map);
|
||||||
|
const parcelsLayer = L.layerGroup().addTo(map);
|
||||||
|
|
||||||
|
const greenIcon = new L.Icon({
|
||||||
|
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-green.png',
|
||||||
|
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png',
|
||||||
|
iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], shadowSize: [41, 41]
|
||||||
|
});
|
||||||
|
const redIcon = new L.Icon({
|
||||||
|
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png',
|
||||||
|
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png',
|
||||||
|
iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], shadowSize: [41, 41]
|
||||||
|
});
|
||||||
|
|
||||||
|
function roadColor(status) {
|
||||||
|
if (status === 'Jalan Nasional') return '#FF0000';
|
||||||
|
if (status === 'Jalan Provinsi') return '#00FF00';
|
||||||
|
if (status === 'Jalan Kabupaten') return '#0000FF';
|
||||||
|
return '#666666';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parcelFillColor(status) {
|
||||||
|
if (status === 'SHM') return '#FFD700';
|
||||||
|
if (status === 'HGB') return '#800080';
|
||||||
|
if (status === 'HGU') return '#FFA500';
|
||||||
|
if (status === 'HP') return '#808080';
|
||||||
|
return '#666666';
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshMarkers() {
|
||||||
|
return fetch('get_spbu.php')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(rows => {
|
||||||
|
markersLayer.clearLayers();
|
||||||
|
rows.forEach(spbu => {
|
||||||
|
const markerIcon = (spbu.status === 'Buka 24 Jam') ? greenIcon : redIcon;
|
||||||
|
const lat = spbu.Latitude;
|
||||||
|
const lng = spbu.Longitude;
|
||||||
|
|
||||||
|
const marker = L.marker([lat, lng], { icon: markerIcon });
|
||||||
|
marker.bindPopup(`
|
||||||
|
<b>${spbu.nama}</b><br>
|
||||||
|
Nomor: ${spbu.nomor}<br>
|
||||||
|
Buka: ${spbu.status}
|
||||||
|
`);
|
||||||
|
marker.addTo(markersLayer);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(err => console.error('Error fetching spbu:', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshRoads() {
|
||||||
|
return fetch('get_jalan.php')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(rows => {
|
||||||
|
roadsLayer.clearLayers();
|
||||||
|
rows.forEach(row => {
|
||||||
|
let geom;
|
||||||
|
try { geom = JSON.parse(row.koordinat); } catch { return; }
|
||||||
|
|
||||||
|
const gj = L.geoJSON(geom, {
|
||||||
|
style: { color: roadColor(row.status_jalan), weight: 4 }
|
||||||
|
});
|
||||||
|
gj.eachLayer(l => {
|
||||||
|
l.bindPopup(`
|
||||||
|
<b>${row.nama_jalan}</b><br>
|
||||||
|
Status: ${row.status_jalan}<br>
|
||||||
|
Panjang (m): ${Number(row.panjang_meter || 0).toFixed(2)}
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
gj.addTo(roadsLayer);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(err => console.error('Error fetching jalan:', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshParcels() {
|
||||||
|
return fetch('get_parsil.php')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(rows => {
|
||||||
|
parcelsLayer.clearLayers();
|
||||||
|
rows.forEach(row => {
|
||||||
|
let geom;
|
||||||
|
try { geom = JSON.parse(row.koordinat); } catch { return; }
|
||||||
|
|
||||||
|
const gj = L.geoJSON(geom, {
|
||||||
|
style: { color: '#333333', weight: 2, fillColor: parcelFillColor(row.status_kepemilikan), fillOpacity: 0.45 }
|
||||||
|
});
|
||||||
|
gj.eachLayer(l => {
|
||||||
|
l.bindPopup(`
|
||||||
|
<b>${row.nama_pemilik}</b><br>
|
||||||
|
Status: ${row.status_kepemilikan}<br>
|
||||||
|
Luas (m²): ${Number(row.luas_m2 || 0).toFixed(2)}
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
gj.addTo(parcelsLayer);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(err => console.error('Error fetching parsil:', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.all([refreshMarkers(), refreshRoads(), refreshParcels()]);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# markdown
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
/Laporan
|
||||||
|
/sistem-km-old
|
||||||
|
*.sql
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.js`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
basePath: '/sistem-km-next',
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "sistem-km-next",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/ssr": "^0.10.3",
|
||||||
|
"@supabase/supabase-js": "^2.106.0",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"leaflet.heat": "^0.2.0",
|
||||||
|
"next": "16.2.6",
|
||||||
|
"react": "19.2.4",
|
||||||
|
"react-chartjs-2": "^5.3.1",
|
||||||
|
"react-dom": "19.2.4",
|
||||||
|
"react-leaflet": "^5.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.2.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 689 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,104 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
let code = fs.readFileSync('src/components/Dashboard.js', 'utf8');
|
||||||
|
|
||||||
|
const imports = `import SidebarControls from './dashboard/SidebarControls';
|
||||||
|
import StatsPanel from './dashboard/StatsPanel';
|
||||||
|
import ApprovalQueueModal from './dashboard/modals/ApprovalQueueModal';
|
||||||
|
import UserProvisioningModal from './dashboard/modals/UserProvisioningModal';
|
||||||
|
import DonationModal from './dashboard/modals/DonationModal';
|
||||||
|
import HubFormModal from './dashboard/modals/HubFormModal';
|
||||||
|
import HouseholdFormModal from './dashboard/modals/HouseholdFormModal';
|
||||||
|
`;
|
||||||
|
|
||||||
|
code = code.replace("import { useRouter } from 'next/navigation';", "import { useRouter } from 'next/navigation';\n" + imports);
|
||||||
|
|
||||||
|
const leftPanelStart = code.indexOf('{/* Left Panel (Controls) */}');
|
||||||
|
const leftPanelEnd = code.indexOf('{/* Right Panel (Stats) */}');
|
||||||
|
|
||||||
|
if (leftPanelStart > -1 && leftPanelEnd > -1) {
|
||||||
|
const newLeftPanel = `{/* Left Panel (Controls) */}
|
||||||
|
<div className={\`left-panel glass-panel \${showControls ? '' : 'hidden'}\`} id="controlsPanel">
|
||||||
|
<div className="panel-title">
|
||||||
|
<span className="title-text"><i className="fas fa-sliders-h"></i> Controls</span>
|
||||||
|
<button className="btn-toggle-panel" onClick={() => setShowControls(false)} title="Hide Controls">
|
||||||
|
<i className="fas fa-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SidebarControls
|
||||||
|
user={user} profile={profile} router={router}
|
||||||
|
isEditMode={isEditMode} setIsEditMode={setIsEditMode}
|
||||||
|
setShowApprovalModal={setShowApprovalModal} setShowUserModal={setShowUserModal}
|
||||||
|
fetchUsers={fetchUsers} setPlacementMode={setPlacementMode}
|
||||||
|
setShowToast={setShowToast} filters={filters} setFilters={setFilters}
|
||||||
|
hubStyles={hubStyles}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
code = code.substring(0, leftPanelStart) + newLeftPanel + code.substring(leftPanelEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rightPanelStart = code.indexOf('{/* Right Panel (Stats) */}');
|
||||||
|
const rightPanelEnd = code.indexOf('{/* Add Location Floating Button */}');
|
||||||
|
|
||||||
|
if (rightPanelStart > -1 && rightPanelEnd > -1) {
|
||||||
|
const newRightPanel = `{/* Right Panel (Stats) */}
|
||||||
|
<StatsPanel
|
||||||
|
profile={profile} showStats={showStats}
|
||||||
|
setShowStats={setShowStats} stats={stats}
|
||||||
|
/>
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
code = code.substring(0, rightPanelStart) + newRightPanel + code.substring(rightPanelEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalStartIndex = code.indexOf('{/* Hub Modal */}');
|
||||||
|
|
||||||
|
if (modalStartIndex > -1) {
|
||||||
|
const newModals = `
|
||||||
|
<HubFormModal
|
||||||
|
showHubModal={showHubModal} setShowHubModal={setShowHubModal}
|
||||||
|
hubFormData={hubFormData} setHubFormData={setHubFormData}
|
||||||
|
handleSaveHub={handleSaveHub} isEditMode={isEditMode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HouseholdFormModal
|
||||||
|
showHhModal={showHhModal} setShowHhModal={setShowHhModal}
|
||||||
|
hhFormData={hhFormData} setHhFormData={setHhFormData}
|
||||||
|
handleSaveHh={handleSaveHh} hubs={hubs}
|
||||||
|
imageFile={imageFile} setImageFile={setImageFile}
|
||||||
|
isUploading={isUploading} isEditMode={isEditMode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DonationModal
|
||||||
|
showDonationModal={showDonationModal} setShowDonationModal={setShowDonationModal}
|
||||||
|
donationFormData={donationFormData} setDonationFormData={setDonationFormData}
|
||||||
|
hubs={hubs} isSubmittingDonation={isSubmittingDonation}
|
||||||
|
handleSaveDonation={handleSaveDonation} donationsList={donationsList}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ApprovalQueueModal
|
||||||
|
showApprovalModal={showApprovalModal} setShowApprovalModal={setShowApprovalModal}
|
||||||
|
households={households} selectedPhotoUrl={selectedPhotoUrl}
|
||||||
|
setSelectedPhotoUrl={setSelectedPhotoUrl} handleVerifyAction={handleVerifyAction}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UserProvisioningModal
|
||||||
|
showUserModal={showUserModal} setShowUserModal={setShowUserModal}
|
||||||
|
usersList={usersList} profile={profile}
|
||||||
|
userFormData={userFormData} setUserFormData={setUserFormData}
|
||||||
|
handleCreateUser={handleCreateUser} isSubmittingUser={isSubmittingUser}
|
||||||
|
handleDeleteUser={handleDeleteUser}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
code = code.substring(0, modalStartIndex) + newModals;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync('src/components/Dashboard.js', code);
|
||||||
|
console.log('Dashboard.js successfully refactored');
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
let code = fs.readFileSync('src/components/Dashboard.js', 'utf8');
|
||||||
|
|
||||||
|
// 1. ADD MISSING STATE AND FUNCTIONS FOR USER PROVISIONING
|
||||||
|
const stateIndex = code.indexOf('const [isEditMode, setIsEditMode] = useState(false);');
|
||||||
|
if (stateIndex > -1) {
|
||||||
|
const userStates = `
|
||||||
|
const [showUserModal, setShowUserModal] = useState(false);
|
||||||
|
const [usersList, setUsersList] = useState([]);
|
||||||
|
const [userFormData, setUserFormData] = useState({
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
full_name: '',
|
||||||
|
role: 'pic'
|
||||||
|
});
|
||||||
|
const [isSubmittingUser, setIsSubmittingUser] = useState(false);
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/users');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setUsersList(data.users || []);
|
||||||
|
}
|
||||||
|
} catch (e) { console.error('Error fetching users:', e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateUser = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsSubmittingUser(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(userFormData)
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
alert('User created successfully');
|
||||||
|
fetchUsers();
|
||||||
|
setUserFormData({ email: '', password: '', full_name: '', role: 'pic' });
|
||||||
|
setShowUserModal(false);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
alert('Failed to create user: ' + data.error);
|
||||||
|
}
|
||||||
|
} catch (e) { alert('Error: ' + e.message); }
|
||||||
|
setIsSubmittingUser(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = async (id) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this user?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(\`/api/users?id=\${id}\`, { method: 'DELETE' });
|
||||||
|
if (res.ok) fetchUsers();
|
||||||
|
else alert('Failed to delete user');
|
||||||
|
} catch (e) { alert('Error deleting user'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
`;
|
||||||
|
code = code.substring(0, stateIndex) + userStates + code.substring(stateIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. REMOVE isPointInPolygon AND getKecamatanForPoint
|
||||||
|
code = code.replace(/const isPointInPolygon = [\s\S]*?return null;\n};\n/, '');
|
||||||
|
|
||||||
|
// 3. FIX PostGIS USAGES (remove getKecamatanForPoint calls)
|
||||||
|
code = code.replace(/const calculatedKec = getKecamatanForPoint[^;]+;/g, '');
|
||||||
|
code = code.replace(/hh\.sub_district \|\| calculatedKec \|\| subDistrictsList\[idx % subDistrictsList\.length\]/g, "hh.sub_district || 'Belum Terpetakan'");
|
||||||
|
code = code.replace(/hh\.sub_district = calculatedKec \|\| hh\.sub_district \|\| subDistrictsList\[idx % subDistrictsList\.length\];/g, "hh.sub_district = hh.sub_district || 'Belum Terpetakan';");
|
||||||
|
|
||||||
|
// 4. ADD MODULAR IMPORTS
|
||||||
|
const imports = `import SidebarControls from './dashboard/SidebarControls';
|
||||||
|
import StatsPanel from './dashboard/StatsPanel';
|
||||||
|
import ApprovalQueueModal from './dashboard/modals/ApprovalQueueModal';
|
||||||
|
import UserProvisioningModal from './dashboard/modals/UserProvisioningModal';
|
||||||
|
import DonationModal from './dashboard/modals/DonationModal';
|
||||||
|
import HubFormModal from './dashboard/modals/HubFormModal';
|
||||||
|
import HouseholdFormModal from './dashboard/modals/HouseholdFormModal';
|
||||||
|
`;
|
||||||
|
code = code.replace("import { useRouter } from 'next/navigation';", "import { useRouter } from 'next/navigation';\n" + imports);
|
||||||
|
|
||||||
|
// 5. REPLACE LEFT PANEL
|
||||||
|
const leftPanelStart = code.indexOf('{/* Left Panel (Controls) */}');
|
||||||
|
const leftPanelEnd = code.indexOf('{/* Right Panel (Stats) */}');
|
||||||
|
if (leftPanelStart > -1 && leftPanelEnd > -1) {
|
||||||
|
const newLeftPanel = `{/* Left Panel (Controls) */}
|
||||||
|
<div className={\`left-panel glass-panel \${showControls ? '' : 'hidden'}\`} id="controlsPanel">
|
||||||
|
<div className="panel-title">
|
||||||
|
<span className="title-text"><i className="fas fa-sliders-h"></i> Controls</span>
|
||||||
|
<button className="btn-toggle-panel" onClick={() => setShowControls(false)} title="Hide Controls">
|
||||||
|
<i className="fas fa-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SidebarControls
|
||||||
|
user={user} profile={profile} router={router}
|
||||||
|
isEditMode={isEditMode} setIsEditMode={setIsEditMode}
|
||||||
|
setShowApprovalModal={setShowApprovalModal} setShowUserModal={setShowUserModal}
|
||||||
|
fetchUsers={fetchUsers} setPlacementMode={setPlacementMode}
|
||||||
|
setShowToast={setShowToast} filters={filters} setFilters={setFilters}
|
||||||
|
hubStyles={hubStyles}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
`;
|
||||||
|
code = code.substring(0, leftPanelStart) + newLeftPanel + code.substring(leftPanelEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. REPLACE RIGHT PANEL
|
||||||
|
const rightPanelStart = code.indexOf('{/* Right Panel (Stats) */}');
|
||||||
|
const rightPanelEnd = code.indexOf('{/* Add Location Floating Button */}');
|
||||||
|
if (rightPanelStart > -1 && rightPanelEnd > -1) {
|
||||||
|
const newRightPanel = `{/* Right Panel (Stats) */}
|
||||||
|
<StatsPanel
|
||||||
|
profile={profile} showStats={showStats}
|
||||||
|
setShowStats={setShowStats} stats={stats}
|
||||||
|
/>
|
||||||
|
|
||||||
|
`;
|
||||||
|
code = code.substring(0, rightPanelStart) + newRightPanel + code.substring(rightPanelEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. REPLACE MODALS
|
||||||
|
const modalStartIndex = code.indexOf('{/* Hub Modal */}');
|
||||||
|
if (modalStartIndex > -1) {
|
||||||
|
const newModals = `{/* Hub Modal */}
|
||||||
|
<HubFormModal
|
||||||
|
showHubModal={showHubModal} setShowHubModal={setShowHubModal}
|
||||||
|
hubFormData={hubFormData} setHubFormData={setHubFormData}
|
||||||
|
handleSaveHub={handleSaveHub} isEditMode={isEditMode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HouseholdFormModal
|
||||||
|
showHhModal={showHhModal} setShowHhModal={setShowHhModal}
|
||||||
|
hhFormData={hhFormData} setHhFormData={setHhFormData}
|
||||||
|
handleSaveHh={handleSaveHh} hubs={hubs}
|
||||||
|
imageFile={imageFile} setImageFile={setImageFile}
|
||||||
|
isUploading={isUploading} isEditMode={isEditMode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DonationModal
|
||||||
|
showDonationModal={showDonationModal} setShowDonationModal={setShowDonationModal}
|
||||||
|
donationFormData={donationFormData} setDonationFormData={setDonationFormData}
|
||||||
|
hubs={hubs} isSubmittingDonation={isSubmittingDonation}
|
||||||
|
handleSaveDonation={handleSaveDonation} donationsList={donationsList}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ApprovalQueueModal
|
||||||
|
showApprovalModal={showApprovalModal} setShowApprovalModal={setShowApprovalModal}
|
||||||
|
households={households} selectedPhotoUrl={selectedPhotoUrl}
|
||||||
|
setSelectedPhotoUrl={setSelectedPhotoUrl} handleVerifyAction={handleVerifyAction}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UserProvisioningModal
|
||||||
|
showUserModal={showUserModal} setShowUserModal={setShowUserModal}
|
||||||
|
usersList={usersList} profile={profile}
|
||||||
|
userFormData={userFormData} setUserFormData={setUserFormData}
|
||||||
|
handleCreateUser={handleCreateUser} isSubmittingUser={isSubmittingUser}
|
||||||
|
handleDeleteUser={handleDeleteUser}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}`;
|
||||||
|
code = code.substring(0, modalStartIndex) + newModals;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync('src/components/Dashboard.js', code);
|
||||||
|
console.log('Dashboard.js completely restored and refactored!');
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const { createClient } = require('@supabase/supabase-js');
|
||||||
|
|
||||||
|
// Parse .env.local
|
||||||
|
const envFile = fs.readFileSync('.env.local', 'utf8');
|
||||||
|
const envVars = {};
|
||||||
|
envFile.split('\n').forEach(line => {
|
||||||
|
const match = line.match(/^([^=]+)=(.*)$/);
|
||||||
|
if (match) envVars[match[1]] = match[2];
|
||||||
|
});
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const supabaseUrl = envVars.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseKey = envVars.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
|
||||||
|
if (!supabaseUrl || !supabaseKey) {
|
||||||
|
console.error('Missing Supabase credentials in .env.local');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||||
|
|
||||||
|
// 1. Upload Sub-districts
|
||||||
|
console.log('Reading GeoJSON...');
|
||||||
|
const geojsonRaw = fs.readFileSync('src/lib/Admin_Kecamatan.json', 'utf8');
|
||||||
|
const geojson = JSON.parse(geojsonRaw);
|
||||||
|
|
||||||
|
for (const feature of geojson.features) {
|
||||||
|
const name = feature.properties?.Ket;
|
||||||
|
if (!name) continue;
|
||||||
|
|
||||||
|
let geom = feature.geometry;
|
||||||
|
if (geom.type === 'Polygon') {
|
||||||
|
geom = {
|
||||||
|
type: 'MultiPolygon',
|
||||||
|
coordinates: [geom.coordinates]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Uploading ${name}...`);
|
||||||
|
// Supabase auto-casts GeoJSON objects to PostGIS geometry during insert
|
||||||
|
const { error } = await supabase.from('sub_districts').upsert(
|
||||||
|
{ name: name, geom: geom },
|
||||||
|
{ onConflict: 'name' }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error(`Failed to upload ${name}:`, error.message);
|
||||||
|
} else {
|
||||||
|
console.log(`Success: ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Trigger updates on existing households to populate the sub_district column
|
||||||
|
console.log('Triggering updates on existing households to populate sub_district...');
|
||||||
|
const { data: households, error: fetchErr } = await supabase.from('households').select('id, location');
|
||||||
|
|
||||||
|
if (fetchErr) {
|
||||||
|
console.error('Error fetching households:', fetchErr);
|
||||||
|
} else if (households && households.length > 0) {
|
||||||
|
console.log(`Found ${households.length} households. Updating...`);
|
||||||
|
for (const hh of households) {
|
||||||
|
// Touching the row will fire the trigger
|
||||||
|
const { error: upErr } = await supabase.from('households').update({ location: hh.location }).eq('id', hh.id);
|
||||||
|
if (upErr) console.error(`Error updating HH ${hh.id}:`, upErr.message);
|
||||||
|
}
|
||||||
|
console.log('Completed household updates.');
|
||||||
|
} else {
|
||||||
|
console.log('No households found to update.');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Migration complete.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function DELETE(request) {
|
||||||
|
try {
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
|
||||||
|
if (!supabaseServiceKey) {
|
||||||
|
return NextResponse.json({ error: 'Service role key not configured' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey);
|
||||||
|
|
||||||
|
// Get Hub ID
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const id = searchParams.get('id');
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: 'Missing hub id' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Fetch Hub to identify connected users (pic_id & head_id)
|
||||||
|
const { data: hub, error: fetchErr } = await supabaseAdmin
|
||||||
|
.from('hubs')
|
||||||
|
.select('pic_id, head_id')
|
||||||
|
.eq('id', id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (fetchErr) {
|
||||||
|
console.error(`Failed to fetch hub ${id}:`, fetchErr);
|
||||||
|
return NextResponse.json({ error: 'Hub not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Delete connected accounts if they exist
|
||||||
|
if (hub.pic_id) {
|
||||||
|
try {
|
||||||
|
const { error: delPicErr } = await supabaseAdmin.auth.admin.deleteUser(hub.pic_id);
|
||||||
|
if (delPicErr) console.error(`Error deleting PIC user ${hub.pic_id} from Auth:`, delPicErr);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to delete PIC user ${hub.pic_id}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hub.head_id) {
|
||||||
|
try {
|
||||||
|
const { error: delHeadErr } = await supabaseAdmin.auth.admin.deleteUser(hub.head_id);
|
||||||
|
if (delHeadErr) console.error(`Error deleting Head user ${hub.head_id} from Auth:`, delHeadErr);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to delete Head user ${hub.head_id}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Delete the Hub (cascades donations, sets household assignments to NULL)
|
||||||
|
const { error: deleteErr } = await supabaseAdmin
|
||||||
|
.from('hubs')
|
||||||
|
.delete()
|
||||||
|
.eq('id', id);
|
||||||
|
|
||||||
|
if (deleteErr) throw deleteErr;
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Hubs API DELETE Error:', error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import kecamatanData from '@/lib/Admin_Kecamatan.json';
|
||||||
|
|
||||||
|
const isPointInPolygon = (point, polygon) => {
|
||||||
|
const x = point[0], y = point[1];
|
||||||
|
let inside = false;
|
||||||
|
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||||
|
const xi = polygon[i][0], yi = polygon[i][1];
|
||||||
|
const xj = polygon[j][0], yj = polygon[j][1];
|
||||||
|
const intersect = ((yi > y) !== (yj > y))
|
||||||
|
&& (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
|
||||||
|
if (intersect) inside = !inside;
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getKecamatanForPoint = (lat, lng) => {
|
||||||
|
if (!kecamatanData || !kecamatanData.features) return null;
|
||||||
|
for (const feature of kecamatanData.features) {
|
||||||
|
const name = feature.properties?.Ket;
|
||||||
|
const geom = feature.geometry;
|
||||||
|
if (!name || !geom) continue;
|
||||||
|
|
||||||
|
if (geom.type === 'Polygon') {
|
||||||
|
if (isPointInPolygon([lng, lat], geom.coordinates[0])) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
} else if (geom.type === 'MultiPolygon') {
|
||||||
|
for (const polygonCoords of geom.coordinates) {
|
||||||
|
if (isPointInPolygon([lng, lat], polygonCoords[0])) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseLocation = (loc) => {
|
||||||
|
if (!loc) return { lat: 0, lng: 0 };
|
||||||
|
if (typeof loc === 'string') {
|
||||||
|
const match = loc.match(/POINT\(([^ ]+) ([^ ]+)\)/);
|
||||||
|
if (match) return { lng: parseFloat(match[1]), lat: parseFloat(match[2]) };
|
||||||
|
} else if (loc.coordinates) {
|
||||||
|
return { lng: loc.coordinates[0], lat: loc.coordinates[1] };
|
||||||
|
}
|
||||||
|
return { lat: 0, lng: 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate distance in meters between two points (Haversine formula approximation equivalent to Leaflet's distance)
|
||||||
|
const distanceTo = (lat1, lon1, lat2, lon2) => {
|
||||||
|
const R = 6371e3; // metres
|
||||||
|
const φ1 = lat1 * Math.PI/180; // φ, λ in radians
|
||||||
|
const φ2 = lat2 * Math.PI/180;
|
||||||
|
const Δφ = (lat2-lat1) * Math.PI/180;
|
||||||
|
const Δλ = (lon2-lon1) * Math.PI/180;
|
||||||
|
|
||||||
|
const a = Math.sin(Δφ/2) * Math.sin(Δφ/2) +
|
||||||
|
Math.cos(φ1) * Math.cos(φ2) *
|
||||||
|
Math.sin(Δλ/2) * Math.sin(Δλ/2);
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
||||||
|
|
||||||
|
return R * c;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
|
||||||
|
if (!supabaseServiceKey) {
|
||||||
|
return NextResponse.json({ error: 'Service role key not configured' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey);
|
||||||
|
|
||||||
|
// Fetch all hubs and households using admin privileges
|
||||||
|
const { data: hubsData, error: hubsErr } = await supabaseAdmin.from('hubs').select('*');
|
||||||
|
const { data: hhData, error: hhErr } = await supabaseAdmin.from('households').select('*');
|
||||||
|
|
||||||
|
if (hubsErr) throw hubsErr;
|
||||||
|
if (hhErr) throw hhErr;
|
||||||
|
|
||||||
|
const parsedHubs = (hubsData || []).map(hub => {
|
||||||
|
const { lat, lng } = parseLocation(hub.location);
|
||||||
|
return { ...hub, lat, lng, radius: hub.radius_meter };
|
||||||
|
});
|
||||||
|
|
||||||
|
let coveredCount = 0;
|
||||||
|
const typeCounts = {};
|
||||||
|
const subDistrictsMap = {};
|
||||||
|
const subDistrictsList = ['Pontianak Barat', 'Pontianak Kota', 'Pontianak Selatan', 'Pontianak Tenggara', 'Pontianak Timur', 'Pontianak Utara'];
|
||||||
|
|
||||||
|
subDistrictsList.forEach(sub => {
|
||||||
|
subDistrictsMap[sub] = { covered: 0, uncovered: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
(hhData || []).forEach((hh, idx) => {
|
||||||
|
const { lat, lng } = parseLocation(hh.location);
|
||||||
|
|
||||||
|
let closestHub = null;
|
||||||
|
let minDistance = Infinity;
|
||||||
|
|
||||||
|
parsedHubs.forEach(hub => {
|
||||||
|
const dist = distanceTo(lat, lng, hub.lat, hub.lng);
|
||||||
|
const hubRadius = parseInt(hub.radius) || 500;
|
||||||
|
if (dist <= hubRadius && dist < minDistance) {
|
||||||
|
minDistance = dist;
|
||||||
|
closestHub = hub;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const covered = !!closestHub;
|
||||||
|
if (covered) {
|
||||||
|
coveredCount++;
|
||||||
|
const type = closestHub.type;
|
||||||
|
typeCounts[type] = (typeCounts[type] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sub-district logic (now provided by PostGIS database trigger with dynamic backend JS fallback!)
|
||||||
|
let sub = hh.sub_district;
|
||||||
|
if (!sub || sub === 'Belum Terpetakan') {
|
||||||
|
const coords = parseLocation(hh.location);
|
||||||
|
sub = getKecamatanForPoint(coords.lat, coords.lng) || 'Belum Terpetakan';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subDistrictsMap[sub]) subDistrictsMap[sub] = { covered: 0, uncovered: 0 };
|
||||||
|
|
||||||
|
if (covered) subDistrictsMap[sub].covered++;
|
||||||
|
else subDistrictsMap[sub].uncovered++;
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = hhData ? hhData.length : 0;
|
||||||
|
const uncoveredCount = total - coveredCount;
|
||||||
|
const equalityIndex = total > 0 ? Math.round((coveredCount / total) * 100) : 0;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
hubsCount: parsedHubs.length,
|
||||||
|
coveredCount,
|
||||||
|
uncoveredCount,
|
||||||
|
equalityIndex,
|
||||||
|
subDistrictsMap,
|
||||||
|
typeCounts
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Stats API Error:', error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
|
||||||
|
if (!supabaseServiceKey) {
|
||||||
|
return NextResponse.json({ error: 'Service role key not configured' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey);
|
||||||
|
|
||||||
|
// Fetch auth users
|
||||||
|
const { data: authData, error: authErr } = await supabaseAdmin.auth.admin.listUsers();
|
||||||
|
if (authErr) throw authErr;
|
||||||
|
|
||||||
|
// Fetch profiles
|
||||||
|
const { data: profilesData, error: profErr } = await supabaseAdmin.from('profiles').select('*');
|
||||||
|
if (profErr) throw profErr;
|
||||||
|
|
||||||
|
// Merge them
|
||||||
|
const users = (authData.users || []).map(authUser => {
|
||||||
|
const profile = profilesData.find(p => p.id === authUser.id) || {};
|
||||||
|
return {
|
||||||
|
id: authUser.id,
|
||||||
|
email: authUser.email,
|
||||||
|
full_name: profile.full_name || 'N/A',
|
||||||
|
role: profile.role || 'public',
|
||||||
|
password_changed: profile.password_changed,
|
||||||
|
created_at: authUser.created_at
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ users });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Users API GET Error:', error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
try {
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
|
||||||
|
if (!supabaseServiceKey) {
|
||||||
|
return NextResponse.json({ error: 'Service role key not configured' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey);
|
||||||
|
const body = await request.json();
|
||||||
|
const { email, password, full_name, role, hub_id } = body;
|
||||||
|
|
||||||
|
if (!email || !password || !full_name || !role) {
|
||||||
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Create user in Auth
|
||||||
|
const { data: authData, error: authErr } = await supabaseAdmin.auth.admin.createUser({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
email_confirm: true, // Auto-confirm email
|
||||||
|
});
|
||||||
|
|
||||||
|
if (authErr) throw authErr;
|
||||||
|
|
||||||
|
const newUserId = authData.user.id;
|
||||||
|
|
||||||
|
// 2. Insert into profiles (bypassing RLS with service role)
|
||||||
|
const { error: profErr } = await supabaseAdmin.from('profiles').insert([{
|
||||||
|
id: newUserId,
|
||||||
|
full_name,
|
||||||
|
role,
|
||||||
|
password_changed: false // Force change password on first login
|
||||||
|
}]);
|
||||||
|
|
||||||
|
// If profile insert fails, we rollback the auth user creation
|
||||||
|
if (profErr) {
|
||||||
|
await supabaseAdmin.auth.admin.deleteUser(newUserId);
|
||||||
|
throw profErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Assign user to worship hub (if role is pic or hub_head and hub_id is provided)
|
||||||
|
if (hub_id && (role === 'pic' || role === 'hub_head')) {
|
||||||
|
const updateData = {};
|
||||||
|
if (role === 'pic') {
|
||||||
|
updateData.pic_id = newUserId;
|
||||||
|
} else if (role === 'hub_head') {
|
||||||
|
updateData.head_id = newUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: hubErr } = await supabaseAdmin
|
||||||
|
.from('hubs')
|
||||||
|
.update(updateData)
|
||||||
|
.eq('id', hub_id);
|
||||||
|
|
||||||
|
if (hubErr) {
|
||||||
|
// Rollback profile and auth user
|
||||||
|
await supabaseAdmin.from('profiles').delete().eq('id', newUserId);
|
||||||
|
await supabaseAdmin.auth.admin.deleteUser(newUserId);
|
||||||
|
throw hubErr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, user: authData.user });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Users API POST Error:', error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request) {
|
||||||
|
try {
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
|
||||||
|
if (!supabaseServiceKey) {
|
||||||
|
return NextResponse.json({ error: 'Service role key not configured' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey);
|
||||||
|
|
||||||
|
// Using searchParams to get ID
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const id = searchParams.get('id');
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: 'Missing user id' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from auth.users (Cascades to profiles table via foreign key)
|
||||||
|
const { error } = await supabaseAdmin.auth.admin.deleteUser(id);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Users API DELETE Error:', error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function ChangePassword() {
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
supabase.auth.getUser().then(({ data }) => {
|
||||||
|
if (!data?.user) router.push('/login');
|
||||||
|
else setUser(data.user);
|
||||||
|
});
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleUpdate = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const { error: authError } = await supabase.auth.updateUser({ password });
|
||||||
|
|
||||||
|
if (authError) {
|
||||||
|
setError(authError.message);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: profileError } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.update({ password_changed: true })
|
||||||
|
.eq('id', user.id);
|
||||||
|
|
||||||
|
if (profileError) {
|
||||||
|
setError(profileError.message);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: '#f8fafc',
|
||||||
|
fontFamily: '"Inter", sans-serif'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: 'white',
|
||||||
|
padding: '40px',
|
||||||
|
borderRadius: '24px',
|
||||||
|
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.05)',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '400px',
|
||||||
|
textAlign: 'center',
|
||||||
|
border: '1px solid #e2e8f0'
|
||||||
|
}}>
|
||||||
|
<h2 style={{ margin: '0 0 8px 0', color: '#1e293b', fontSize: '1.5rem' }}>Rotasi Keamanan</h2>
|
||||||
|
<p style={{ margin: '0 0 24px 0', color: '#ef4444', fontSize: '0.85rem', fontWeight: '500', background: '#fee2e2', padding: '10px', borderRadius: '8px' }}>
|
||||||
|
<i className="fas fa-lock"></i> Untuk alasan keamanan, Anda wajib mengubah kata sandi default sebelum melanjutkan.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ background: '#fee2e2', color: '#ef4444', padding: '10px', borderRadius: '8px', marginBottom: '16px', fontSize: '0.85rem' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleUpdate} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
|
<div style={{ textAlign: 'left' }}>
|
||||||
|
<label style={{ display: 'block', fontSize: '0.85rem', color: '#475569', marginBottom: '6px', fontWeight: '500' }}>Kata Sandi Baru</label>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
minLength={6}
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '12px 44px 12px 16px', borderRadius: '12px',
|
||||||
|
border: '1px solid #cbd5e1', background: 'white',
|
||||||
|
outline: 'none', transition: 'border-color 0.2s', color: 'black',
|
||||||
|
boxShadow: 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.05)'
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: '14px',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: '#94a3b8',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '1rem',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
title={showPassword ? 'Sembunyikan Kata Sandi' : 'Tampilkan Kata Sandi'}
|
||||||
|
>
|
||||||
|
<i className={showPassword ? 'fas fa-eye-slash' : 'fas fa-eye'}></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '14px', borderRadius: '12px',
|
||||||
|
background: '#10b981', color: 'white',
|
||||||
|
border: 'none', fontWeight: '600', fontSize: '1rem',
|
||||||
|
cursor: loading ? 'not-allowed' : 'pointer',
|
||||||
|
marginTop: '8px', transition: 'background 0.2s',
|
||||||
|
opacity: loading ? 0.7 : 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? 'Menyimpan...' : 'Simpan & Lanjutkan'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,784 @@
|
|||||||
|
:root {
|
||||||
|
--primary: #3b82f6;
|
||||||
|
--primary-hover: #2563eb;
|
||||||
|
--success: #10b981;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--dark: #0f172a;
|
||||||
|
--panel-bg: rgba(255, 255, 255, 0.85); /* Slightly less transparent for readability */
|
||||||
|
--panel-border: rgba(255, 255, 255, 0.5);
|
||||||
|
--text-main: #1e293b;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||||
|
--glow: 0 0 40px rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body, html {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #f0f0f0; /* Light background behind map */
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
#map {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glassmorphism Panels */
|
||||||
|
.glass-panel {
|
||||||
|
background: var(--panel-bg);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top Header */
|
||||||
|
.top-bar {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 96%;
|
||||||
|
height: 64px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 24px;
|
||||||
|
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar.hidden {
|
||||||
|
transform: translate(-50%, -150%);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo i {
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
border-radius: 32px;
|
||||||
|
padding: 10px 24px;
|
||||||
|
width: 40%;
|
||||||
|
max-width: 500px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container:focus-within {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container input {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-main);
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 12px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container input::placeholder {
|
||||||
|
color: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn {
|
||||||
|
background: linear-gradient(135deg, var(--primary), #6366f1);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 24px;
|
||||||
|
border-radius: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Left Panel */
|
||||||
|
.left-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 100px;
|
||||||
|
left: 2%;
|
||||||
|
width: 300px;
|
||||||
|
padding: 24px;
|
||||||
|
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.4s ease;
|
||||||
|
transform-origin: left center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-panel.hidden {
|
||||||
|
transform: translateX(-120%);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: var(--text-main);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title .title-text {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown & Buttons */
|
||||||
|
.btn-group {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: rgba(59, 130, 246, 0.2);
|
||||||
|
color: #60a5fa;
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: calc(100% + 10px); /* Show on the right instead of covering the edit mode toggle */
|
||||||
|
width: 220px;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
z-index: 1001;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu button {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-main);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu button:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle Switch */
|
||||||
|
.toggle-group {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-group label {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 46px;
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
transition: .4s;
|
||||||
|
border-radius: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider:before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
background-color: white;
|
||||||
|
transition: .4s;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .slider {
|
||||||
|
background-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .slider:before {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkboxes */
|
||||||
|
.filter-group h4 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-item input {
|
||||||
|
appearance: none;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 2px solid rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-item input:checked {
|
||||||
|
background: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-item input:checked::after {
|
||||||
|
content: '\f00c';
|
||||||
|
font-family: 'Font Awesome 6 Free';
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 10px;
|
||||||
|
color: white;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right Panel */
|
||||||
|
.right-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 100px;
|
||||||
|
right: 2%;
|
||||||
|
width: 380px;
|
||||||
|
padding: 24px;
|
||||||
|
max-height: calc(100vh - 120px);
|
||||||
|
overflow-y: auto;
|
||||||
|
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||||
|
transform-origin: right center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-panel.hidden {
|
||||||
|
transform: translateX(120%);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-panel::-webkit-scrollbar { width: 4px; }
|
||||||
|
.right-panel::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 4px; }
|
||||||
|
|
||||||
|
.btn-toggle-panel {
|
||||||
|
background: rgba(0,0,0,0.05);
|
||||||
|
border: none;
|
||||||
|
color: var(--text-main);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.btn-toggle-panel:hover {
|
||||||
|
background: rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-panel-btn {
|
||||||
|
position: absolute;
|
||||||
|
padding: 10px 18px;
|
||||||
|
background: var(--panel-bg);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
color: var(--text-main);
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.show-panel-btn:hover {
|
||||||
|
background: rgba(241, 245, 249, 0.95);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.show-panel-btn.hidden {
|
||||||
|
display: none;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#showTopBarBtn { top: 20px; left: 50%; transform: translateX(-50%); }
|
||||||
|
#showTopBarBtn:hover { transform: translateX(-50%) translateY(-2px); }
|
||||||
|
#showControlsBtn { top: 100px; left: 2%; }
|
||||||
|
#showStatsBtn { top: 100px; right: 2%; }
|
||||||
|
#showLegendBtn { bottom: 30px; left: 2%; }
|
||||||
|
|
||||||
|
.kpi-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-box {
|
||||||
|
flex: 1;
|
||||||
|
background: rgba(0, 0, 0, 0.03);
|
||||||
|
padding: 16px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-success { color: var(--success); }
|
||||||
|
.text-danger { color: var(--danger); }
|
||||||
|
.text-primary { color: #60a5fa; }
|
||||||
|
|
||||||
|
.chart-box {
|
||||||
|
background: rgba(0, 0, 0, 0.03);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-box h4 {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gauge */
|
||||||
|
.gauge-container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge {
|
||||||
|
width: 160px;
|
||||||
|
height: 80px;
|
||||||
|
margin: 0 auto;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
border-top-left-radius: 160px;
|
||||||
|
border-top-right-radius: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-fill {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(to right, var(--danger), var(--warning), var(--success));
|
||||||
|
transform-origin: center top;
|
||||||
|
transform: rotate(0turn);
|
||||||
|
transition: transform 1s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-cover {
|
||||||
|
width: 130px;
|
||||||
|
height: 65px;
|
||||||
|
background: white;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 15px;
|
||||||
|
border-top-left-radius: 130px;
|
||||||
|
border-top-right-radius: 130px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Legend */
|
||||||
|
.legend {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 30px;
|
||||||
|
left: 2%;
|
||||||
|
padding: 16px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
z-index: 1000;
|
||||||
|
width: 230px;
|
||||||
|
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.4s ease;
|
||||||
|
transform-origin: left bottom;
|
||||||
|
}
|
||||||
|
.legend.hidden {
|
||||||
|
transform: translateX(-120%);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-color {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-legend-icon {
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 11px; color: white; border: 1px solid rgba(255,255,255,0.8);
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Map Icons & Popups */
|
||||||
|
.leaflet-popup-content-wrapper {
|
||||||
|
background: var(--panel-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
color: var(--text-main);
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-tip {
|
||||||
|
background: var(--panel-bg);
|
||||||
|
}
|
||||||
|
.leaflet-popup-content h3 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
border-bottom: 1px solid rgba(0,0,0,0.1);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content p {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.popup-slider-container {
|
||||||
|
margin-top: 12px;
|
||||||
|
background: rgba(0,0,0,0.05);
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.popup-slider-container label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.popup-slider-container input[type=range] {
|
||||||
|
width: 100%;
|
||||||
|
accent-color: var(--primary);
|
||||||
|
}
|
||||||
|
.popup-slider-container input[type=range]:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.citizen-modal {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.citizen-photo {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
background: rgba(0,0,0,0.05);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: rgba(0,0,0,0.3);
|
||||||
|
font-size: 2rem;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.citizen-details {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Overlay */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 2000;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
.modal-overlay.active {
|
||||||
|
display: flex;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.modal-content {
|
||||||
|
background: var(--panel-bg);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 20px 40px rgba(0,0,0,0.5);
|
||||||
|
transform: translateY(20px);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
.modal-overlay.active .modal-content {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
.modal-header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.close-btn {
|
||||||
|
background: none; border: none; font-size: 1.5rem; color: var(--text-muted); cursor: pointer;
|
||||||
|
}
|
||||||
|
.form-group { margin-bottom: 16px; }
|
||||||
|
.form-group label { display: block; font-size: 0.85rem; margin-bottom: 6px; color: var(--text-muted); }
|
||||||
|
.form-control {
|
||||||
|
width: 100%; padding: 10px 12px; background: rgba(0,0,0,0.05); border: 1px solid rgba(0,0,0,0.1);
|
||||||
|
border-radius: 8px; color: var(--text-main); font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.form-control:focus { outline: none; border-color: var(--primary); }
|
||||||
|
.modal-actions { display: flex; gap: 12px; margin-top: 24px; }
|
||||||
|
.modal-actions .btn { flex: 1; }
|
||||||
|
|
||||||
|
.btn-success { background: rgba(16, 185, 129, 0.2); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.4); }
|
||||||
|
.btn-success:hover { background: rgba(16, 185, 129, 0.3); }
|
||||||
|
|
||||||
|
.placement-toast {
|
||||||
|
position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%);
|
||||||
|
padding: 12px 24px; border-radius: 30px; font-weight: 500;
|
||||||
|
z-index: 1500; display: none; align-items: center; gap: 10px;
|
||||||
|
}
|
||||||
|
.placement-toast button {
|
||||||
|
background: rgba(0,0,0,0.1); border: none; color: var(--text-main); padding: 6px 12px;
|
||||||
|
border-radius: 12px; cursor: pointer; font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login Specific Styles */
|
||||||
|
.map-glow-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: radial-gradient(circle at center, rgba(255, 255, 255, 0.25) 0%, rgba(248, 250, 252, 0.85) 100%);
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 1000;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 40px;
|
||||||
|
background: var(--panel-bg);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: var(--shadow), var(--glow);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
animation: fadeIn 0.8s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translate(-50%, -40%); }
|
||||||
|
to { opacity: 1; transform: translate(-50%, -50%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
background: linear-gradient(135deg, var(--primary), #6366f1);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.4);
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link a {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link a:hover {
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from { transform: translateX(120%); opacity: 0; }
|
||||||
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes fadeInScale {
|
||||||
|
from { transform: scale(0.95); opacity: 0; }
|
||||||
|
to { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Suppress native password reveal/clear buttons in IE/Edge to prevent double eye icon stacking */
|
||||||
|
input::-ms-reveal,
|
||||||
|
input::-ms-clear {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-border {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.8), 0 4px 10px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
box-shadow: 0 0 0 8px rgba(239, 68, 68, 0), 0 4px 10px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0), 0 4px 10px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pulse-border {
|
||||||
|
animation: pulse-border 1.8s infinite !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Outfit } from "next/font/google";
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const outfit = Outfit({
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["300", "400", "500", "600", "700"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Sinergi Umat - Community Aid Distribution & Mapping System",
|
||||||
|
description: "A spatial assignment application to map and manage distribution of community aid to underprivileged households.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
||||||
|
</head>
|
||||||
|
<body className={outfit.className}>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const mapRef = useRef(null);
|
||||||
|
const mapInstanceRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
|
||||||
|
// Dynamically load Leaflet for the background map
|
||||||
|
import('leaflet').then((L) => {
|
||||||
|
if (!active) return;
|
||||||
|
|
||||||
|
if (!mapInstanceRef.current && mapRef.current) {
|
||||||
|
// Initialize map centered on Pontianak
|
||||||
|
const map = L.map(mapRef.current, {
|
||||||
|
center: [-0.0227, 109.3340],
|
||||||
|
zoom: 13,
|
||||||
|
zoomControl: false,
|
||||||
|
dragging: false,
|
||||||
|
scrollWheelZoom: false,
|
||||||
|
doubleClickZoom: false,
|
||||||
|
boxZoom: false,
|
||||||
|
keyboard: false,
|
||||||
|
attributionControl: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use CartoDB Positron for a clean, light background
|
||||||
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
||||||
|
maxZoom: 19,
|
||||||
|
subdomains: 'abcd'
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
mapInstanceRef.current = map;
|
||||||
|
|
||||||
|
// Force resize recalculation to ensure tiles render immediately
|
||||||
|
setTimeout(() => {
|
||||||
|
if (active && mapInstanceRef.current) {
|
||||||
|
mapInstanceRef.current.invalidateSize();
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
if (mapInstanceRef.current) {
|
||||||
|
mapInstanceRef.current.remove();
|
||||||
|
mapInstanceRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogin = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const { data, error: authError } = await supabase.auth.signInWithPassword({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (authError) {
|
||||||
|
setError(authError.message);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if they need password rotation
|
||||||
|
const { data: profile } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('password_changed')
|
||||||
|
.eq('id', data.user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (profile && profile.password_changed === false) {
|
||||||
|
router.push('/change-password');
|
||||||
|
} else {
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'relative',
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontFamily: '"Inter", sans-serif',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
{/* Return to Dashboard Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/')}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '24px',
|
||||||
|
left: '24px',
|
||||||
|
zIndex: 10,
|
||||||
|
background: 'rgba(255, 255, 255, 0.85)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
WebkitBackdropFilter: 'blur(8px)',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.6)',
|
||||||
|
borderRadius: '14px',
|
||||||
|
padding: '10px 18px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
color: '#1e293b',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.15)',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||||
|
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.95)';
|
||||||
|
}}
|
||||||
|
onMouseOut={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.85)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fas fa-arrow-left" style={{ color: 'var(--primary, #3b82f6)' }}></i>
|
||||||
|
<span>Kembali ke Dasbor</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dynamic Leaflet Background */}
|
||||||
|
<div
|
||||||
|
ref={mapRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0, left: 0, right: 0, bottom: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
zIndex: 1,
|
||||||
|
filter: 'brightness(0.95)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Glassmorphism Dark/Blur Overlay */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0, left: 0, right: 0, bottom: 0,
|
||||||
|
zIndex: 2,
|
||||||
|
background: 'rgba(15, 23, 42, 0.45)', // Slightly reduced opacity for a clearer view of the map
|
||||||
|
backdropFilter: 'blur(4px)', // Reduced from 10px
|
||||||
|
WebkitBackdropFilter: 'blur(4px)'
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* Login Card */}
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(255, 255, 255, 0.85)',
|
||||||
|
backdropFilter: 'blur(8px)', // Reduced from 20px
|
||||||
|
WebkitBackdropFilter: 'blur(8px)',
|
||||||
|
padding: '40px',
|
||||||
|
borderRadius: '24px',
|
||||||
|
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5)',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '400px',
|
||||||
|
textAlign: 'center',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.6)',
|
||||||
|
zIndex: 10
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--primary, #3b82f6)',
|
||||||
|
width: '60px', height: '60px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: 'white', fontSize: '24px',
|
||||||
|
margin: '0 auto 20px auto',
|
||||||
|
boxShadow: '0 10px 15px -3px rgba(59, 130, 246, 0.5)'
|
||||||
|
}}>
|
||||||
|
<i className="fas fa-hands-holding-circle"></i>
|
||||||
|
</div>
|
||||||
|
<h2 style={{ margin: '0 0 8px 0', color: '#1e293b', fontSize: '1.8rem', fontWeight: '800' }}>Sinergi Umat</h2>
|
||||||
|
<p style={{ margin: '0 0 24px 0', color: '#64748b', fontSize: '0.95rem' }}>Portal Manajemen Bantuan & Geotagging</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ background: '#fee2e2', color: '#ef4444', padding: '10px', borderRadius: '8px', marginBottom: '16px', fontSize: '0.85rem', fontWeight: '500' }}>
|
||||||
|
<i className="fas fa-exclamation-circle" style={{ marginRight: '5px' }}></i> {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleLogin} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
|
<div style={{ textAlign: 'left' }}>
|
||||||
|
<label style={{ display: 'block', fontSize: '0.85rem', color: '#475569', marginBottom: '6px', fontWeight: '600' }}>Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '12px 16px', borderRadius: '12px',
|
||||||
|
border: '1px solid #cbd5e1', background: 'white',
|
||||||
|
outline: 'none', transition: 'all 0.2s', color: '#1e293b',
|
||||||
|
boxShadow: 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.05)'
|
||||||
|
}}
|
||||||
|
onFocus={(e) => e.target.style.borderColor = 'var(--primary, #3b82f6)'}
|
||||||
|
onBlur={(e) => e.target.style.borderColor = '#cbd5e1'}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'left' }}>
|
||||||
|
<label style={{ display: 'block', fontSize: '0.85rem', color: '#475569', marginBottom: '6px', fontWeight: '600' }}>Kata Sandi</label>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '12px 44px 12px 16px', borderRadius: '12px',
|
||||||
|
border: '1px solid #cbd5e1', background: 'white',
|
||||||
|
outline: 'none', transition: 'all 0.2s', color: '#1e293b',
|
||||||
|
boxShadow: 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.05)'
|
||||||
|
}}
|
||||||
|
onFocus={(e) => e.target.style.borderColor = 'var(--primary, #3b82f6)'}
|
||||||
|
onBlur={(e) => e.target.style.borderColor = '#cbd5e1'}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: '14px',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: '#94a3b8',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '1rem',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
title={showPassword ? 'Sembunyikan Kata Sandi' : 'Tampilkan Kata Sandi'}
|
||||||
|
>
|
||||||
|
<i className={showPassword ? 'fas fa-eye-slash' : 'fas fa-eye'}></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '14px', borderRadius: '12px',
|
||||||
|
background: 'var(--primary, #3b82f6)', color: 'white',
|
||||||
|
border: 'none', fontWeight: '600', fontSize: '1rem',
|
||||||
|
cursor: loading ? 'not-allowed' : 'pointer',
|
||||||
|
marginTop: '8px', transition: 'all 0.2s',
|
||||||
|
opacity: loading ? 0.8 : 1,
|
||||||
|
boxShadow: loading ? 'none' : '0 4px 6px -1px rgba(59, 130, 246, 0.5)'
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => { if(!loading) e.target.style.transform = 'translateY(-2px)' }}
|
||||||
|
onMouseOut={(e) => { if(!loading) e.target.style.transform = 'translateY(0)' }}
|
||||||
|
>
|
||||||
|
{loading ? <><i className="fas fa-spinner fa-spin"></i> Memverifikasi...</> : 'Masuk ke Dasbor'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
// Load Leaflet map on client-side ONLY to bypass window-not-defined Node errors
|
||||||
|
const Dashboard = dynamic(() => import('@/components/Dashboard'), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<main style={{ width: '100vw', height: '100vh', overflow: 'hidden', position: 'relative' }}>
|
||||||
|
<Dashboard />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
.page {
|
||||||
|
--background: #fafafa;
|
||||||
|
--foreground: #fff;
|
||||||
|
|
||||||
|
--text-primary: #000;
|
||||||
|
--text-secondary: #666;
|
||||||
|
|
||||||
|
--button-primary-hover: #383838;
|
||||||
|
--button-secondary-hover: #f2f2f2;
|
||||||
|
--button-secondary-border: #ebebeb;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: var(--font-geist-sans);
|
||||||
|
background-color: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 800px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
background-color: var(--foreground);
|
||||||
|
padding: 120px 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro h1 {
|
||||||
|
max-width: 320px;
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 48px;
|
||||||
|
letter-spacing: -2.4px;
|
||||||
|
text-wrap: balance;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro p {
|
||||||
|
max-width: 440px;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 32px;
|
||||||
|
text-wrap: balance;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro a {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctas {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 440px;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctas a {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-radius: 128px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
width: fit-content;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.primary {
|
||||||
|
background: var(--text-primary);
|
||||||
|
color: var(--background);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.secondary {
|
||||||
|
border-color: var(--button-secondary-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enable hover only on non-touch devices */
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
a.primary:hover {
|
||||||
|
background: var(--button-primary-hover);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.secondary:hover {
|
||||||
|
background: var(--button-secondary-hover);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.main {
|
||||||
|
padding: 48px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 40px;
|
||||||
|
letter-spacing: -1.92px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.logo {
|
||||||
|
filter: invert();
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
--background: #000;
|
||||||
|
--foreground: #000;
|
||||||
|
|
||||||
|
--text-primary: #ededed;
|
||||||
|
--text-secondary: #999;
|
||||||
|
|
||||||
|
--button-primary-hover: #ccc;
|
||||||
|
--button-secondary-hover: #1a1a1a;
|
||||||
|
--button-secondary-border: #1a1a1a;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function SidebarControls({
|
||||||
|
user,
|
||||||
|
profile,
|
||||||
|
isEditMode,
|
||||||
|
setIsEditMode,
|
||||||
|
setShowApprovalModal,
|
||||||
|
setShowUserModal,
|
||||||
|
fetchUsers,
|
||||||
|
setPlacementMode,
|
||||||
|
setShowToast,
|
||||||
|
filters,
|
||||||
|
setFilters,
|
||||||
|
hubStyles,
|
||||||
|
setShowSurveyModal,
|
||||||
|
setShowCapacityModal,
|
||||||
|
pendingCapacityCount,
|
||||||
|
setShowDelegationModal,
|
||||||
|
pendingDelegationsCount,
|
||||||
|
hubs,
|
||||||
|
onFocusHub,
|
||||||
|
onOpenKas,
|
||||||
|
setShowPicListModal,
|
||||||
|
onlyMyHub,
|
||||||
|
setOnlyMyHub
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||||
|
{user && (profile?.role === 'super_admin' || profile?.role === 'pic') && (
|
||||||
|
<div className="edit-mode-controls" style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
|
<h4 style={{ fontSize: '0.8rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '4px' }}>
|
||||||
|
<i className="fas fa-edit"></i> Mode Edit Peta
|
||||||
|
</h4>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => setIsEditMode(!isEditMode)}
|
||||||
|
style={{
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
background: isEditMode ? 'rgba(239, 68, 68, 0.15)' : 'rgba(59, 130, 246, 0.15)',
|
||||||
|
color: isEditMode ? 'var(--danger)' : 'var(--primary)',
|
||||||
|
border: isEditMode ? '1px solid rgba(239, 68, 68, 0.3)' : '1px solid rgba(59, 130, 246, 0.3)',
|
||||||
|
padding: '10px 16px',
|
||||||
|
borderRadius: '8px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className={`fas ${isEditMode ? 'fa-lock-open' : 'fa-lock'}`}></i>
|
||||||
|
<span>{isEditMode ? 'Nonaktifkan Mode Edit' : 'Aktifkan Mode Edit'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user && profile?.role === 'super_admin' && (
|
||||||
|
<div className="admin-controls" style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginTop: '10px' }}>
|
||||||
|
<h4 style={{ fontSize: '0.8rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '4px' }}>
|
||||||
|
<i className="fas fa-user-shield"></i> Aksi Admin
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => setShowApprovalModal(true)}
|
||||||
|
style={{
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
background: 'rgba(59, 130, 246, 0.05)',
|
||||||
|
color: 'var(--text-main)',
|
||||||
|
border: '1px solid rgba(0, 0, 0, 0.1)',
|
||||||
|
padding: '10px 16px',
|
||||||
|
borderRadius: '8px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fas fa-list-check" style={{ color: 'var(--primary)' }}></i>
|
||||||
|
<span>Verifikasi KK</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => setShowCapacityModal(true)}
|
||||||
|
style={{
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
background: 'rgba(59, 130, 246, 0.05)',
|
||||||
|
color: 'var(--text-main)',
|
||||||
|
border: '1px solid rgba(0, 0, 0, 0.1)',
|
||||||
|
padding: '10px 16px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fas fa-triangle-exclamation" style={{ color: '#d97706' }}></i>
|
||||||
|
<span>Kapasitas Hub</span>
|
||||||
|
{pendingCapacityCount > 0 && (
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: '16px',
|
||||||
|
background: 'var(--danger)',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}}>
|
||||||
|
{pendingCapacityCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => { fetchUsers(); setShowUserModal(true); }}
|
||||||
|
style={{
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
background: 'rgba(59, 130, 246, 0.05)',
|
||||||
|
color: 'var(--text-main)',
|
||||||
|
border: '1px solid rgba(0, 0, 0, 0.1)',
|
||||||
|
padding: '10px 16px',
|
||||||
|
borderRadius: '8px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fas fa-users-cog" style={{ color: 'var(--primary)' }}></i>
|
||||||
|
<span>Manajemen Pengguna</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user && (profile?.role === 'pic' || profile?.role === 'super_admin') && isEditMode && (
|
||||||
|
<div className="surveyor-controls" style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
|
<h4 style={{ fontSize: '0.8rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '4px' }}>
|
||||||
|
<i className="fas fa-route"></i> Menu Surveyor
|
||||||
|
</h4>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => setShowSurveyModal(true)}
|
||||||
|
style={{
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
background: 'rgba(16, 185, 129, 0.15)',
|
||||||
|
color: 'var(--success)',
|
||||||
|
border: '1px solid rgba(16, 185, 129, 0.3)',
|
||||||
|
padding: '10px 16px',
|
||||||
|
borderRadius: '8px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fas fa-house-medical" style={{ color: 'var(--success)' }}></i>
|
||||||
|
<span>Tambah Pendataan KK</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user && profile?.role === 'pic' && (
|
||||||
|
<div className="pic-hub-controls" style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginTop: '5px' }}>
|
||||||
|
<h4 style={{ fontSize: '0.8rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '4px' }}>
|
||||||
|
<i className="fas fa-mosque"></i> Hub Saya
|
||||||
|
</h4>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => {
|
||||||
|
const myHub = hubs.find(h => h.pic_id === profile.id);
|
||||||
|
if (myHub) onFocusHub(myHub);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
background: 'rgba(59, 130, 246, 0.05)',
|
||||||
|
color: 'var(--text-main)',
|
||||||
|
border: '1px solid rgba(0, 0, 0, 0.1)',
|
||||||
|
padding: '10px 16px',
|
||||||
|
borderRadius: '8px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fas fa-search-location" style={{ color: 'var(--primary)' }}></i>
|
||||||
|
<span>Fokus ke Hub Saya</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user && profile?.role === 'hub_head' && (
|
||||||
|
<div className="delegation-controls" style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginTop: '5px' }}>
|
||||||
|
<h4 style={{ fontSize: '0.8rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '4px' }}>
|
||||||
|
<i className="fas fa-handshake"></i> Koordinasi Hub
|
||||||
|
</h4>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => setShowDelegationModal(true)}
|
||||||
|
style={{
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
background: 'rgba(59, 130, 246, 0.05)',
|
||||||
|
color: 'var(--text-main)',
|
||||||
|
border: '1px solid rgba(0, 0, 0, 0.1)',
|
||||||
|
padding: '10px 16px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fas fa-exchange-alt" style={{ color: 'var(--primary)' }}></i>
|
||||||
|
<span>Delegasi Masuk</span>
|
||||||
|
{pendingDelegationsCount > 0 && (
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: '16px',
|
||||||
|
background: 'var(--danger)',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}}>
|
||||||
|
{pendingDelegationsCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user && profile?.role === 'hub_head' && (
|
||||||
|
<div className="manager-controls" style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginTop: '5px' }}>
|
||||||
|
<h4 style={{ fontSize: '0.8rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '4px' }}>
|
||||||
|
<i className="fas fa-mosque"></i> Menu Ketua Pengurus
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => {
|
||||||
|
const myHub = hubs.find(h => h.head_id === profile.id);
|
||||||
|
if (myHub) onFocusHub(myHub);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
background: 'rgba(59, 130, 246, 0.05)',
|
||||||
|
color: 'var(--text-main)',
|
||||||
|
border: '1px solid rgba(0, 0, 0, 0.1)',
|
||||||
|
padding: '10px 16px',
|
||||||
|
borderRadius: '8px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fas fa-search-location" style={{ color: 'var(--primary)' }}></i>
|
||||||
|
<span>Fokus ke Hub Saya</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => {
|
||||||
|
const myHub = hubs.find(h => h.head_id === profile.id);
|
||||||
|
if (myHub) onOpenKas(myHub);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
background: 'rgba(59, 130, 246, 0.05)',
|
||||||
|
color: 'var(--text-main)',
|
||||||
|
border: '1px solid rgba(0, 0, 0, 0.1)',
|
||||||
|
padding: '10px 16px',
|
||||||
|
borderRadius: '8px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fas fa-wallet" style={{ color: 'var(--success)' }}></i>
|
||||||
|
<span>Manajemen Kas</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => {
|
||||||
|
fetchUsers();
|
||||||
|
setShowPicListModal(true);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
background: 'rgba(59, 130, 246, 0.05)',
|
||||||
|
color: 'var(--text-main)',
|
||||||
|
border: '1px solid rgba(0, 0, 0, 0.1)',
|
||||||
|
padding: '10px 16px',
|
||||||
|
borderRadius: '8px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fas fa-users" style={{ color: '#d97706' }}></i>
|
||||||
|
<span>Akun PIC Lapangan</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEditMode && profile?.role === 'super_admin' && (
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => { setPlacementMode('hub'); setShowToast(true); }}
|
||||||
|
style={{
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
background: 'rgba(59, 130, 246, 0.15)',
|
||||||
|
color: 'var(--primary)',
|
||||||
|
border: '1px solid rgba(59, 130, 246, 0.3)',
|
||||||
|
padding: '10px 16px',
|
||||||
|
borderRadius: '8px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fas fa-place-of-worship"></i>
|
||||||
|
<span>Tambah Rumah Ibadah</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="filter-group">
|
||||||
|
<h4><i className="fas fa-filter"></i> Filter Visibilitas</h4>
|
||||||
|
{user && (profile?.role === 'pic' || profile?.role === 'hub_head') && (
|
||||||
|
<label className="checkbox-item" style={{ marginBottom: '8px', borderBottom: '1px dashed rgba(0,0,0,0.1)', paddingBottom: '8px', fontWeight: 'bold' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={onlyMyHub}
|
||||||
|
onChange={(e) => setOnlyMyHub(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<i className="fas fa-location-crosshairs" style={{ color: 'var(--primary)', width: '20px' }}></i> Hanya Hub Saya
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{Object.keys(filters).map(key => (
|
||||||
|
<label className="checkbox-item" key={key}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filters[key]}
|
||||||
|
onChange={(e) => setFilters({ ...filters, [key]: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<i className={hubStyles[key].iconClass} style={{ color: hubStyles[key].color, width: '20px' }}></i> {key === 'Mosque' ? 'Masjid' : key === 'Church' ? 'Gereja' : key === 'Temple' ? 'Pura' : key === 'Vihara' ? 'Vihara' : 'Lainnya'}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function StatsPanel({
|
||||||
|
profile,
|
||||||
|
showStats,
|
||||||
|
setShowStats,
|
||||||
|
stats
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`right-panel glass-panel ${showStats ? '' : 'hidden'}`} id="statsPanel">
|
||||||
|
<div className="panel-title">
|
||||||
|
<span className="title-text"><i className="fas fa-chart-pie"></i> {profile?.role === 'hub_head' || profile?.role === 'pic' ? 'Micro Statistics (Your Area)' : 'Macro Statistics (City Level)'}</span>
|
||||||
|
<button className="btn-toggle-panel" onClick={() => setShowStats(false)}><i className="fas fa-chevron-right"></i> Hide</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="kpi-container">
|
||||||
|
<div className="kpi-card text-center">
|
||||||
|
<div style={{ fontSize: '2rem', color: 'var(--success)', fontWeight: 'bold' }}>{stats.coveredCount}</div>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: '#64748b' }}>Warga Terjangkau</div>
|
||||||
|
</div>
|
||||||
|
<div className="kpi-card text-center">
|
||||||
|
<div style={{ fontSize: '2rem', color: 'var(--danger)', fontWeight: 'bold' }}>{stats.uncoveredCount}</div>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: '#64748b' }}>Warga Belum Terjangkau</div>
|
||||||
|
</div>
|
||||||
|
<div className="kpi-card text-center">
|
||||||
|
<div style={{ fontSize: '2rem', color: 'var(--primary)', fontWeight: 'bold' }}>{stats.equalityIndex}%</div>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: '#64748b' }}>Indeks Pemerataan</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '0 20px', display: 'flex', gap: '20px' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<h4 style={{ margin: '10px 0 5px 0', fontSize: '0.85rem', color: '#475569', textAlign: 'center' }}>Sebaran per Wilayah</h4>
|
||||||
|
<canvas id="subDistrictChart" style={{ width: '100%', height: '200px' }}></canvas>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<h4 style={{ margin: '10px 0 5px 0', fontSize: '0.85rem', color: '#475569', textAlign: 'center' }}>Berdasarkan Tipe Hub</h4>
|
||||||
|
<canvas id="typeChart" style={{ width: '100%', height: '200px' }}></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { supabase } from '@/lib/supabase';
|
||||||
|
|
||||||
|
export default function ApprovalQueueModal({
|
||||||
|
showApprovalModal,
|
||||||
|
setShowApprovalModal,
|
||||||
|
households,
|
||||||
|
selectedPhotoUrl,
|
||||||
|
setSelectedPhotoUrl,
|
||||||
|
handleVerifyAction,
|
||||||
|
onFocusHousehold
|
||||||
|
}) {
|
||||||
|
if (!showApprovalModal) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="modal-overlay active">
|
||||||
|
<div className="modal-content" style={{ maxWidth: '900px', width: '90%' }}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2><i className="fas fa-list-check text-primary"></i> Antrean Verifikasi KK</h2>
|
||||||
|
<button className="close-btn" onClick={() => setShowApprovalModal(false)}>×</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.9rem' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: '#f1f5f9', textAlign: 'left' }}>
|
||||||
|
<th style={{ padding: '10px', borderBottom: '2px solid #e2e8f0' }}>Kepala Keluarga</th>
|
||||||
|
<th style={{ padding: '10px', borderBottom: '2px solid #e2e8f0' }}>Jml Anggota</th>
|
||||||
|
<th style={{ padding: '10px', borderBottom: '2px solid #e2e8f0' }}>Wilayah</th>
|
||||||
|
<th style={{ padding: '10px', borderBottom: '2px solid #e2e8f0' }}>Aksi Lokasi / Foto</th>
|
||||||
|
<th style={{ padding: '10px', borderBottom: '2px solid #e2e8f0', textAlign: 'right' }}>Aksi Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{households.filter(hh => hh.status === 'Pending').length === 0 ? (
|
||||||
|
<tr><td colSpan="5" style={{ textAlign: 'center', padding: '20px', color: '#64748b' }}>Tidak ada antrean pending.</td></tr>
|
||||||
|
) : (
|
||||||
|
households.filter(hh => hh.status === 'Pending').map(hh => (
|
||||||
|
<tr key={hh.id} style={{ borderBottom: '1px solid #e2e8f0' }}>
|
||||||
|
<td style={{ padding: '10px' }}>
|
||||||
|
<span style={{ fontWeight: 'bold' }}>{hh.head_of_family}</span>
|
||||||
|
<br/><small style={{ color: '#64748b' }}>NIK: {hh.nik || '-'}</small>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px' }}>{hh.family_size} Jiwa</td>
|
||||||
|
<td style={{ padding: '10px' }}>{hh.sub_district}</td>
|
||||||
|
<td style={{ padding: '10px' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button onClick={async () => {
|
||||||
|
if (hh.photo_url && !hh.photo_url.startsWith('http')) {
|
||||||
|
const { data } = await supabase.storage.from('geotagging-photo').createSignedUrl(hh.photo_url, 3600);
|
||||||
|
if (data && data.signedUrl) setSelectedPhotoUrl(data.signedUrl);
|
||||||
|
} else {
|
||||||
|
setSelectedPhotoUrl(hh.photo_url || 'https://via.placeholder.com/150');
|
||||||
|
}
|
||||||
|
}} className="btn btn-outline" style={{ padding: '4px 8px', fontSize: '0.8rem' }}>Lihat Foto</button>
|
||||||
|
|
||||||
|
<button onClick={() => onFocusHousehold(hh)} className="btn btn-outline" style={{ padding: '4px 8px', fontSize: '0.8rem', color: 'var(--primary)', borderColor: 'rgba(59, 130, 246, 0.3)' }} title="Fokus Peta ke Lokasi KK">
|
||||||
|
<i className="fas fa-crosshairs"></i> Lihat Lokasi
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px', textAlign: 'right' }}>
|
||||||
|
<button onClick={() => handleVerifyAction(hh.id, 'Verified')} className="btn btn-primary" style={{ padding: '4px 8px', fontSize: '0.8rem', background: '#10b981', borderColor: '#10b981', color: 'white', marginRight: '5px' }}>Terima</button>
|
||||||
|
<button onClick={() => handleVerifyAction(hh.id, 'Rejected')} className="btn btn-outline" style={{ padding: '4px 8px', fontSize: '0.8rem', color: '#ef4444', borderColor: '#ef4444' }}>Tolak</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Photo Preview Overlay */}
|
||||||
|
{selectedPhotoUrl && (
|
||||||
|
<div className="modal-overlay active" style={{ zIndex: 9999 }} onClick={() => setSelectedPhotoUrl('')}>
|
||||||
|
<div style={{ position: 'relative', maxWidth: '90%', maxHeight: '90%' }}>
|
||||||
|
<button onClick={() => setSelectedPhotoUrl('')} style={{ position: 'absolute', top: '-40px', right: '0', background: 'none', border: 'none', color: 'white', fontSize: '2rem', cursor: 'pointer' }}>×</button>
|
||||||
|
<img src={selectedPhotoUrl} alt="Bukti Foto" style={{ maxWidth: '100%', maxHeight: '90vh', borderRadius: '8px', boxShadow: '0 4px 20px rgba(0,0,0,0.5)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { supabase } from '../../../lib/supabase';
|
||||||
|
|
||||||
|
export default function CapacityApprovalModal({
|
||||||
|
showApprovalModal,
|
||||||
|
setShowApprovalModal,
|
||||||
|
hubs,
|
||||||
|
households,
|
||||||
|
showToastNotification,
|
||||||
|
onSuccess
|
||||||
|
}) {
|
||||||
|
const [selectedHub, setSelectedHub] = useState(null);
|
||||||
|
const [hubDonations, setHubDonations] = useState([]);
|
||||||
|
const [isLoadingFinancials, setIsLoadingFinancials] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Filter hubs that have pending requests
|
||||||
|
const pendingHubs = hubs.filter(h => h.pending_capacity_status !== null && h.pending_capacity_status !== undefined);
|
||||||
|
|
||||||
|
// Auto-select first pending hub
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingHubs.length > 0 && !selectedHub) {
|
||||||
|
setSelectedHub(pendingHubs[0]);
|
||||||
|
} else if (pendingHubs.length === 0) {
|
||||||
|
setSelectedHub(null);
|
||||||
|
}
|
||||||
|
}, [hubs, pendingHubs, selectedHub]);
|
||||||
|
|
||||||
|
// Fetch selected hub's financials
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedHub) {
|
||||||
|
const fetchHubFinancials = async () => {
|
||||||
|
setIsLoadingFinancials(true);
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('donations')
|
||||||
|
.select('*')
|
||||||
|
.eq('hub_id', selectedHub.id)
|
||||||
|
.order('transaction_date', { ascending: false });
|
||||||
|
if (error) {
|
||||||
|
console.error('Error fetching hub donations:', error);
|
||||||
|
} else {
|
||||||
|
setHubDonations(data || []);
|
||||||
|
}
|
||||||
|
setIsLoadingFinancials(false);
|
||||||
|
};
|
||||||
|
fetchHubFinancials();
|
||||||
|
} else {
|
||||||
|
setHubDonations([]);
|
||||||
|
}
|
||||||
|
}, [selectedHub]);
|
||||||
|
|
||||||
|
if (!showApprovalModal) return null;
|
||||||
|
|
||||||
|
// Calculations for selected hub
|
||||||
|
const servedKkCount = selectedHub
|
||||||
|
? households.filter(hh => hh.db_assigned_hub_id === selectedHub.id).length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const totalBalance = hubDonations.reduce((acc, curr) => {
|
||||||
|
if (curr.type === 'IN') return acc + Number(curr.amount);
|
||||||
|
return acc - Number(curr.amount);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const ratioPerKk = servedKkCount > 0 ? totalBalance / servedKkCount : totalBalance;
|
||||||
|
|
||||||
|
// Determine critical state warning and recommendations
|
||||||
|
let recommendationText = 'Kondisi Aman';
|
||||||
|
let recommendationColor = '#10b981';
|
||||||
|
let recommendationIcon = 'fa-check-circle';
|
||||||
|
|
||||||
|
if (ratioPerKk < 100000) {
|
||||||
|
recommendationText = 'SANGAT KRITIS (Rekomendasi Setujui: Valid)';
|
||||||
|
recommendationColor = '#ef4444';
|
||||||
|
recommendationIcon = 'fa-exclamation-triangle';
|
||||||
|
} else if (ratioPerKk < 300000) {
|
||||||
|
recommendationText = 'KRITIS / SEDANG (Rekomendasi Setujui: Cukup Valid)';
|
||||||
|
recommendationColor = '#f59e0b';
|
||||||
|
recommendationIcon = 'fa-circle-info';
|
||||||
|
} else {
|
||||||
|
recommendationText = 'KONDISI SEHAT (Rekomendasi Tolak: Dana Cukup)';
|
||||||
|
recommendationColor = '#3b82f6';
|
||||||
|
recommendationIcon = 'fa-shield-halved';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle decisions
|
||||||
|
const handleDecision = async (hubId, approve) => {
|
||||||
|
if (isSubmitting) return;
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (approve) {
|
||||||
|
// Overwrite main status and reset pending status
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('hubs')
|
||||||
|
.update({
|
||||||
|
capacity_status: selectedHub.pending_capacity_status,
|
||||||
|
pending_capacity_status: null
|
||||||
|
})
|
||||||
|
.eq('id', hubId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
alert(`Gagal menyetujui pengajuan: ${error.message}`);
|
||||||
|
} else {
|
||||||
|
showToastNotification(`Berhasil menyetujui perubahan kapasitas menjadi overloaded!`);
|
||||||
|
setSelectedHub(null);
|
||||||
|
onSuccess();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Just reject by resetting pending status
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('hubs')
|
||||||
|
.update({ pending_capacity_status: null })
|
||||||
|
.eq('id', hubId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
alert(`Gagal menolak pengajuan: ${error.message}`);
|
||||||
|
} else {
|
||||||
|
showToastNotification(`Pengajuan perubahan kapasitas ditolak.`);
|
||||||
|
setSelectedHub(null);
|
||||||
|
onSuccess();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay active">
|
||||||
|
<div className="modal-content" style={{ maxWidth: '950px', width: '95%' }}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2><i className="fas fa-list-check text-primary"></i> Antrean Verifikasi Kapasitas Hub</h2>
|
||||||
|
<button className="close-btn" onClick={() => setShowApprovalModal(false)}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-body" style={{ display: 'grid', gridTemplateColumns: '300px 1fr', gap: '20px', maxHeight: '70vh', overflowY: 'auto' }}>
|
||||||
|
|
||||||
|
{/* Left Column: Pending Hubs List */}
|
||||||
|
<div style={{ borderRight: '1px solid #e2e8f0', paddingRight: '15px' }}>
|
||||||
|
<h3 style={{ margin: '0 0 15px 0', fontSize: '1rem', color: 'var(--text-muted)' }}>Daftar Pengajuan ({pendingHubs.length})</h3>
|
||||||
|
{pendingHubs.length === 0 ? (
|
||||||
|
<p style={{ textAlign: 'center', color: '#94a3b8', fontSize: '0.9rem', padding: '40px 10px' }}>
|
||||||
|
Tidak ada pengajuan perubahan kapasitas saat ini.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
|
{pendingHubs.map(hub => {
|
||||||
|
const isSelected = selectedHub?.id === hub.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={hub.id}
|
||||||
|
onClick={() => setSelectedHub(hub)}
|
||||||
|
style={{
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: isSelected ? '2px solid var(--primary)' : '1px solid #e2e8f0',
|
||||||
|
background: isSelected ? 'rgba(59, 130, 246, 0.05)' : 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong style={{ display: 'block', fontSize: '0.9rem', color: '#1e293b' }}>{hub.name}</strong>
|
||||||
|
<span style={{ fontSize: '0.75rem', color: '#64748b' }}>{hub.type}</span>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '8px' }}>
|
||||||
|
<span style={{ fontSize: '0.7rem', padding: '2px 6px', background: '#fef3c7', color: '#d97706', borderRadius: '4px', fontWeight: 'bold' }}>
|
||||||
|
Minta: {hub.pending_capacity_status === 'overloaded' ? 'Overloaded' : 'Normal'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column: Selected Hub Details and Ratio Analysis */}
|
||||||
|
<div>
|
||||||
|
{selectedHub ? (
|
||||||
|
<div>
|
||||||
|
{/* Hub Header */}
|
||||||
|
<div style={{ marginBottom: '20px' }}>
|
||||||
|
<h3 style={{ margin: '0 0 4px 0', fontSize: '1.25rem', color: '#1e293b' }}>{selectedHub.name}</h3>
|
||||||
|
<p style={{ margin: 0, fontSize: '0.8rem', color: '#64748b' }}>
|
||||||
|
<i className="fas fa-map-marker-alt" style={{ marginRight: '6px' }}></i>{selectedHub.address || 'Alamat belum diatur'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metrics Cards Grid */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '15px', marginBottom: '20px' }}>
|
||||||
|
|
||||||
|
{/* Card 1: Assigned KK */}
|
||||||
|
<div style={{ background: '#f8fafc', padding: '15px', borderRadius: '8px', border: '1px solid #e2e8f0' }}>
|
||||||
|
<span style={{ display: 'block', fontSize: '0.75rem', color: '#64748b', fontWeight: 'bold', textTransform: 'uppercase' }}>KK Terlayani</span>
|
||||||
|
<strong style={{ fontSize: '1.5rem', color: '#1e293b' }}>{servedKkCount} <span style={{ fontSize: '0.85rem', fontWeight: 'normal' }}>Keluarga</span></strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card 2: Donation Balance */}
|
||||||
|
<div style={{ background: '#f8fafc', padding: '15px', borderRadius: '8px', border: '1px solid #e2e8f0' }}>
|
||||||
|
<span style={{ display: 'block', fontSize: '0.75rem', color: '#64748b', fontWeight: 'bold', textTransform: 'uppercase' }}>Saldo Kas</span>
|
||||||
|
{isLoadingFinancials ? (
|
||||||
|
<span style={{ fontSize: '0.9rem', color: '#94a3b8' }}>Memuat data...</span>
|
||||||
|
) : (
|
||||||
|
<strong style={{ fontSize: '1.5rem', color: totalBalance >= 0 ? '#10b981' : '#ef4444' }}>
|
||||||
|
Rp{totalBalance.toLocaleString('id-ID')}
|
||||||
|
</strong>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card 3: Financial Ratio */}
|
||||||
|
<div style={{ background: '#f8fafc', padding: '15px', borderRadius: '8px', border: '1px solid #e2e8f0' }}>
|
||||||
|
<span style={{ display: 'block', fontSize: '0.75rem', color: '#64748b', fontWeight: 'bold', textTransform: 'uppercase' }}>Rasio Dana / KK</span>
|
||||||
|
{isLoadingFinancials ? (
|
||||||
|
<span style={{ fontSize: '0.9rem', color: '#94a3b8' }}>Memuat data...</span>
|
||||||
|
) : (
|
||||||
|
<strong style={{ fontSize: '1.25rem', color: ratioPerKk < 100000 ? '#ef4444' : '#1e293b' }}>
|
||||||
|
Rp{Math.round(ratioPerKk).toLocaleString('id-ID')} <span style={{ fontSize: '0.75rem', fontWeight: 'normal', color: '#64748b' }}>/ KK</span>
|
||||||
|
</strong>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Audit & Recommendation Box */}
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(0,0,0,0.02)',
|
||||||
|
padding: '15px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
borderLeft: `4px solid ${recommendationColor}`,
|
||||||
|
marginBottom: '20px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px'
|
||||||
|
}}>
|
||||||
|
<i className={`fas ${recommendationIcon}`} style={{ color: recommendationColor, fontSize: '1.5rem' }}></i>
|
||||||
|
<div>
|
||||||
|
<strong style={{ display: 'block', fontSize: '0.85rem', color: '#1e293b' }}>Rekomendasi Analitis Pusat:</strong>
|
||||||
|
<span style={{ fontSize: '0.8rem', fontWeight: 'bold', color: recommendationColor }}>{recommendationText}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Last 5 Transactions List */}
|
||||||
|
<div style={{ background: '#f8fafc', padding: '15px', borderRadius: '8px', border: '1px solid #e2e8f0', marginBottom: '25px' }}>
|
||||||
|
<h4 style={{ margin: '0 0 10px 0', fontSize: '0.9rem', color: '#1e293b' }}><i className="fas fa-history"></i> Riwayat Transaksi Terakhir</h4>
|
||||||
|
{isLoadingFinancials ? (
|
||||||
|
<p style={{ textAlign: 'center', color: '#94a3b8', fontSize: '0.85rem', padding: '10px 0' }}>Memuat data keuangan...</p>
|
||||||
|
) : hubDonations.length === 0 ? (
|
||||||
|
<p style={{ textAlign: 'center', color: '#94a3b8', fontSize: '0.85rem', padding: '10px 0' }}>Tidak ada riwayat transaksi kas.</p>
|
||||||
|
) : (
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: '#e2e8f0', textAlign: 'left' }}>
|
||||||
|
<th style={{ padding: '6px', borderBottom: '1px solid #cbd5e1' }}>Tanggal</th>
|
||||||
|
<th style={{ padding: '6px', borderBottom: '1px solid #cbd5e1' }}>Keterangan</th>
|
||||||
|
<th style={{ padding: '6px', borderBottom: '1px solid #cbd5e1', textAlign: 'right' }}>Nominal</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{hubDonations.slice(0, 5).map(donation => (
|
||||||
|
<tr key={donation.id} style={{ borderBottom: '1px solid #e2e8f0' }}>
|
||||||
|
<td style={{ padding: '6px', color: '#475569' }}>{donation.transaction_date}</td>
|
||||||
|
<td style={{ padding: '6px', color: '#1e293b' }}>{donation.description}</td>
|
||||||
|
<td style={{ padding: '6px', textAlign: 'right', fontWeight: 'bold', color: donation.type === 'IN' ? '#10b981' : '#ef4444' }}>
|
||||||
|
{donation.type === 'IN' ? '+' : '-'} Rp{Number(donation.amount).toLocaleString('id-ID')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions Buttons */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => handleDecision(selectedHub.id, false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
background: '#fee2e2',
|
||||||
|
color: '#b91c1c',
|
||||||
|
border: '1px solid #fecaca',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: '0.85rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fas fa-times-circle"></i> Tolak Pengajuan
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => handleDecision(selectedHub.id, true)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
background: '#10b981',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: '0.85rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fas fa-check-circle"></i> Setujui Pengajuan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '40vh', color: '#94a3b8' }}>
|
||||||
|
<i className="fas fa-arrow-left" style={{ fontSize: '2rem', marginBottom: '10px' }}></i>
|
||||||
|
<p style={{ margin: 0 }}>Silakan pilih salah satu Hub dari antrean di sebelah kiri untuk diaudit.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { supabase } from '@/lib/supabase';
|
||||||
|
|
||||||
|
// Simple Haversine distance calculator
|
||||||
|
function getDistance(lat1, lon1, lat2, lon2) {
|
||||||
|
if (!lat1 || !lon1 || !lat2 || !lon2) return 0;
|
||||||
|
const R = 6371000; // meters
|
||||||
|
const phi1 = (lat1 * Math.PI) / 180;
|
||||||
|
const phi2 = (lat2 * Math.PI) / 180;
|
||||||
|
const deltaPhi = ((lat2 - lat1) * Math.PI) / 180;
|
||||||
|
const deltaLambda = ((lon2 - lon1) * Math.PI) / 180;
|
||||||
|
|
||||||
|
const a =
|
||||||
|
Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) +
|
||||||
|
Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2);
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
|
||||||
|
return R * c; // in meters
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DelegationQueueModal({
|
||||||
|
showDelegationModal,
|
||||||
|
setShowDelegationModal,
|
||||||
|
households,
|
||||||
|
hubs,
|
||||||
|
myHub,
|
||||||
|
selectedPhotoUrl,
|
||||||
|
setSelectedPhotoUrl,
|
||||||
|
onFocusHousehold,
|
||||||
|
fetchData,
|
||||||
|
showToastNotification
|
||||||
|
}) {
|
||||||
|
if (!showDelegationModal || !myHub) return null;
|
||||||
|
|
||||||
|
// Filter incoming transfers targeted to myHub
|
||||||
|
const incomingDelegations = households.filter(hh => hh.pending_transfer_hub_id === myHub.id);
|
||||||
|
|
||||||
|
const handleAccept = async (hh) => {
|
||||||
|
try {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('households')
|
||||||
|
.update({
|
||||||
|
assigned_hub_id: myHub.id,
|
||||||
|
pending_transfer_hub_id: null,
|
||||||
|
manual_override_assigned_hub: true
|
||||||
|
})
|
||||||
|
.eq('id', hh.id);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
showToastNotification(`Warga ${hh.head_of_family} berhasil diterima dan dialihkan ke Hub Anda!`, 'success');
|
||||||
|
fetchData();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
showToastNotification(`Gagal menerima delegasi: ${err.message}`, 'danger');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReject = async (hh) => {
|
||||||
|
try {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('households')
|
||||||
|
.update({
|
||||||
|
pending_transfer_hub_id: null
|
||||||
|
})
|
||||||
|
.eq('id', hh.id);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
showToastNotification(`Delegasi warga ${hh.head_of_family} berhasil ditolak.`, 'success');
|
||||||
|
fetchData();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
showToastNotification(`Gagal menolak delegasi: ${err.message}`, 'danger');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="modal-overlay active">
|
||||||
|
<div className="modal-content" style={{ maxWidth: '950px', width: '90%' }}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>
|
||||||
|
<i className="fas fa-exchange-alt text-primary" style={{ marginRight: '8px' }}></i>
|
||||||
|
Permohonan Delegasi Masuk
|
||||||
|
</h2>
|
||||||
|
<button className="close-btn" onClick={() => setShowDelegationModal(false)}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.9rem' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: '#f1f5f9', textAlign: 'left' }}>
|
||||||
|
<th style={{ padding: '12px 10px', borderBottom: '2px solid #e2e8f0' }}>Kepala Keluarga</th>
|
||||||
|
<th style={{ padding: '12px 10px', borderBottom: '2px solid #e2e8f0' }}>Jumlah Anggota</th>
|
||||||
|
<th style={{ padding: '12px 10px', borderBottom: '2px solid #e2e8f0' }}>Hub Asal</th>
|
||||||
|
<th style={{ padding: '12px 10px', borderBottom: '2px solid #e2e8f0' }}>Jarak ke Hub Anda</th>
|
||||||
|
<th style={{ padding: '12px 10px', borderBottom: '2px solid #e2e8f0' }}>Verifikasi Media</th>
|
||||||
|
<th style={{ padding: '12px 10px', borderBottom: '2px solid #e2e8f0', textAlign: 'right' }}>Aksi Keputusan</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{incomingDelegations.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan="6" style={{ textAlign: 'center', padding: '30px', color: '#64748b' }}>
|
||||||
|
Tidak ada permohonan delegasi masuk untuk Hub Anda.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
incomingDelegations.map((hh) => {
|
||||||
|
const originHubName = hubs.find((h) => String(h.id) === String(hh.db_assigned_hub_id))?.name || 'Hub Tidak Dikenal';
|
||||||
|
const distanceMeters = getDistance(hh.lat, hh.lng, myHub.lat, myHub.lng);
|
||||||
|
const formattedDistance =
|
||||||
|
distanceMeters > 1000
|
||||||
|
? `${(distanceMeters / 1000).toFixed(2)} km`
|
||||||
|
: `${Math.round(distanceMeters)} m`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={hh.id} style={{ borderBottom: '1px solid #e2e8f0' }}>
|
||||||
|
<td style={{ padding: '12px 10px' }}>
|
||||||
|
<span style={{ fontWeight: 'bold' }}>{hh.head_of_family}</span>
|
||||||
|
<br />
|
||||||
|
<small style={{ color: '#64748b' }}>NIK: {hh.nik || '-'}</small>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 10px' }}>{hh.family_size} Jiwa</td>
|
||||||
|
<td style={{ padding: '12px 10px', color: '#4b5563', fontWeight: '500' }}>
|
||||||
|
{originHubName}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 10px' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: distanceMeters > (myHub.radius || 500) ? '#fef3c7' : '#dcfce7',
|
||||||
|
color: distanceMeters > (myHub.radius || 500) ? '#d97706' : '#15803d',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: '0.8rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formattedDistance}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 10px' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (hh.photo_url && !hh.photo_url.startsWith('http')) {
|
||||||
|
const { data } = await supabase.storage.from('geotagging-photo').createSignedUrl(hh.photo_url, 3600);
|
||||||
|
if (data && data.signedUrl) setSelectedPhotoUrl(data.signedUrl);
|
||||||
|
} else {
|
||||||
|
setSelectedPhotoUrl(hh.photo_url || 'https://via.placeholder.com/150');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="btn btn-outline"
|
||||||
|
style={{ padding: '4px 8px', fontSize: '0.8rem' }}
|
||||||
|
>
|
||||||
|
Foto
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onFocusHousehold(hh);
|
||||||
|
setShowDelegationModal(false);
|
||||||
|
}}
|
||||||
|
className="btn btn-outline"
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
color: 'var(--primary)',
|
||||||
|
borderColor: 'rgba(59, 130, 246, 0.3)'
|
||||||
|
}}
|
||||||
|
title="Fokus Peta ke Lokasi KK"
|
||||||
|
>
|
||||||
|
<i className="fas fa-crosshairs"></i> Peta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 10px', textAlign: 'right' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleAccept(hh)}
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{
|
||||||
|
padding: '4px 10px',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
background: '#10b981',
|
||||||
|
borderColor: '#10b981',
|
||||||
|
color: 'white',
|
||||||
|
marginRight: '6px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Terima
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleReject(hh)}
|
||||||
|
className="btn btn-outline"
|
||||||
|
style={{
|
||||||
|
padding: '4px 10px',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
color: '#ef4444',
|
||||||
|
borderColor: '#ef4444'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Tolak
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Photo Preview Overlay */}
|
||||||
|
{selectedPhotoUrl && (
|
||||||
|
<div className="modal-overlay active" style={{ zIndex: 9999 }} onClick={() => setSelectedPhotoUrl('')}>
|
||||||
|
<div style={{ position: 'relative', maxWidth: '90%', maxHeight: '90%' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedPhotoUrl('')}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-40px',
|
||||||
|
right: '0',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '2rem',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
src={selectedPhotoUrl}
|
||||||
|
alt="Bukti Foto"
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '90vh',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 4px 20px rgba(0,0,0,0.5)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function DonationModal({
|
||||||
|
showDonationModal,
|
||||||
|
setShowDonationModal,
|
||||||
|
donationFormData,
|
||||||
|
setDonationFormData,
|
||||||
|
hubs,
|
||||||
|
isSubmittingDonation,
|
||||||
|
handleSaveDonation,
|
||||||
|
donationsList,
|
||||||
|
profile
|
||||||
|
}) {
|
||||||
|
if (!showDonationModal) return null;
|
||||||
|
if (!profile) return null; // Secure fail-safe for unauthenticated users
|
||||||
|
|
||||||
|
// Check roles: PIC, Hub Head, and Super Admin can record transactions
|
||||||
|
const canEdit = profile.role === 'super_admin' || profile.role === 'pic' || profile.role === 'hub_head';
|
||||||
|
|
||||||
|
// Calculate Running / Total Balance
|
||||||
|
const totalBalance = donationsList.reduce((acc, curr) => {
|
||||||
|
if (curr.type === 'IN') {
|
||||||
|
return acc + Number(curr.amount);
|
||||||
|
} else {
|
||||||
|
return acc - Number(curr.amount);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Filter hubs list based on role
|
||||||
|
const allowedHubs = profile.role === 'super_admin'
|
||||||
|
? hubs
|
||||||
|
: hubs.filter(h => h.pic_id === profile.id || h.head_id === profile.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay active">
|
||||||
|
<div className="modal-content" style={{ maxWidth: '800px', width: '90%' }}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2><i className="fas fa-hand-holding-heart text-primary"></i> Manajemen Donasi / Kas</h2>
|
||||||
|
<button className="close-btn" onClick={() => setShowDonationModal(false)}>×</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body" style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: canEdit ? '1fr 1fr' : '1fr',
|
||||||
|
gap: '20px',
|
||||||
|
maxHeight: '60vh',
|
||||||
|
overflowY: 'auto'
|
||||||
|
}}>
|
||||||
|
|
||||||
|
{/* Form Transaksi (Only for PIC & Super Admin) */}
|
||||||
|
{canEdit && (
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 15px 0', fontSize: '1.1rem' }}>Tambah Transaksi</h3>
|
||||||
|
<form onSubmit={handleSaveDonation} style={{ display: 'flex', flexDirection: 'column', gap: '15px' }}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Rumah Ibadah</label>
|
||||||
|
<select
|
||||||
|
className="form-control"
|
||||||
|
value={donationFormData.hub_id}
|
||||||
|
onChange={(e) => setDonationFormData({...donationFormData, hub_id: e.target.value})}
|
||||||
|
required
|
||||||
|
disabled={profile.role !== 'super_admin'}
|
||||||
|
>
|
||||||
|
<option value="">Pilih Rumah Ibadah</option>
|
||||||
|
{allowedHubs.map(h => <option key={h.id} value={h.id}>{h.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Jenis Transaksi</label>
|
||||||
|
<select className="form-control" value={donationFormData.type} onChange={(e) => setDonationFormData({...donationFormData, type: e.target.value})} required>
|
||||||
|
<option value="IN">Pemasukan (Donasi/Sedekah)</option>
|
||||||
|
<option value="OUT">Pengeluaran (Bantuan/Operasional)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Nominal (Rp)</label>
|
||||||
|
<input type="number" className="form-control" value={donationFormData.amount} onChange={(e) => setDonationFormData({...donationFormData, amount: e.target.value})} min="1" required />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Keterangan</label>
|
||||||
|
<textarea className="form-control" value={donationFormData.description} onChange={(e) => setDonationFormData({...donationFormData, description: e.target.value})} required rows="3" placeholder="Contoh: Sedekah hamba Allah / Bantuan Sembako"></textarea>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Tanggal Transaksi</label>
|
||||||
|
<input type="date" className="form-control" value={donationFormData.transaction_date} onChange={(e) => setDonationFormData({...donationFormData, transaction_date: e.target.value})} required />
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={isSubmittingDonation} style={{ width: '100%', padding: '10px' }}>
|
||||||
|
<i className="fas fa-save"></i> {isSubmittingDonation ? 'Menyimpan...' : 'Simpan Transaksi'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Transaction Logs */}
|
||||||
|
<div style={{ background: '#f8fafc', padding: '15px', borderRadius: '8px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '15px', flexWrap: 'wrap', gap: '10px' }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '1.1rem' }}>Riwayat Transaksi</h3>
|
||||||
|
<div style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
background: totalBalance >= 0 ? '#dcfce7' : '#fee2e2',
|
||||||
|
color: totalBalance >= 0 ? '#15803d' : '#b91c1c',
|
||||||
|
borderRadius: '20px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: '0.85rem'
|
||||||
|
}}>
|
||||||
|
Saldo Kas: Rp{totalBalance.toLocaleString('id-ID')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{donationsList.length === 0 ? (
|
||||||
|
<p style={{ textAlign: 'center', color: '#94a3b8', fontSize: '0.9rem', padding: '20px 0' }}>Belum ada data transaksi.</p>
|
||||||
|
) : (
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: '#e2e8f0', textAlign: 'left' }}>
|
||||||
|
<th style={{ padding: '8px', borderBottom: '2px solid #cbd5e1' }}>Tanggal</th>
|
||||||
|
<th style={{ padding: '8px', borderBottom: '2px solid #cbd5e1' }}>Keterangan</th>
|
||||||
|
<th style={{ padding: '8px', borderBottom: '2px solid #cbd5e1', textAlign: 'right' }}>Nominal</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{donationsList.map(d => (
|
||||||
|
<tr key={d.id} style={{ borderBottom: '1px solid #e2e8f0' }}>
|
||||||
|
<td style={{ padding: '8px', color: '#475569' }}>{d.transaction_date}</td>
|
||||||
|
<td style={{ padding: '8px' }}>
|
||||||
|
<span style={{ fontWeight: '500', color: '#1e293b' }}>{d.description}</span>
|
||||||
|
{d.profiles && d.profiles.full_name && <div style={{ fontSize: '0.7rem', color: '#94a3b8' }}>Oleh: {d.profiles.full_name}</div>}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '8px', textAlign: 'right', fontWeight: 'bold', color: d.type === 'IN' ? '#10b981' : '#ef4444' }}>
|
||||||
|
{d.type === 'IN' ? '+' : '-'} Rp{Number(d.amount).toLocaleString('id-ID')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function HouseholdFormModal({
|
||||||
|
showHhModal,
|
||||||
|
setShowHhModal,
|
||||||
|
hhFormData,
|
||||||
|
setHhFormData,
|
||||||
|
handleSaveHh,
|
||||||
|
hubs,
|
||||||
|
imageFile,
|
||||||
|
setImageFile,
|
||||||
|
isUploading,
|
||||||
|
isEditMode,
|
||||||
|
profile
|
||||||
|
}) {
|
||||||
|
if (!showHhModal) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay active">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>{hhFormData.id ? 'Edit' : 'Tambah'} Data Warga</h2>
|
||||||
|
<button className="close-btn" onClick={() => setShowHhModal(false)}>×</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<form onSubmit={handleSaveHh} style={{ display: 'flex', flexDirection: 'column', gap: '15px' }}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Nama Kepala Keluarga</label>
|
||||||
|
<input type="text" className="form-control" value={hhFormData.head_name} onChange={e => setHhFormData({...hhFormData, head_name: e.target.value})} required />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>NIK</label>
|
||||||
|
<input type="text" className="form-control" value={hhFormData.nik} onChange={e => setHhFormData({...hhFormData, nik: e.target.value})} required />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>No. KK</label>
|
||||||
|
<input type="text" className="form-control" value={hhFormData.no_kk} onChange={e => setHhFormData({...hhFormData, no_kk: e.target.value})} required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Jumlah Anggota Keluarga</label>
|
||||||
|
<input type="number" className="form-control" value={hhFormData.family_size} onChange={e => setHhFormData({...hhFormData, family_size: e.target.value})} required min="1" />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Pilih Hub Rumah Ibadah {profile?.role === 'pic' ? '(Terkunci untuk Hub Anda)' : '(Opsional)'}</label>
|
||||||
|
<select
|
||||||
|
className="form-control"
|
||||||
|
value={hhFormData.assigned_hub_id || ''}
|
||||||
|
onChange={e => setHhFormData({...hhFormData, assigned_hub_id: e.target.value || null})}
|
||||||
|
disabled={profile?.role === 'pic'}
|
||||||
|
>
|
||||||
|
<option value="">-- Tidak Ada --</option>
|
||||||
|
{hubs.map(h => (
|
||||||
|
<option key={h.id} value={h.id}>{h.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Geotagging Photo Upload */}
|
||||||
|
<div className="form-group" style={{ background: '#f8fafc', padding: '15px', borderRadius: '8px', border: '1px dashed #cbd5e1' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '10px', fontWeight: 'bold' }}><i className="fas fa-camera"></i> Bukti Foto Lokasi / Rumah</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.files && e.target.files[0]) {
|
||||||
|
setImageFile(e.target.files[0]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ width: '100%', fontSize: '0.9rem' }}
|
||||||
|
required={false}
|
||||||
|
/>
|
||||||
|
{imageFile && <p style={{ fontSize: '0.8rem', color: '#10b981', marginTop: '5px' }}>File siap diunggah: {imageFile.name}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Latitude</label>
|
||||||
|
<input type="text" className="form-control" value={hhFormData.lat} readOnly />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Longitude</label>
|
||||||
|
<input type="text" className="form-control" value={hhFormData.lng} readOnly />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isEditMode && (
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={isUploading}>
|
||||||
|
{isUploading ? 'Menyimpan...' : 'Simpan'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function HubFormModal({
|
||||||
|
showHubModal,
|
||||||
|
setShowHubModal,
|
||||||
|
hubFormData,
|
||||||
|
setHubFormData,
|
||||||
|
handleSaveHub,
|
||||||
|
isEditMode
|
||||||
|
}) {
|
||||||
|
if (!showHubModal) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay active">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>{hubFormData.id ? 'Edit' : 'Tambah'} Rumah Ibadah</h2>
|
||||||
|
<button className="close-btn" onClick={() => setShowHubModal(false)}>×</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<form onSubmit={handleSaveHub} style={{ display: 'flex', flexDirection: 'column', gap: '15px' }}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Nama Rumah Ibadah</label>
|
||||||
|
<input type="text" className="form-control" value={hubFormData.name} onChange={e => setHubFormData({...hubFormData, name: e.target.value})} required />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Tipe</label>
|
||||||
|
<select className="form-control" value={hubFormData.type} onChange={e => setHubFormData({...hubFormData, type: e.target.value})}>
|
||||||
|
<option value="Mosque">Masjid</option>
|
||||||
|
<option value="Church">Gereja</option>
|
||||||
|
<option value="Temple">Pura</option>
|
||||||
|
<option value="Vihara">Vihara</option>
|
||||||
|
<option value="Other">Lainnya</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Radius Jangkauan (Meter)</label>
|
||||||
|
<input type="number" className="form-control" value={hubFormData.radius_meter} onChange={e => setHubFormData({...hubFormData, radius_meter: e.target.value})} required />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Nomor Kontak PIC</label>
|
||||||
|
<input type="text" className="form-control" value={hubFormData.pic_phone || ''} onChange={e => setHubFormData({...hubFormData, pic_phone: e.target.value})} placeholder="Contoh: 08123456789" />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Latitude</label>
|
||||||
|
<input type="text" className="form-control" value={hubFormData.lat} readOnly />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Longitude</label>
|
||||||
|
<input type="text" className="form-control" value={hubFormData.lng} readOnly />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isEditMode && (
|
||||||
|
<button type="submit" className="btn btn-primary" style={{ marginTop: '10px' }}>
|
||||||
|
<i className="fas fa-save"></i> Simpan
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function PicListModal({
|
||||||
|
showPicListModal,
|
||||||
|
setShowPicListModal,
|
||||||
|
myHub,
|
||||||
|
usersList
|
||||||
|
}) {
|
||||||
|
if (!showPicListModal || !myHub) return null;
|
||||||
|
|
||||||
|
// Filter PICs assigned to this hub
|
||||||
|
const assignedPics = usersList.filter(u => u.role === 'pic' && u.id === myHub.pic_id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay active">
|
||||||
|
<div className="modal-content" style={{ maxWidth: '500px', width: '90%' }}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>
|
||||||
|
<i className="fas fa-id-card text-primary" style={{ marginRight: '8px' }}></i>
|
||||||
|
Daftar PIC Lapangan
|
||||||
|
</h2>
|
||||||
|
<button className="close-btn" onClick={() => setShowPicListModal(false)}>×</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: '15px', padding: '10px 0' }}>
|
||||||
|
<p style={{ margin: 0, fontSize: '0.9rem', color: 'var(--text-muted)' }}>
|
||||||
|
Berikut adalah akun PIC / Surveyor Lapangan yang ditugaskan untuk mengelola dan menyurvei wilayah jangkauan <strong>{myHub.name}</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', marginTop: '10px' }}>
|
||||||
|
{assignedPics.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '20px', background: '#f8fafc', borderRadius: '8px', color: '#94a3b8', border: '1px dashed #cbd5e1' }}>
|
||||||
|
<i className="fas fa-user-slash" style={{ fontSize: '1.5rem', marginBottom: '8px', display: 'block' }}></i>
|
||||||
|
Belum ada akun PIC yang ditugaskan ke Hub ini.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
assignedPics.map(pic => (
|
||||||
|
<div key={pic.id} style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '15px',
|
||||||
|
padding: '15px',
|
||||||
|
background: 'rgba(59, 130, 246, 0.05)',
|
||||||
|
border: '1px solid rgba(59, 130, 246, 0.15)',
|
||||||
|
borderRadius: '8px'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: '45px',
|
||||||
|
height: '45px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'var(--primary)',
|
||||||
|
color: 'white',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: '1.1rem'
|
||||||
|
}}>
|
||||||
|
{pic.full_name ? pic.full_name.charAt(0).toUpperCase() : 'P'}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<h4 style={{ margin: '0 0 4px 0', fontSize: '0.95rem', fontWeight: 'bold', color: 'var(--text-main)' }}>{pic.full_name}</h4>
|
||||||
|
<p style={{ margin: 0, fontSize: '0.8rem', color: '#64748b' }}><i className="fas fa-envelope" style={{ marginRight: '6px' }}></i>{pic.email}</p>
|
||||||
|
</div>
|
||||||
|
<span style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
background: '#dbeafe',
|
||||||
|
color: '#3b82f6',
|
||||||
|
textTransform: 'uppercase'
|
||||||
|
}}>
|
||||||
|
PIC Surveyor
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function ProfileModal({
|
||||||
|
showProfileModal,
|
||||||
|
setShowProfileModal,
|
||||||
|
profile,
|
||||||
|
user,
|
||||||
|
handleLogout,
|
||||||
|
isLoggingOut
|
||||||
|
}) {
|
||||||
|
if (!showProfileModal) return null;
|
||||||
|
|
||||||
|
const getRoleBadge = (role) => {
|
||||||
|
switch (role) {
|
||||||
|
case 'super_admin':
|
||||||
|
return {
|
||||||
|
label: 'Super Admin',
|
||||||
|
bg: '#fee2e2',
|
||||||
|
color: '#ef4444',
|
||||||
|
icon: 'fa-shield-halved'
|
||||||
|
};
|
||||||
|
case 'pic':
|
||||||
|
return {
|
||||||
|
label: 'Surveyor / PIC Lapangan',
|
||||||
|
bg: '#dbeafe',
|
||||||
|
color: '#3b82f6',
|
||||||
|
icon: 'fa-route'
|
||||||
|
};
|
||||||
|
case 'hub_head':
|
||||||
|
return {
|
||||||
|
label: 'Ketua Pengurus Rumah Ibadah',
|
||||||
|
bg: '#fef3c7',
|
||||||
|
color: '#d97706',
|
||||||
|
icon: 'fa-mosque'
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
label: 'Pengguna',
|
||||||
|
bg: '#f1f5f9',
|
||||||
|
color: '#475569',
|
||||||
|
icon: 'fa-user'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const badge = getRoleBadge(profile?.role);
|
||||||
|
const initials = profile?.full_name ? profile.full_name.substring(0, 2).toUpperCase() : 'US';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay active" onClick={() => setShowProfileModal(false)}>
|
||||||
|
<div
|
||||||
|
className="modal-content"
|
||||||
|
style={{ maxWidth: '400px', width: '90%', textAlign: 'center', padding: '30px' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="modal-header" style={{ marginBottom: '10px', justifyContent: 'center', position: 'relative' }}>
|
||||||
|
<h2 style={{ fontSize: '1.3rem', margin: 0, fontWeight: '700' }}>Profil Pengguna</h2>
|
||||||
|
<button
|
||||||
|
className="close-btn"
|
||||||
|
onClick={() => setShowProfileModal(false)}
|
||||||
|
style={{ position: 'absolute', right: 0, top: '50%', transform: 'translateY(-50%)' }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '20px', padding: '10px 0 0 0' }}>
|
||||||
|
{/* Avatar Area */}
|
||||||
|
<div style={{
|
||||||
|
background: 'linear-gradient(135deg, var(--primary), #6366f1)',
|
||||||
|
color: 'white',
|
||||||
|
width: '80px',
|
||||||
|
height: '80px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
fontSize: '2rem',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
boxShadow: '0 10px 25px rgba(59, 130, 246, 0.4)',
|
||||||
|
marginBottom: '5px'
|
||||||
|
}}>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Details */}
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 4px 0', fontSize: '1.25rem', fontWeight: '700', color: '#1e293b' }}>
|
||||||
|
{profile?.full_name || 'Pengguna Sinergi Umat'}
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: '0 0 12px 0', color: '#64748b', fontSize: '0.9rem' }}>
|
||||||
|
{user?.email}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Role Badge */}
|
||||||
|
<span style={{
|
||||||
|
padding: '6px 14px',
|
||||||
|
borderRadius: '20px',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontWeight: '700',
|
||||||
|
background: badge.bg,
|
||||||
|
color: badge.color,
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
boxShadow: `0 2px 8px ${badge.bg}`
|
||||||
|
}}>
|
||||||
|
<i className={`fas ${badge.icon}`}></i> {badge.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr style={{ width: '100%', border: '0', borderTop: '1px solid #e2e8f0', margin: '5px 0' }} />
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
disabled={isLoggingOut}
|
||||||
|
className="btn"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #ef4444, #dc2626)',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
boxShadow: '0 4px 12px rgba(239, 68, 68, 0.3)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => e.currentTarget.style.transform = 'translateY(-2px)'}
|
||||||
|
onMouseOut={(e) => e.currentTarget.style.transform = 'translateY(0)'}
|
||||||
|
>
|
||||||
|
<i className="fas fa-sign-out-alt"></i> {isLoggingOut ? 'Keluar...' : 'Keluar dari Akun (Logout)'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowProfileModal(false)}
|
||||||
|
className="btn btn-outline"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(0,0,0,0.05)',
|
||||||
|
color: '#475569',
|
||||||
|
border: '1px solid rgba(0,0,0,0.1)',
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontWeight: '500',
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => e.currentTarget.style.background = 'rgba(0,0,0,0.1)'}
|
||||||
|
onMouseOut={(e) => e.currentTarget.style.background = 'rgba(0,0,0,0.05)'}
|
||||||
|
>
|
||||||
|
Kembali ke Dasbor
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
|
||||||
|
// Pure JavaScript GPS EXIF metadata reader
|
||||||
|
function getEXIFGPS(arrayBuffer) {
|
||||||
|
const dataView = new DataView(arrayBuffer);
|
||||||
|
if (dataView.getUint16(0, false) !== 0xFFD8) {
|
||||||
|
return null; // Not a JPEG
|
||||||
|
}
|
||||||
|
let offset = 2;
|
||||||
|
const length = dataView.byteLength;
|
||||||
|
let exifOffset = null;
|
||||||
|
|
||||||
|
while (offset < length) {
|
||||||
|
const marker = dataView.getUint16(offset, false);
|
||||||
|
offset += 2;
|
||||||
|
if (marker === 0xFFE1) { // APP1 segment containing EXIF
|
||||||
|
const segmentLength = dataView.getUint16(offset, false);
|
||||||
|
if (dataView.getUint32(offset + 2, false) === 0x45786966) { // "Exif"
|
||||||
|
exifOffset = offset + 8;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
offset += segmentLength;
|
||||||
|
} else if ((marker & 0xFF00) === 0xFF00) {
|
||||||
|
const segmentLength = dataView.getUint16(offset, false);
|
||||||
|
offset += segmentLength;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!exifOffset) return null;
|
||||||
|
|
||||||
|
const littleEndian = dataView.getUint16(exifOffset, false) === 0x4949;
|
||||||
|
const ifd0Offset = dataView.getUint32(exifOffset + 4, littleEndian);
|
||||||
|
|
||||||
|
function parseIFD(offset, tiffOffset) {
|
||||||
|
try {
|
||||||
|
const entriesCount = dataView.getUint16(tiffOffset + offset, littleEndian);
|
||||||
|
const tags = {};
|
||||||
|
|
||||||
|
for (let i = 0; i < entriesCount; i++) {
|
||||||
|
const entryOffset = tiffOffset + offset + 2 + i * 12;
|
||||||
|
if (entryOffset + 12 > dataView.byteLength) break;
|
||||||
|
const tag = dataView.getUint16(entryOffset, littleEndian);
|
||||||
|
const type = dataView.getUint16(entryOffset + 2, littleEndian);
|
||||||
|
const count = dataView.getUint32(entryOffset + 4, littleEndian);
|
||||||
|
const valueOffset = dataView.getUint32(entryOffset + 8, littleEndian);
|
||||||
|
|
||||||
|
tags[tag] = { type, count, offset: valueOffset, rawOffset: entryOffset + 8 };
|
||||||
|
}
|
||||||
|
return tags;
|
||||||
|
} catch (e) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ifd0 = parseIFD(ifd0Offset, exifOffset);
|
||||||
|
if (ifd0[0x8825]) { // GPS IFD Pointer
|
||||||
|
const gpsOffset = ifd0[0x8825].offset;
|
||||||
|
const gpsIFD = parseIFD(gpsOffset, exifOffset);
|
||||||
|
|
||||||
|
const readRational = (valueOffset) => {
|
||||||
|
const targetOffset = exifOffset + valueOffset;
|
||||||
|
if (targetOffset + 8 > dataView.byteLength) return 0;
|
||||||
|
const num = dataView.getUint32(targetOffset, littleEndian);
|
||||||
|
const den = dataView.getUint32(targetOffset + 4, littleEndian);
|
||||||
|
return den === 0 ? 0 : num / den;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readCoordinate = (tag) => {
|
||||||
|
if (!gpsIFD[tag]) return null;
|
||||||
|
const offset = gpsIFD[tag].offset;
|
||||||
|
const deg = readRational(offset);
|
||||||
|
const min = readRational(offset + 8);
|
||||||
|
const sec = readRational(offset + 16);
|
||||||
|
return deg + min / 60 + sec / 3600;
|
||||||
|
};
|
||||||
|
|
||||||
|
let lat = readCoordinate(2); // GPSLatitude
|
||||||
|
let lng = readCoordinate(4); // GPSLongitude
|
||||||
|
|
||||||
|
if (lat !== null && lng !== null) {
|
||||||
|
if (gpsIFD[1]) { // GPSLatitudeRef
|
||||||
|
const refOffset = gpsIFD[1].rawOffset;
|
||||||
|
const refChar = String.fromCharCode(dataView.getUint8(refOffset));
|
||||||
|
if (refChar === 'S') lat = -lat;
|
||||||
|
}
|
||||||
|
if (gpsIFD[3]) { // GPSLongitudeRef
|
||||||
|
const refOffset = gpsIFD[3].rawOffset;
|
||||||
|
const refChar = String.fromCharCode(dataView.getUint8(refOffset));
|
||||||
|
if (refChar === 'W') lng = -lng;
|
||||||
|
}
|
||||||
|
return { lat, lng };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GPS parsing error:', err);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SurveyMethodModal({
|
||||||
|
showSurveyModal,
|
||||||
|
setShowSurveyModal,
|
||||||
|
setPlacementMode,
|
||||||
|
setShowToast,
|
||||||
|
hubs,
|
||||||
|
mapInstance,
|
||||||
|
setHhFormData,
|
||||||
|
setImageFile,
|
||||||
|
setShowHhModal,
|
||||||
|
showToastNotification,
|
||||||
|
profile
|
||||||
|
}) {
|
||||||
|
const [optionMode, setOptionMode] = useState(null); // 'upload' or null
|
||||||
|
const [gpsData, setGpsData] = useState(null);
|
||||||
|
const [errorMsg, setErrorMsg] = useState(null);
|
||||||
|
const [tempFile, setTempFile] = useState(null);
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
if (!showSurveyModal) return null;
|
||||||
|
|
||||||
|
const resetSurveyModal = () => {
|
||||||
|
setOptionMode(null);
|
||||||
|
setGpsData(null);
|
||||||
|
setErrorMsg(null);
|
||||||
|
setTempFile(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectMapOption = () => {
|
||||||
|
setPlacementMode('household');
|
||||||
|
setShowToast(true);
|
||||||
|
setShowSurveyModal(false);
|
||||||
|
resetSurveyModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setErrorMsg(null);
|
||||||
|
setGpsData(null);
|
||||||
|
setTempFile(null);
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const buffer = event.target.result;
|
||||||
|
const coords = getEXIFGPS(buffer);
|
||||||
|
|
||||||
|
if (coords) {
|
||||||
|
setGpsData(coords);
|
||||||
|
setTempFile(file);
|
||||||
|
showToastNotification('Metadata lokasi GPS berhasil ditarik!');
|
||||||
|
} else {
|
||||||
|
setErrorMsg('Foto ini tidak memiliki metadata lokasi GPS (geotag). Silakan ambil foto baru menggunakan kamera ponsel dengan mengaktifkan GPS / Lokasi, atau gunakan opsi "Tentukan Posisi Melalui Peta".');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
setErrorMsg('Gagal membaca berkas gambar.');
|
||||||
|
};
|
||||||
|
reader.readAsArrayBuffer(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContinueSurvey = () => {
|
||||||
|
if (!gpsData || !tempFile) return;
|
||||||
|
|
||||||
|
// Spatially calculate closest hub using Haversine / Leaflet map distance
|
||||||
|
let closestHub = null;
|
||||||
|
let minDistance = Infinity;
|
||||||
|
|
||||||
|
hubs.forEach(hub => {
|
||||||
|
if (mapInstance && mapInstance.current) {
|
||||||
|
const dist = mapInstance.current.distance([gpsData.lat, gpsData.lng], [hub.lat, hub.lng]);
|
||||||
|
const hubRadius = parseInt(hub.radius) || 500;
|
||||||
|
if (dist <= hubRadius && dist < minDistance) {
|
||||||
|
minDistance = dist;
|
||||||
|
closestHub = hub;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// For non-admin, if they use photo geotag, they can add anywhere.
|
||||||
|
// If it's outside their own hub's radius, we assign it to their hub anyway but flag it with manual override.
|
||||||
|
let finalAssignedHubId = closestHub ? closestHub.id : null;
|
||||||
|
let forceManualOverride = false;
|
||||||
|
|
||||||
|
if (profile?.role !== 'super_admin') {
|
||||||
|
const myHub = hubs.find(h => h.pic_id === profile?.id || h.head_id === profile?.id);
|
||||||
|
if (myHub) {
|
||||||
|
const isInsideRadius = closestHub && closestHub.id === myHub.id;
|
||||||
|
if (!isInsideRadius) {
|
||||||
|
// Outside their hub's radius! Force assignment to myHub and flag with manual override
|
||||||
|
finalAssignedHubId = myHub.id;
|
||||||
|
forceManualOverride = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setHhFormData({
|
||||||
|
id: '',
|
||||||
|
head_name: '',
|
||||||
|
nik: '',
|
||||||
|
no_kk: '',
|
||||||
|
family_size: 1,
|
||||||
|
status: 'Pending',
|
||||||
|
photo_url: '',
|
||||||
|
lat: gpsData.lat,
|
||||||
|
lng: gpsData.lng,
|
||||||
|
assigned_hub_id: finalAssignedHubId,
|
||||||
|
manual_override_assigned_hub: forceManualOverride
|
||||||
|
});
|
||||||
|
|
||||||
|
setImageFile(tempFile);
|
||||||
|
setShowSurveyModal(false);
|
||||||
|
setShowHhModal(true);
|
||||||
|
resetSurveyModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay active" onClick={() => { setShowSurveyModal(false); resetSurveyModal(); }}>
|
||||||
|
<div
|
||||||
|
className="modal-content"
|
||||||
|
style={{ maxWidth: '500px', width: '90%', padding: '24px' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2><i className="fas fa-house-medical text-primary"></i> Pendataan KK Baru</h2>
|
||||||
|
<button className="close-btn" onClick={() => { setShowSurveyModal(false); resetSurveyModal(); }}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||||
|
|
||||||
|
{optionMode === null ? (
|
||||||
|
<>
|
||||||
|
<p style={{ margin: '0 0 10px 0', color: '#64748b', fontSize: '0.95rem', lineHeight: '1.5' }}>
|
||||||
|
Silakan pilih salah satu opsi di bawah untuk menentukan lokasi koordinat rumah warga yang didata:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
{/* Option 1: Map click */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSelectMapOption}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(59, 130, 246, 0.08)',
|
||||||
|
border: '1px solid rgba(59, 130, 246, 0.25)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
width: '100%',
|
||||||
|
color: '#1e293b'
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.15)';
|
||||||
|
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||||
|
}}
|
||||||
|
onMouseOut={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.08)';
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--primary, #3b82f6)',
|
||||||
|
color: 'white',
|
||||||
|
width: '40px', height: '40px', borderRadius: '50%',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: '1.25rem', flexShrink: 0
|
||||||
|
}}>
|
||||||
|
<i className="fas fa-map-location-dot"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 style={{ margin: '0 0 4px 0', fontSize: '0.95rem', fontWeight: '700' }}>Tentukan Posisi Melalui Peta</h4>
|
||||||
|
<p style={{ margin: 0, fontSize: '0.8rem', color: '#64748b' }}>Pilih lokasi rumah warga secara manual dengan menunjuknya langsung di peta interaktif.</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Option 2: Geotagging upload */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOptionMode('upload')}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(16, 185, 129, 0.08)',
|
||||||
|
border: '1px solid rgba(16, 185, 129, 0.25)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
width: '100%',
|
||||||
|
color: '#1e293b'
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(16, 185, 129, 0.15)';
|
||||||
|
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||||
|
}}
|
||||||
|
onMouseOut={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(16, 185, 129, 0.08)';
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--success, #10b981)',
|
||||||
|
color: 'white',
|
||||||
|
width: '40px', height: '40px', borderRadius: '50%',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: '1.25rem', flexShrink: 0
|
||||||
|
}}>
|
||||||
|
<i className="fas fa-camera-retro"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 style={{ margin: '0 0 4px 0', fontSize: '0.95rem', fontWeight: '700' }}>Unggah Foto dengan Geotag</h4>
|
||||||
|
<p style={{ margin: 0, fontSize: '0.8rem', color: '#64748b' }}>Ekstraksi koordinat otomatis dari metadata GPS foto rumah warga yang diambil langsung di lapangan.</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOptionMode(null)}
|
||||||
|
style={{ background: 'none', border: 'none', color: '#64748b', cursor: 'pointer', fontSize: '0.9rem' }}
|
||||||
|
>
|
||||||
|
<i className="fas fa-arrow-left"></i> Kembali
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 style={{ fontSize: '0.95rem', fontWeight: '600', margin: 0 }}>Metode Unggah Foto Ber-Geotag</h4>
|
||||||
|
<p style={{ margin: 0, color: '#64748b', fontSize: '0.85rem', lineHeight: '1.4' }}>
|
||||||
|
Unggah berkas foto JPG/JPEG rumah warga yang diambil dari kamera ber-GPS aktif. Sistem akan menarik metadata lokasi secara otomatis.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '2px dashed #cbd5e1',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '24px 16px',
|
||||||
|
textAlign: 'center',
|
||||||
|
background: 'rgba(0,0,0,0.02)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'border-color 0.2s'
|
||||||
|
}}
|
||||||
|
onClick={() => fileInputRef.current && fileInputRef.current.click()}
|
||||||
|
onMouseOver={(e) => e.currentTarget.style.borderColor = 'var(--success, #10b981)'}
|
||||||
|
onMouseOut={(e) => e.currentTarget.style.borderColor = '#cbd5e1'}
|
||||||
|
>
|
||||||
|
<i className="fas fa-cloud-arrow-up" style={{ fontSize: '2.5rem', color: '#94a3b8', marginBottom: '10px', display: 'block' }}></i>
|
||||||
|
<span style={{ fontSize: '0.9rem', color: '#475569', fontWeight: '500' }}>
|
||||||
|
{tempFile ? tempFile.name : 'Klik untuk Pilih Foto Rumah'}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
accept="image/jpeg,image/jpg"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorMsg && (
|
||||||
|
<div style={{ background: '#fee2e2', color: '#ef4444', padding: '12px', borderRadius: '8px', fontSize: '0.8rem', lineHeight: '1.4', fontWeight: '500' }}>
|
||||||
|
<i className="fas fa-exclamation-triangle" style={{ marginRight: '6px' }}></i> {errorMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{gpsData && (
|
||||||
|
<div style={{ background: '#d1fae5', color: '#065f46', padding: '12px', borderRadius: '8px', fontSize: '0.85rem', fontWeight: '500' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '4px' }}>
|
||||||
|
<i className="fas fa-circle-check" style={{ color: '#10b981' }}></i>
|
||||||
|
<strong>Metadata Lokasi Terbaca!</strong>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', fontFamily: 'monospace', marginTop: '4px', opacity: 0.9 }}>
|
||||||
|
Lintang: {gpsData.lat.toFixed(6)} | Bujur: {gpsData.lng.toFixed(6)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '12px', marginTop: '10px' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleContinueSurvey}
|
||||||
|
disabled={!gpsData}
|
||||||
|
className="btn btn-success"
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: '12px', borderRadius: '10px',
|
||||||
|
fontWeight: '600', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px',
|
||||||
|
opacity: gpsData ? 1 : 0.5, cursor: gpsData ? 'pointer' : 'not-allowed'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fas fa-arrow-right"></i> Lanjutkan Pengisian Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function UserProvisioningModal({
|
||||||
|
showUserModal,
|
||||||
|
setShowUserModal,
|
||||||
|
usersList,
|
||||||
|
profile,
|
||||||
|
userFormData,
|
||||||
|
setUserFormData,
|
||||||
|
handleCreateUser,
|
||||||
|
isSubmittingUser,
|
||||||
|
handleDeleteUser,
|
||||||
|
hubs
|
||||||
|
}) {
|
||||||
|
if (!showUserModal) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay active">
|
||||||
|
<div className="modal-content" style={{ maxWidth: '900px', width: '90%' }}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2><i className="fas fa-users-cog text-primary"></i> Manajemen Pengguna</h2>
|
||||||
|
<button className="close-btn" onClick={() => setShowUserModal(false)}>×</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: '20px', maxHeight: '70vh', overflowY: 'auto' }}>
|
||||||
|
|
||||||
|
{/* Form Tambah Pengguna */}
|
||||||
|
<div style={{ background: '#f8fafc', padding: '20px', borderRadius: '8px', border: '1px solid #e2e8f0' }}>
|
||||||
|
<h3 style={{ margin: '0 0 15px 0', fontSize: '1.1rem' }}>Tambah Pengguna Baru</h3>
|
||||||
|
<form onSubmit={handleCreateUser} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '15px' }}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Email</label>
|
||||||
|
<input type="email" className="form-control" value={userFormData.email} onChange={(e) => setUserFormData({...userFormData, email: e.target.value})} required />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Kata Sandi Sementara</label>
|
||||||
|
<input type="text" className="form-control" value={userFormData.password} onChange={(e) => setUserFormData({...userFormData, password: e.target.value})} required minLength="6" />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Nama Lengkap</label>
|
||||||
|
<input type="text" className="form-control" value={userFormData.full_name} onChange={(e) => setUserFormData({...userFormData, full_name: e.target.value})} required />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Peran (Role)</label>
|
||||||
|
<select className="form-control" value={userFormData.role} onChange={(e) => setUserFormData({...userFormData, role: e.target.value, hub_id: ''})} required>
|
||||||
|
<option value="pic">Surveyor / PIC Lapangan</option>
|
||||||
|
<option value="hub_head">Ketua Pengurus Rumah Ibadah</option>
|
||||||
|
<option value="super_admin">Super Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{(userFormData.role === 'pic' || userFormData.role === 'hub_head') && (
|
||||||
|
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
|
||||||
|
<label>Tugaskan ke Rumah Ibadah (Hub)</label>
|
||||||
|
<select className="form-control" value={userFormData.hub_id || ''} onChange={(e) => setUserFormData({...userFormData, hub_id: e.target.value})} required>
|
||||||
|
<option value="">Pilih Rumah Ibadah</option>
|
||||||
|
{hubs.map(h => (
|
||||||
|
<option key={h.id} value={h.id}>{h.name} ({h.type === 'Mosque' ? 'Masjid' : h.type === 'Church' ? 'Gereja' : h.type === 'Temple' ? 'Pura' : h.type === 'Vihara' ? 'Vihara' : 'Lainnya'})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ gridColumn: '1 / -1', display: 'flex', justifyContent: 'flex-end', marginTop: '10px' }}>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={isSubmittingUser}>
|
||||||
|
<i className="fas fa-plus"></i> {isSubmittingUser ? 'Menyimpan...' : 'Buat Akun'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabel Pengguna */}
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 15px 0', fontSize: '1.1rem' }}>Daftar Pengguna</h3>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.9rem' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: '#f1f5f9', textAlign: 'left' }}>
|
||||||
|
<th style={{ padding: '10px', borderBottom: '2px solid #e2e8f0' }}>Nama</th>
|
||||||
|
<th style={{ padding: '10px', borderBottom: '2px solid #e2e8f0' }}>Email</th>
|
||||||
|
<th style={{ padding: '10px', borderBottom: '2px solid #e2e8f0' }}>Role</th>
|
||||||
|
<th style={{ padding: '10px', borderBottom: '2px solid #e2e8f0', textAlign: 'right' }}>Aksi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{usersList.length === 0 ? (
|
||||||
|
<tr><td colSpan="4" style={{ textAlign: 'center', padding: '20px', color: '#64748b' }}>Memuat data pengguna...</td></tr>
|
||||||
|
) : (
|
||||||
|
usersList.map(u => (
|
||||||
|
<tr key={u.id} style={{ borderBottom: '1px solid #e2e8f0' }}>
|
||||||
|
<td style={{ padding: '10px', fontWeight: '500' }}>{u.full_name}</td>
|
||||||
|
<td style={{ padding: '10px', color: '#475569' }}>{u.email}</td>
|
||||||
|
<td style={{ padding: '10px' }}>
|
||||||
|
<span style={{
|
||||||
|
padding: '4px 8px', borderRadius: '4px', fontSize: '0.8rem', fontWeight: 'bold',
|
||||||
|
background: u.role === 'super_admin' ? '#fee2e2' : u.role === 'pic' ? '#dbeafe' : '#fef3c7',
|
||||||
|
color: u.role === 'super_admin' ? '#ef4444' : u.role === 'pic' ? '#3b82f6' : '#d97706'
|
||||||
|
}}>
|
||||||
|
{u.role.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px', textAlign: 'right' }}>
|
||||||
|
{u.id !== profile?.id && (
|
||||||
|
<button onClick={() => handleDeleteUser(u.id)} className="btn btn-outline" style={{ padding: '4px 8px', fontSize: '0.8rem', color: '#ef4444', borderColor: '#ef4444' }}>
|
||||||
|
<i className="fas fa-trash"></i> Hapus
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
/**
|
||||||
|
* A safe, modular Leaflet Heatmap Layer factory that does NOT mutate the
|
||||||
|
* read-only/frozen Leaflet ES module (L) instance.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { createHeatLayer } from '@/lib/initLeafletHeat';
|
||||||
|
* ...
|
||||||
|
* heatLayersRef.current = createHeatLayer(L, points, options).addTo(map);
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── simpleheat (inlined from mourner/simpleheat) ───────────────────────────
|
||||||
|
function simpleheat(canvas) {
|
||||||
|
if (!(this instanceof simpleheat)) return new simpleheat(canvas);
|
||||||
|
this._canvas = canvas = typeof canvas === 'string'
|
||||||
|
? document.getElementById(canvas)
|
||||||
|
: canvas;
|
||||||
|
this._ctx = canvas.getContext('2d');
|
||||||
|
this._width = canvas.width;
|
||||||
|
this._height = canvas.height;
|
||||||
|
this._max = 1;
|
||||||
|
this.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
simpleheat.prototype = {
|
||||||
|
defaultRadius: 25,
|
||||||
|
defaultGradient: { 0.4: 'blue', 0.6: 'cyan', 0.7: 'lime', 0.8: 'yellow', 1: 'red' },
|
||||||
|
|
||||||
|
data(data) { this._data = data; return this; },
|
||||||
|
max(max) { this._max = max; return this; },
|
||||||
|
add(point) { this._data.push(point); return this; },
|
||||||
|
clear() { this._data = []; return this; },
|
||||||
|
|
||||||
|
radius(r, blur) {
|
||||||
|
blur = blur || 15;
|
||||||
|
const circle = this._circle = document.createElement('canvas');
|
||||||
|
const ctx = circle.getContext('2d');
|
||||||
|
const e = this._r = r + blur;
|
||||||
|
circle.width = circle.height = 2 * e;
|
||||||
|
ctx.shadowOffsetX = ctx.shadowOffsetY = 200;
|
||||||
|
ctx.shadowBlur = blur;
|
||||||
|
ctx.shadowColor = 'black';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(e - 200, e - 200, r, 0, Math.PI * 2, true);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
gradient(grad) {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const grad2 = ctx.createLinearGradient(0, 0, 0, 256);
|
||||||
|
canvas.width = 1;
|
||||||
|
canvas.height = 256;
|
||||||
|
for (const i in grad) grad2.addColorStop(+i, grad[i]);
|
||||||
|
ctx.fillStyle = grad2;
|
||||||
|
ctx.fillRect(0, 0, 1, 256);
|
||||||
|
this._grad = ctx.getImageData(0, 0, 1, 256).data;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
draw(minOpacity) {
|
||||||
|
if (!this._circle) this.radius(this.defaultRadius);
|
||||||
|
if (!this._grad) this.gradient(this.defaultGradient);
|
||||||
|
const ctx = this._ctx;
|
||||||
|
ctx.clearRect(0, 0, this._width, this._height);
|
||||||
|
for (let i = 0, len = this._data.length; i < len; i++) {
|
||||||
|
const p = this._data[i];
|
||||||
|
ctx.globalAlpha = Math.max(p[2] / this._max, minOpacity || 0.05);
|
||||||
|
ctx.drawImage(this._circle, p[0] - this._r, p[1] - this._r);
|
||||||
|
}
|
||||||
|
const img = ctx.getImageData(0, 0, this._width, this._height);
|
||||||
|
this._colorize(img.data, this._grad);
|
||||||
|
ctx.putImageData(img, 0, 0);
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
_colorize(pixels, grad) {
|
||||||
|
for (let j = 3, len = pixels.length; j < len; j += 4) {
|
||||||
|
const k = pixels[j] * 4;
|
||||||
|
if (k) {
|
||||||
|
pixels[j - 3] = grad[k];
|
||||||
|
pixels[j - 2] = grad[k + 1];
|
||||||
|
pixels[j - 1] = grad[k + 2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cache the HeatLayer class once defined per L instance
|
||||||
|
let HeatLayerClass = null;
|
||||||
|
|
||||||
|
export function createHeatLayer(L, latlngs, options) {
|
||||||
|
if (!HeatLayerClass) {
|
||||||
|
HeatLayerClass = (L.Layer ? L.Layer : L.Class).extend({
|
||||||
|
initialize(latlngs, options) {
|
||||||
|
this._latlngs = latlngs;
|
||||||
|
L.setOptions(this, options);
|
||||||
|
},
|
||||||
|
|
||||||
|
setLatLngs(latlngs) { this._latlngs = latlngs; return this.redraw(); },
|
||||||
|
addLatLng(latlng) { this._latlngs.push(latlng); return this.redraw(); },
|
||||||
|
|
||||||
|
setOptions(options) {
|
||||||
|
L.setOptions(this, options);
|
||||||
|
if (this._heat) this._updateOptions();
|
||||||
|
return this.redraw();
|
||||||
|
},
|
||||||
|
|
||||||
|
redraw() {
|
||||||
|
if (!this._heat || this._frame || this._map._animating) return this;
|
||||||
|
this._frame = L.Util.requestAnimFrame(this._redraw, this);
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
onAdd(map) {
|
||||||
|
this._map = map;
|
||||||
|
if (!this._canvas) this._initCanvas();
|
||||||
|
map.getPanes().overlayPane.appendChild(this._canvas);
|
||||||
|
map.on('moveend', this._reset, this);
|
||||||
|
if (map.options.zoomAnimation && L.Browser.any3d) {
|
||||||
|
map.on('zoomanim', this._animateZoom, this);
|
||||||
|
}
|
||||||
|
this._reset();
|
||||||
|
},
|
||||||
|
|
||||||
|
onRemove(map) {
|
||||||
|
map.getPanes().overlayPane.removeChild(this._canvas);
|
||||||
|
map.off('moveend', this._reset, this);
|
||||||
|
if (map.options.zoomAnimation) {
|
||||||
|
map.off('zoomanim', this._animateZoom, this);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addTo(map) { map.addLayer(this); return this; },
|
||||||
|
|
||||||
|
_initCanvas() {
|
||||||
|
const canvas = this._canvas = L.DomUtil.create('canvas', 'leaflet-heatmap-layer leaflet-layer');
|
||||||
|
const originProp = L.DomUtil.testProp(['transformOrigin', 'WebkitTransformOrigin', 'msTransformOrigin']);
|
||||||
|
canvas.style[originProp] = '50% 50%';
|
||||||
|
const size = this._map.getSize();
|
||||||
|
canvas.width = size.x;
|
||||||
|
canvas.height = size.y;
|
||||||
|
const animated = this._map.options.zoomAnimation && L.Browser.any3d;
|
||||||
|
L.DomUtil.addClass(canvas, 'leaflet-zoom-' + (animated ? 'animated' : 'hide'));
|
||||||
|
this._heat = simpleheat(canvas);
|
||||||
|
this._updateOptions();
|
||||||
|
},
|
||||||
|
|
||||||
|
_updateOptions() {
|
||||||
|
this._heat.radius(this.options.radius || this._heat.defaultRadius, this.options.blur);
|
||||||
|
if (this.options.gradient) this._heat.gradient(this.options.gradient);
|
||||||
|
if (this.options.max) this._heat.max(this.options.max);
|
||||||
|
},
|
||||||
|
|
||||||
|
_reset() {
|
||||||
|
const topLeft = this._map.containerPointToLayerPoint([0, 0]);
|
||||||
|
L.DomUtil.setPosition(this._canvas, topLeft);
|
||||||
|
const size = this._map.getSize();
|
||||||
|
if (this._heat._width !== size.x) this._canvas.width = this._heat._width = size.x;
|
||||||
|
if (this._heat._height !== size.y) this._canvas.height = this._heat._height = size.y;
|
||||||
|
this._redraw();
|
||||||
|
},
|
||||||
|
|
||||||
|
_redraw() {
|
||||||
|
const r = this._heat._r;
|
||||||
|
const size = this._map.getSize();
|
||||||
|
const bounds = new L.Bounds(L.point([-r, -r]), size.add([r, r]));
|
||||||
|
const maxVal = this.options.max == null ? 1 : this.options.max;
|
||||||
|
const maxZoom = this.options.maxZoom == null ? this._map.getMaxZoom() : this.options.maxZoom;
|
||||||
|
const v = 1 / Math.pow(2, Math.max(0, Math.min(maxZoom - this._map.getZoom(), 12)));
|
||||||
|
const cellSize = r / 2;
|
||||||
|
const panePos = this._map._getMapPanePos();
|
||||||
|
const offsetX = panePos.x % cellSize;
|
||||||
|
const offsetY = panePos.y % cellSize;
|
||||||
|
const grid = [];
|
||||||
|
const points = [];
|
||||||
|
|
||||||
|
for (let i = 0, len = this._latlngs.length; i < len; i++) {
|
||||||
|
const p = this._map.latLngToContainerPoint(this._latlngs[i]);
|
||||||
|
if (!bounds.contains(p)) continue;
|
||||||
|
const x = Math.floor((p.x - offsetX) / cellSize) + 2;
|
||||||
|
const y = Math.floor((p.y - offsetY) / cellSize) + 2;
|
||||||
|
const alt = this._latlngs[i].alt != null ? this._latlngs[i].alt
|
||||||
|
: this._latlngs[i][2] != null ? +this._latlngs[i][2]
|
||||||
|
: 1;
|
||||||
|
const w = alt * v;
|
||||||
|
grid[y] = grid[y] || [];
|
||||||
|
const cell = grid[y][x];
|
||||||
|
if (cell) {
|
||||||
|
cell[0] = (cell[0] * cell[2] + p.x * w) / (cell[2] + w);
|
||||||
|
cell[1] = (cell[1] * cell[2] + p.y * w) / (cell[2] + w);
|
||||||
|
cell[2] += w;
|
||||||
|
} else {
|
||||||
|
grid[y][x] = [p.x, p.y, w];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0, len = grid.length; i < len; i++) {
|
||||||
|
if (!grid[i]) continue;
|
||||||
|
for (let j = 0, jlen = grid[i].length; j < jlen; j++) {
|
||||||
|
const cell = grid[i][j];
|
||||||
|
if (cell) points.push([Math.round(cell[0]), Math.round(cell[1]), Math.min(cell[2], maxVal)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._heat.data(points).draw(this.options.minOpacity);
|
||||||
|
this._frame = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
_animateZoom(e) {
|
||||||
|
const scale = this._map.getZoomScale(e.zoom);
|
||||||
|
const offset = this._map._getCenterOffset(e.center)._multiplyBy(-scale).subtract(this._map._getMapPanePos());
|
||||||
|
if (L.DomUtil.setTransform) {
|
||||||
|
L.DomUtil.setTransform(this._canvas, offset, scale);
|
||||||
|
} else {
|
||||||
|
this._canvas.style[L.DomUtil.TRANSFORM] = L.DomUtil.getTranslateString(offset) + ' scale(' + scale + ')';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HeatLayerClass(latlngs, options);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
$host = 'localhost';
|
||||||
|
$dbname = 'spbu_db'; // Change if your database name is different
|
||||||
|
$username = 'root'; // Default XAMPP username
|
||||||
|
$password = ''; // Default XAMPP password is empty
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = new PDO("mysql:host=$host;dbname=$dbname", $username, $password);
|
||||||
|
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
die("Database connection failed: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
require 'db.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
if ($_SERVER["REQUEST_METHOD"] == "POST" && isset($_POST['id'])) {
|
||||||
|
$id = $_POST['id'] ?? null;
|
||||||
|
|
||||||
|
if ($id === null) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(["status" => "error", "message" => "Missing id."]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "DELETE FROM SPBU WHERE id = ?";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
|
||||||
|
if ($stmt->execute([$id])) {
|
||||||
|
echo json_encode(["status" => "success", "message" => "Data deleted!"]);
|
||||||
|
} else {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(["status" => "error", "message" => "Failed to delete data."]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(["status" => "error", "message" => "Method not allowed."]);
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
require 'db.php';
|
||||||
|
|
||||||
|
$sql = "SELECT * FROM SPBU";
|
||||||
|
$stmt = $pdo->query($sql);
|
||||||
|
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode($results);
|
||||||
|
?>
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Peta SPBU</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0b1220;
|
||||||
|
--panel: rgba(255, 255, 255, 0.95);
|
||||||
|
--text: #0f172a;
|
||||||
|
--muted: #64748b;
|
||||||
|
--border: rgba(15, 23, 42, 0.12);
|
||||||
|
--primary: #2563eb;
|
||||||
|
--danger: #dc3545;
|
||||||
|
--warning: #ffc107;
|
||||||
|
--success: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
body { margin: 0; padding: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; }
|
||||||
|
#map { height: 100vh; width: 100vw; }
|
||||||
|
form { display: flex; flex-direction: column; gap: 8px; width: 220px; }
|
||||||
|
|
||||||
|
input, select {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
input:focus, select:focus {
|
||||||
|
outline: 2px solid rgba(37, 99, 235, 0.25);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-popup-content-wrapper { border-radius: 14px; }
|
||||||
|
.leaflet-popup-content { margin: 12px 14px; }
|
||||||
|
.leaflet-control-layers {
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: 0 10px 30px rgba(2, 6, 23, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
position: fixed;
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
z-index: 2500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: 0 10px 30px rgba(2, 6, 23, 0.18);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-title {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
margin-right: 6px;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
position: fixed;
|
||||||
|
top: 62px;
|
||||||
|
left: 12px;
|
||||||
|
z-index: 2500;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
box-shadow: 0 10px 30px rgba(2, 6, 23, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: white;
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.btn:hover { filter: brightness(0.98); }
|
||||||
|
.btn:active { transform: translateY(1px); }
|
||||||
|
.btn-primary { background: var(--primary); color: white; border-color: rgba(37, 99, 235, 0.25); }
|
||||||
|
.btn-ghost { background: white; }
|
||||||
|
.btn-toggle[aria-pressed="true"] { background: #0f172a; color: white; border-color: rgba(15, 23, 42, 0.25); }
|
||||||
|
|
||||||
|
/* Modal Styles */
|
||||||
|
#editModal {
|
||||||
|
display: none; position: fixed; top: 50%; left: 50%;
|
||||||
|
transform: translate(-50%, -50%); z-index: 2000;
|
||||||
|
background: white; padding: 20px; border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.3); width: 260px;
|
||||||
|
}
|
||||||
|
#modalOverlay {
|
||||||
|
display: none; position: fixed; top: 0; left: 0;
|
||||||
|
width: 100%; height: 100%; background: rgba(0,0,0,0.5);
|
||||||
|
z-index: 1500;
|
||||||
|
}
|
||||||
|
.btn-group { display: flex; justify-content: space-between; margin-top: 10px; }
|
||||||
|
.btn-action { padding: 6px 10px; border: none; cursor: pointer; border-radius: 8px; font-weight: 600; }
|
||||||
|
.btn-edit { background: var(--warning); }
|
||||||
|
.btn-delete { background: var(--danger); color: white; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="toolbar" role="region" aria-label="Kontrol peta">
|
||||||
|
<div class="toolbar-title">Peta SPBU</div>
|
||||||
|
<button id="btnAdd" type="button" class="btn btn-primary">Tambah titik baru</button>
|
||||||
|
<button id="btnMove" type="button" class="btn btn-ghost btn-toggle" aria-pressed="false">Aktifkan pindah</button>
|
||||||
|
</div>
|
||||||
|
<div id="hint" class="hint" hidden>Klik pada peta untuk menambahkan titik baru.</div>
|
||||||
|
|
||||||
|
<div id="map"></div>
|
||||||
|
|
||||||
|
<div id="modalOverlay" onclick="closeModal()"></div>
|
||||||
|
|
||||||
|
<div id="editModal">
|
||||||
|
<h3 style="margin-top:0;">Ubah SPBU</h3>
|
||||||
|
<form id="editForm" onsubmit="updateData(event)">
|
||||||
|
<input type="hidden" id="editId">
|
||||||
|
<input type="text" id="editNama" required>
|
||||||
|
<input type="text" id="editNomor" required>
|
||||||
|
<input type="text" id="editLat" readonly>
|
||||||
|
<input type="text" id="editLng" readonly>
|
||||||
|
<div style="font-size:12px; color:#6c757d; margin-top:-6px;">
|
||||||
|
Lintang & bujur akan berubah saat titik dipindah (mode Pindah aktif).
|
||||||
|
</div>
|
||||||
|
<select id="editStatus" required>
|
||||||
|
<option value="Buka 24 Jam">Buka 24 Jam</option>
|
||||||
|
<option value="Tidak">Tidak</option>
|
||||||
|
</select>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button type="button" class="btn-action" onclick="closeModal()">Batal</button>
|
||||||
|
<button type="submit" class="btn-action" style="background:#28a745; color:white;">Simpan</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const map = L.map('map').setView([-0.055, 109.34], 13);
|
||||||
|
|
||||||
|
// Base map layers
|
||||||
|
const baseOSM = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
maxZoom: 19,
|
||||||
|
attribution: '© OpenStreetMap'
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseOSMHOT = L.tileLayer('https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', {
|
||||||
|
maxZoom: 19,
|
||||||
|
subdomains: 'abc',
|
||||||
|
attribution: '© OpenStreetMap contributors, Tiles style by Humanitarian OpenStreetMap Team hosted by OpenStreetMap France'
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseTopo = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
|
||||||
|
maxZoom: 17,
|
||||||
|
subdomains: 'abc',
|
||||||
|
attribution: 'Map data: © OpenStreetMap contributors, SRTM | Map style: © OpenTopoMap (CC-BY-SA)'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Default base layer
|
||||||
|
baseOSM.addTo(map);
|
||||||
|
|
||||||
|
const popup = L.popup();
|
||||||
|
|
||||||
|
// Define Green and Red Icons
|
||||||
|
const greenIcon = new L.Icon({
|
||||||
|
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-green.png',
|
||||||
|
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png',
|
||||||
|
iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], shadowSize: [41, 41]
|
||||||
|
});
|
||||||
|
|
||||||
|
const redIcon = new L.Icon({
|
||||||
|
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png',
|
||||||
|
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png',
|
||||||
|
iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], shadowSize: [41, 41]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Layers for filtering via Leaflet Layers Control
|
||||||
|
const layer24 = L.layerGroup().addTo(map);
|
||||||
|
const layerNot = L.layerGroup().addTo(map);
|
||||||
|
|
||||||
|
const baseLayers = {
|
||||||
|
'Peta Jalan (OSM)': baseOSM,
|
||||||
|
'Peta Jalan (HOT)': baseOSMHOT,
|
||||||
|
'Peta Topografi': baseTopo
|
||||||
|
};
|
||||||
|
|
||||||
|
const overlayLayers = {
|
||||||
|
'Buka 24 Jam': layer24,
|
||||||
|
'Tidak': layerNot
|
||||||
|
};
|
||||||
|
|
||||||
|
L.control.layers(baseLayers, overlayLayers, { collapsed: false }).addTo(map);
|
||||||
|
|
||||||
|
let isAddMode = false;
|
||||||
|
let isMoveMode = false;
|
||||||
|
|
||||||
|
function setAddMode(enabled) {
|
||||||
|
isAddMode = enabled;
|
||||||
|
document.getElementById('hint').hidden = !enabled;
|
||||||
|
const btn = document.getElementById('btnAdd');
|
||||||
|
btn.textContent = enabled ? 'Klik peta untuk menaruh…' : 'Tambah titik baru';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMoveMode(enabled) {
|
||||||
|
isMoveMode = enabled;
|
||||||
|
const btn = document.getElementById('btnMove');
|
||||||
|
btn.setAttribute('aria-pressed', enabled ? 'true' : 'false');
|
||||||
|
btn.textContent = enabled ? 'Matikan pindah' : 'Aktifkan pindah';
|
||||||
|
|
||||||
|
const toggle = (marker) => {
|
||||||
|
if (!marker || !marker.dragging) return;
|
||||||
|
if (enabled) marker.dragging.enable();
|
||||||
|
else marker.dragging.disable();
|
||||||
|
};
|
||||||
|
|
||||||
|
layer24.eachLayer(toggle);
|
||||||
|
layerNot.eachLayer(toggle);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPopupContent(spbu) {
|
||||||
|
return `
|
||||||
|
<b>${escapeHtml(spbu.nama)}</b><br>
|
||||||
|
Nomor: ${escapeHtml(spbu.nomor)}<br>
|
||||||
|
Status: ${escapeHtml(spbu.status)}<br>
|
||||||
|
Lintang: ${escapeHtml(spbu.Latitude)}<br>
|
||||||
|
Bujur: ${escapeHtml(spbu.Longitude)}<br>
|
||||||
|
<hr style="margin: 8px 0;">
|
||||||
|
<button type="button" class="btn-action btn-edit js-edit">Ubah</button>
|
||||||
|
<button type="button" class="btn-action btn-delete js-delete">Hapus</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCoordinates(id, latitude, longitude) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('id', id);
|
||||||
|
formData.append('latitude', latitude);
|
||||||
|
formData.append('longitude', longitude);
|
||||||
|
|
||||||
|
return fetch('update_spbu.php', { method: 'POST', body: formData })
|
||||||
|
.then(r => r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshMarkers() {
|
||||||
|
return fetch('get_spbu.php')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
map.closePopup();
|
||||||
|
layer24.clearLayers();
|
||||||
|
layerNot.clearLayers();
|
||||||
|
|
||||||
|
const rows = Array.isArray(data) ? data : [];
|
||||||
|
rows.forEach(spbu => {
|
||||||
|
const is24 = spbu.status === 'Buka 24 Jam';
|
||||||
|
const markerIcon = is24 ? greenIcon : redIcon;
|
||||||
|
const lat = parseFloat(spbu.Latitude);
|
||||||
|
const lng = parseFloat(spbu.Longitude);
|
||||||
|
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return;
|
||||||
|
|
||||||
|
const popupContent = buildPopupContent(spbu);
|
||||||
|
|
||||||
|
const marker = L.marker([lat, lng], { icon: markerIcon, draggable: true });
|
||||||
|
if (!isMoveMode && marker.dragging) marker.dragging.disable();
|
||||||
|
marker.__spbu = spbu;
|
||||||
|
marker.__oldLatLng = null;
|
||||||
|
marker.bindPopup(popupContent);
|
||||||
|
|
||||||
|
marker.on('dragstart', function(ev) {
|
||||||
|
const m = ev.target;
|
||||||
|
m.closePopup();
|
||||||
|
m.__oldLatLng = m.getLatLng();
|
||||||
|
});
|
||||||
|
|
||||||
|
marker.on('dragend', function(ev) {
|
||||||
|
const m = ev.target;
|
||||||
|
const oldLatLng = m.__oldLatLng;
|
||||||
|
const current = m.getLatLng();
|
||||||
|
const newLat = current.lat.toFixed(8);
|
||||||
|
const newLng = current.lng.toFixed(8);
|
||||||
|
|
||||||
|
const spbuRef = m.__spbu;
|
||||||
|
updateCoordinates(spbuRef.id, newLat, newLng)
|
||||||
|
.then(res => {
|
||||||
|
if (res && res.status === 'success') {
|
||||||
|
spbuRef.Latitude = newLat;
|
||||||
|
spbuRef.Longitude = newLng;
|
||||||
|
m.setPopupContent(buildPopupContent(spbuRef));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (oldLatLng) m.setLatLng(oldLatLng);
|
||||||
|
console.error('Move failed:', res);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
if (oldLatLng) m.setLatLng(oldLatLng);
|
||||||
|
console.error('Move error:', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
marker.addTo(is24 ? layer24 : layerNot);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error fetching data:', error));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire popup buttons (avoids inline onclick bugs)
|
||||||
|
map.on('popupopen', function(e) {
|
||||||
|
const source = e.popup._source;
|
||||||
|
const spbu = source && source.__spbu;
|
||||||
|
if (!spbu) return;
|
||||||
|
|
||||||
|
const el = e.popup.getElement();
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const editBtn = el.querySelector('.js-edit');
|
||||||
|
const deleteBtn = el.querySelector('.js-delete');
|
||||||
|
|
||||||
|
if (editBtn) {
|
||||||
|
editBtn.addEventListener('click', function(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
openModal(spbu);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (deleteBtn) {
|
||||||
|
deleteBtn.addEventListener('click', function(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
deleteData(spbu.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toolbar
|
||||||
|
document.getElementById('btnAdd').addEventListener('click', function() {
|
||||||
|
setAddMode(!isAddMode);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btnMove').addEventListener('click', function() {
|
||||||
|
setMoveMode(!isMoveMode);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle Map Click (add SPBU only when Create mode enabled)
|
||||||
|
map.on('click', function(e) {
|
||||||
|
if (!isAddMode) return;
|
||||||
|
|
||||||
|
const lat = e.latlng.lat.toFixed(8);
|
||||||
|
const lng = e.latlng.lng.toFixed(8);
|
||||||
|
|
||||||
|
const formHTML = `
|
||||||
|
<form id="spbuForm" onsubmit="saveData(event)">
|
||||||
|
<h4 style="margin: 0 0 10px 0;">Tambah SPBU</h4>
|
||||||
|
<input type="text" id="nama" placeholder="Nama SPBU" required>
|
||||||
|
<input type="text" id="nomor" placeholder="Nomor SPBU" required>
|
||||||
|
<select id="status" required>
|
||||||
|
<option value="Buka 24 Jam">Buka 24 Jam</option>
|
||||||
|
<option value="Tidak">Tidak</option>
|
||||||
|
</select>
|
||||||
|
<div>Lintang: ${lat}</div>
|
||||||
|
<div>Bujur: ${lng}</div>
|
||||||
|
<input type="hidden" id="lat" value="${lat}">
|
||||||
|
<input type="hidden" id="lng" value="${lng}">
|
||||||
|
<button type="submit" style="background:#28a745; color:white; border:none; padding:5px;">Simpan</button>
|
||||||
|
</form>
|
||||||
|
`;
|
||||||
|
|
||||||
|
popup.setLatLng(e.latlng).setContent(formHTML).openOn(map);
|
||||||
|
|
||||||
|
// One-shot add: after placing the form, leave add mode.
|
||||||
|
setAddMode(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save (create)
|
||||||
|
function saveData(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('nama', document.getElementById('nama').value);
|
||||||
|
formData.append('nomor', document.getElementById('nomor').value);
|
||||||
|
formData.append('status', document.getElementById('status').value);
|
||||||
|
formData.append('latitude', document.getElementById('lat').value);
|
||||||
|
formData.append('longitude', document.getElementById('lng').value);
|
||||||
|
|
||||||
|
fetch('save_spbu.php', { method: 'POST', body: formData })
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
map.closePopup();
|
||||||
|
return refreshMarkers();
|
||||||
|
}
|
||||||
|
console.error('Save failed:', data);
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error:', error));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
function deleteData(id) {
|
||||||
|
if (!confirm("Yakin ingin menghapus SPBU ini?")) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('id', id);
|
||||||
|
|
||||||
|
fetch('delete_spbu.php', { method: 'POST', body: formData })
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
map.closePopup();
|
||||||
|
return refreshMarkers();
|
||||||
|
}
|
||||||
|
console.error('Delete failed:', data);
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error:', error));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal
|
||||||
|
function openModal(spbu) {
|
||||||
|
document.getElementById('editId').value = spbu.id;
|
||||||
|
document.getElementById('editNama').value = spbu.nama;
|
||||||
|
document.getElementById('editNomor').value = spbu.nomor;
|
||||||
|
document.getElementById('editStatus').value = spbu.status;
|
||||||
|
document.getElementById('editLat').value = spbu.Latitude;
|
||||||
|
document.getElementById('editLng').value = spbu.Longitude;
|
||||||
|
|
||||||
|
document.getElementById('modalOverlay').style.display = 'block';
|
||||||
|
document.getElementById('editModal').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('modalOverlay').style.display = 'none';
|
||||||
|
document.getElementById('editModal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update
|
||||||
|
function updateData(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('id', document.getElementById('editId').value);
|
||||||
|
formData.append('nama', document.getElementById('editNama').value);
|
||||||
|
formData.append('nomor', document.getElementById('editNomor').value);
|
||||||
|
formData.append('status', document.getElementById('editStatus').value);
|
||||||
|
|
||||||
|
fetch('update_spbu.php', { method: 'POST', body: formData })
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
closeModal();
|
||||||
|
return refreshMarkers();
|
||||||
|
}
|
||||||
|
console.error('Update failed:', data);
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error:', error));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
refreshMarkers();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
require 'db.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
if ($_SERVER["REQUEST_METHOD"] == "POST") {
|
||||||
|
$nama = $_POST['nama'] ?? null;
|
||||||
|
$nomor = $_POST['nomor'] ?? null;
|
||||||
|
$status = $_POST['status'] ?? null;
|
||||||
|
$lat = $_POST['latitude'] ?? null;
|
||||||
|
$lng = $_POST['longitude'] ?? null;
|
||||||
|
|
||||||
|
if ($nama === null || $nomor === null || $status === null || $lat === null || $lng === null) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(["status" => "error", "message" => "Missing required fields."]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "INSERT INTO SPBU (nama, nomor, status, Latitude, Longitude) VALUES (?, ?, ?, ?, ?)";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
|
||||||
|
if ($stmt->execute([$nama, $nomor, $status, $lat, $lng])) {
|
||||||
|
echo json_encode(["status" => "success", "message" => "SPBU Data Saved!"]);
|
||||||
|
} else {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(["status" => "error", "message" => "Failed to save data."]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(["status" => "error", "message" => "Method not allowed."]);
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
require 'db.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
if ($_SERVER["REQUEST_METHOD"] == "POST" && isset($_POST['id'])) {
|
||||||
|
$id = $_POST['id'] ?? null;
|
||||||
|
$nama = $_POST['nama'] ?? null;
|
||||||
|
$nomor = $_POST['nomor'] ?? null;
|
||||||
|
$status = $_POST['status'] ?? null;
|
||||||
|
$lat = $_POST['latitude'] ?? null;
|
||||||
|
$lng = $_POST['longitude'] ?? null;
|
||||||
|
|
||||||
|
if ($id === null) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(["status" => "error", "message" => "Missing id."]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields = [];
|
||||||
|
$values = [];
|
||||||
|
|
||||||
|
if ($nama !== null) {
|
||||||
|
$fields[] = "nama = ?";
|
||||||
|
$values[] = $nama;
|
||||||
|
}
|
||||||
|
if ($nomor !== null) {
|
||||||
|
$fields[] = "nomor = ?";
|
||||||
|
$values[] = $nomor;
|
||||||
|
}
|
||||||
|
if ($status !== null) {
|
||||||
|
$fields[] = "status = ?";
|
||||||
|
$values[] = $status;
|
||||||
|
}
|
||||||
|
if ($lat !== null) {
|
||||||
|
$fields[] = "Latitude = ?";
|
||||||
|
$values[] = $lat;
|
||||||
|
}
|
||||||
|
if ($lng !== null) {
|
||||||
|
$fields[] = "Longitude = ?";
|
||||||
|
$values[] = $lng;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($fields) === 0) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(["status" => "error", "message" => "No fields to update."]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$values[] = $id;
|
||||||
|
|
||||||
|
$sql = "UPDATE SPBU SET " . implode(", ", $fields) . " WHERE id = ?";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
|
||||||
|
if ($stmt->execute($values)) {
|
||||||
|
echo json_encode(["status" => "success", "message" => "Data updated!"]);
|
||||||
|
} else {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(["status" => "error", "message" => "Failed to update data."]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(["status" => "error", "message" => "Method not allowed."]);
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Peta SPBU</title>
|
||||||
|
<meta http-equiv="refresh" content="0; url=index.html">
|
||||||
|
<script>
|
||||||
|
window.location.replace('index.html');
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
Mengalihkan ke <a href="index.html">index.html</a>...
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||