Files
open-isp/views/tech/map.ejs
T
oxpoe eaf05f2811 OPEN-ISP v0.5 (pickle-lime) — rilis open-source perdana
Aplikasi billing & manajemen ISP (fiber optic) — PWA pelanggan, portal teknisi & kolektor,
admin panel lengkap, multi-vendor OLT, RADIUS, WhatsApp/Telegram, pembayaran QRIS.

Kredit & lisensi (ISC):
- Base kode & repo: alijayanet/billing-rtrw oleh Alijaya Net — terima kasih banyak
  atas base kode dan repo yang bermanfaat.
- Add-on: GoWA, LibreSpeed/OpenSpeedTest, Uptime Kuma, notification-forwarder.
- Dikembangkan menjadi OPEN-ISP oleh oxpoe — https://github.com/oxpoe/open-isp
2026-10-09 05:51:21 +07:00

268 lines
11 KiB
Plaintext

<!-- views/tech/map.ejs -->
<%- include('partials/header') %>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
<style>
#map { height: calc(100vh - 145px); border-radius: 12px; margin-top: 10px; z-index: 1; border: 1px solid #334155; background: #0f172a; }
.map-info { background: #1e293b; padding: 12px; border-radius: 12px; margin-bottom: 10px; font-size: 13px; border: 1px solid #334155; box-shadow: 0 4px 15px rgba(0,0,0,0.2); }
/* Tech Stats */
.tech-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 10px; }
.tsc { background: #1e293b; border: 1px solid #334155; padding: 10px; border-radius: 10px; text-align: center; }
.tsc .val { display: block; font-size: 16px; font-weight: 700; color: #f8fafc; }
.tsc .lbl { display: block; font-size: 9px; color: #94a3b8; text-transform: uppercase; font-weight: 600; margin-top: 2px; }
.tsc-w { border-bottom: 3px solid #f59e0b; }
.tsc-s { border-bottom: 3px solid #10b981; }
.tsc-d { border-bottom: 3px solid #ef4444; }
/* Tech Legend Grid */
.legend-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 10px; }
.lc { background: #1e293b; border: 1px solid #334155; padding: 8px; border-radius: 8px; display: flex; align-items: center; justify-content: center; gap: 8px; }
.lc i { width: 10px; height: 10px; border-radius: 50%; border: 2px solid white; box-shadow: 0 0 5px rgba(0,0,0,0.5); }
.lc span { font-size: 11px; font-weight: 700; color: #f1f5f9; }
.marker-odp { color: #f59e0b; font-size: 16px; text-shadow: -1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 1px 1px 0 #fff; }
.marker-cust-active { color: #3b82f6; font-size: 18px; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3)); text-shadow: -1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 1px 1px 0 #fff; }
.marker-cust-suspended { color: #ef4444; font-size: 18px; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3)); text-shadow: -1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 1px 1px 0 #fff; }
.popup-dark { color: #1e293b; }
.popup-dark h6 { margin: 0 0 5px 0; border-bottom: 1px solid #ddd; padding-bottom: 5px; font-weight: bold; }
.btn-wa { background: #25d366; color: white; border: none; padding: 5px 10px; border-radius: 5px; text-decoration: none; display: inline-block; margin-top: 8px; font-size: 12px; }
/* Flowing line animation */
@keyframes flow {
from { stroke-dashoffset: 20; }
to { stroke-dashoffset: 0; }
}
.flowing-line {
animation: flow 1s linear infinite;
}
</style>
<style>
/* Re-tema ke design system */
#map { border: 1px solid var(--border); background: var(--bg); }
.map-info { background: var(--glass-2); border-color: var(--border); color: var(--text); }
.tech-stats .tsc { background: var(--glass-2); border-color: var(--border); }
.tsc .val { color: var(--text); }
.tsc .lbl { color: var(--muted); }
.legend-grid .lc { background: var(--glass-2); border-color: var(--border); }
.lc span { color: var(--text); }
.leaflet-popup-content .btn { color: #fff !important; font-weight: 700; border: none !important; }
.leaflet-popup-content .btn-wa { background: #25D366 !important; }
.leaflet-popup-content .btn-wa[style*="3b82f6"] { background: #3b82f6 !important; }
</style>
<div class="tech-stats">
<div class="tsc tsc-w">
<span class="val"><%= odps.length %></span>
<span class="lbl"><%= t('tech.map.total_odp', 'Total ODP') %></span>
</div>
<div class="tsc tsc-s">
<span class="val"><%= customers.filter(c => c.status === 'active').length %></span>
<span class="lbl"><%= t('tech.map.active', 'Aktif') %></span>
</div>
<div class="tsc tsc-d">
<span class="val"><%= customers.filter(c => c.status !== 'active').length %></span>
<span class="lbl"><%= t('tech.map.isolated', 'Isolir') %></span>
</div>
</div>
<div class="legend-grid">
<div class="lc">
<i style="background: #f59e0b;"></i>
<span>ODP</span>
</div>
<div class="lc">
<i style="background: #3b82f6;"></i>
<span><%= t('tech.map.active', 'Aktif') %></span>
</div>
<div class="lc">
<i style="background: #ef4444;"></i>
<span><%= t('tech.map.isolated', 'Isolir') %></span>
</div>
</div>
<div class="map-info">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px">
<span><i class="bi bi-info-circle me-1"></i> <%= t('tech.map.mode', 'Mode') %>: <b><%= t('tech.map.mode_sat', 'Satelit (Navigasi)') %></b></span>
<span class="badge bp"><%= t('tech.map.gps_ready', 'GPS Ready') %></span>
</div>
</div>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script id="settings-data" type="application/json"><%- JSON.stringify(settings).split('<').join('\\u003c') %></script>
<script>
const appSettings = JSON.parse(document.getElementById('settings-data').textContent);
const officeCoords = {
lat: parseFloat(appSettings.office_lat) || -6.200000,
lng: parseFloat(appSettings.office_lng) || 106.816666
};
// Initialize Map
const map = L.map('map').setView([officeCoords.lat, officeCoords.lng], 13);
// Layers
const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OSM'
});
const googleHybrid = L.tileLayer('https://{s}.google.com/vt/lyrs=y&x={x}&y={y}&z={z}', {
maxZoom: 20,
subdomains: ['mt0', 'mt1', 'mt2', 'mt3'],
attribution: '© Google Sat'
});
osm.addTo(map); // Default set to OSM first
googleHybrid.addTo(map); // Then overlay satellite
const baseMaps = {
"<%= t('tech.map.satellite', 'Satelit') %>": googleHybrid,
"<%= t('tech.map.standard', 'Standard') %>": osm
};
L.control.layers(baseMaps).addTo(map);
// Data Injection with safety
let customers = [];
let odps = [];
try {
customers = <%- JSON.stringify(customers).replace(/<\/script/g, '<\\/script').replace(/<!--/g, '<\\!--') %>;
odps = <%- JSON.stringify(odps).replace(/<\/script/g, '<\\/script').replace(/<!--/g, '<\\!--') %>;
} catch (e) {
console.error("Data mapping error:", e);
}
// Add ODP Markers
const odpMarkers = {};
odps.forEach(odp => {
if (odp.lat && odp.lng) {
const odpLat = parseFloat(odp.lat);
const odpLng = parseFloat(odp.lng);
odpMarkers[odp.id] = [odpLat, odpLng];
const marker = L.marker([odpLat, odpLng], {
icon: L.divIcon({
html: '<i class="bi bi-box-seam-fill marker-odp"></i>',
className: 'custom-div-icon',
iconSize: [16, 16],
iconAnchor: [8, 8]
})
}).addTo(map);
marker.bindPopup(`
<div class="popup-dark">
<h6>${odp.name}</h6>
<div style="font-size:11px">
<b><%= t('tech.map.olt', 'OLT') %>:</b> ${odp.olt_name || '-'}<br>
<b><%= t('tech.map.port', 'Port') %>:</b> ${odp.pon_port || '-'}<br>
<b><%= t('tech.map.ket', 'Ket') %>:</b> ${odp.description || '-'}
</div>
<a href="https://www.google.com/maps/dir/?api=1&destination=${odp.lat},${odp.lng}" target="_blank" class="btn btn-wa w-100 text-center mt-2" style="background:#3b82f6">
<i class="bi bi-signpost-2"></i> <%= t('tech.map.directions', 'Petunjuk Arah') %>
</a>
</div>
`);
}
});
// Add Customer Markers
customers.forEach(cust => {
if (cust.lat && cust.lng) {
const is_active = cust.status === 'active';
const iconClass = is_active ? 'marker-cust-active' : 'marker-cust-suspended';
const custLat = parseFloat(cust.lat);
const custLng = parseFloat(cust.lng);
// Draw line to ODP if connected
if (cust.odp_id && odpMarkers[cust.odp_id]) {
let pathCoords = [
[custLat, custLng],
odpMarkers[cust.odp_id]
];
if (cust.cable_path) {
try {
const parsed = JSON.parse(cust.cable_path);
if (Array.isArray(parsed) && parsed.length >= 2) {
const cleaned = parsed
.filter(p => Array.isArray(p) && p.length >= 2)
.map(p => [parseFloat(p[0]), parseFloat(p[1])])
.filter(p => Number.isFinite(p[0]) && Number.isFinite(p[1]));
if (cleaned.length >= 2) pathCoords = cleaned;
}
} catch (e) {}
}
L.polyline(pathCoords, {
color: is_active ? '#3b82f6' : '#ef4444',
weight: 3,
opacity: 0.8,
dashArray: '10, 10',
className: 'flowing-line',
interactive: false
}).addTo(map);
}
const marker = L.marker([custLat, custLng], {
icon: L.divIcon({
html: `<i class="bi bi-person-fill ${iconClass}"></i>`,
className: 'custom-div-icon',
iconSize: [18, 18],
iconAnchor: [9, 9]
})
}).addTo(map);
marker.bindPopup(`
<div class="popup-dark">
<h6>${cust.name}</h6>
<div style="font-size:11px">
<b><%= t('tech.map.address', 'Alamat') %>:</b> ${cust.address}<br>
<b><%= t('tech.map.package', 'Paket') %>:</b> ${cust.package_name || '-'}<br>
<b><%= t('tech.map.status', 'Status') %>:</b> ${cust.status}
</div>
<div class="d-flex gap-1">
<a href="https://wa.me/${cust.phone}" class="btn btn-wa flex-grow-1 text-center">
<i class="bi bi-whatsapp"></i> <%= t('tech.map.chat', 'Chat') %>
</a>
<a href="https://www.google.com/maps/dir/?api=1&destination=${cust.lat},${cust.lng}" target="_blank" class="btn btn-wa flex-grow-1 text-center" style="background:#3b82f6">
<i class="bi bi-geo-alt"></i> <%= t('tech.map.route', 'Rute') %>
</a>
</div>
</div>
`);
}
});
// Geolocation for Tech
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(position => {
const myLat = position.coords.latitude;
const myLng = position.coords.longitude;
L.marker([myLat, myLng], {
icon: L.divIcon({
html: '<i class="bi bi-person-circle text-white" style="font-size:24px; text-shadow: 0 0 10px #3b82f6"></i>',
className: 'custom-div-icon',
iconSize: [24, 24],
iconAnchor: [12, 12]
})
}).addTo(map).bindPopup("<div class='popup-dark text-center'><b><%= t('tech.map.your_pos', 'Posisi Anda') %></b></div>");
});
}
// Auto zoom
const _mkLayers = [];
map.eachLayer(function (l) { if (l instanceof L.Marker || l instanceof L.CircleMarker) _mkLayers.push(l); });
const group = L.featureGroup(_mkLayers);
if (group.getLayers().length > 0) {
map.fitBounds(group.getBounds().pad(0.1));
}
// Fix for blank map on load
setTimeout(() => {
map.invalidateSize();
}, 500);
</script>
<%- include('partials/bottom_nav') %>