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
268 lines
11 KiB
Plaintext
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') %>
|