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
This commit is contained in:
commit
eaf05f2811
280 files changed
+123975
No files matched your search
@@ -0,0 +1,267 @@
|
||||
<!-- 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') %>
|
||||
Reference in new issue
Block a user