update revenue dashboard

This commit is contained in:
wayanrivan
2026-07-07 09:06:45 +07:00
parent c9e28043e4
commit 0ec6ef1841

View File

@ -1,26 +1,52 @@
import { useEffect, useRef, useState, useMemo } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { sitePoints, SitePoint } from '../sitepoints';
import { sitePoints, SitePoint } from '../sitePoints';
// ---------------------------------------------------------------------------
// Satellite map (Leaflet + Esri World Imagery, no API key required) with a
// pin for every Cell/Sector row from the uploaded Excel
// (Sites_Coordinates_untuk_CC.xlsm -> "site coordinates 4G").
//
// No clustering — every site is shown as its own blue pin, directly at its
// real coordinates (previously multi-sector sites used a marker cluster
// plugin, which rendered green/yellow/orange group bubbles; that's removed).
//
// SETUP REQUIRED:
// npm install leaflet
// npm install --save-dev @types/leaflet
// ---------------------------------------------------------------------------
// --- TAMBAHAN DATA MOCK POLYGON UNTUK AREA KLASTER ---
// Dilengkapi data placeholder untuk kebutuhan Dialog Popup Klaster
const CLUSTER_AREAS = [
{
id: 'area-surabaya-pusat',
name: 'Tunjungan & Pusat',
color: '#be123c', // Warna merah muda transparan seperti di screenshot
regionalName: 'JATIM',
siteId: 'SBY095',
neId: 'SBY095ML',
siteName: 'N_SBY095ML_WESTIN',
bscrncName: '-',
lacCi: '232095 - 11',
node: '4G',
coords: [
[-8.5550, 125.5650],
[-8.5550, 125.5800],
[-8.5650, 125.5850],
[-8.5680, 125.5700],
] as [number, number][]
},
{
id: 'area-surabaya-timur',
name: 'Gubeng & Manyar',
color: '#be123c',
regionalName: 'JATIM',
siteId: 'SBY102',
neId: 'SBY102ML',
siteName: 'N_SBY102ML_GUBENG',
bscrncName: '-',
lacCi: '232102 - 12',
node: '4G',
coords: [
[-8.5500, 125.5850],
[-8.5480, 125.5950],
[-8.5580, 125.5980],
[-8.5600, 125.5880],
] as [number, number][]
}
];
const DILI_CENTER: [number, number] = [-8.5586, 125.5736];
const ZOOM_THRESHOLD = 14; // Batas tingkat zoom untuk menyembunyikan/menampilkan pin biru
// Groups points that share the exact same coordinates so a single pin can
// list every cell/sector at that physical site.
const groupByCoordinate = (points: SitePoint[]) => {
const map = new Map<string, SitePoint[]>();
points.forEach((p) => {
@ -31,26 +57,13 @@ const groupByCoordinate = (points: SitePoint[]) => {
return map;
};
// Signal-tower badge icon: blue circle background + white "broadcast tower"
// glyph. Same size/style for every site pin.
const pinIcon = () => {
const size = 28;
const bg = '#2563eb';
return L.divIcon({
className: '',
html: `
<div style="
width:${size}px;
height:${size}px;
border-radius:50%;
background:${bg};
border:2px solid white;
box-shadow:0 1px 3px rgba(0,0,0,0.5);
display:flex;
align-items:center;
justify-content:center;
">
<div style="width:${size}px;height:${size}px;border-radius:50%;background:${bg};border:2px solid white;box-shadow:0 1px 3px rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;">
<svg width="${size * 0.62}" height="${size * 0.62}" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="21" x2="12" y2="10"></line>
<circle cx="12" cy="7" r="2" fill="white" stroke="none"></circle>
@ -64,36 +77,77 @@ const pinIcon = () => {
});
};
// ---------------------------------------------------------------------------
// "Signal detail" popup — tabbed Signal / RAN 2G / RAN 3G / Alarm dialog,
// shown for every pin.
// ---------------------------------------------------------------------------
// --- TEMPLATE TAB DIALOG UNTUK AREA KLASTER (SESUAI SCREENSHOT) ---
const buildClusterPopupHtml = (area: typeof CLUSTER_AREAS[0]) => {
return `
<div class="cluster-popup" style="width:320px; font-family: Arial, sans-serif; color: #333;">
<div style="display:flex; border-bottom: 2px solid #e5e7eb; margin-bottom: 12px; font-size: 12px; font-weight: bold;">
<div data-cluster-tab="general" class="cluster-tab active" style="padding: 6px 12px; cursor: pointer; color: #2563eb; border-bottom: 2px solid #2563eb; margin-bottom: -2px;">GENERAL</div>
<div data-cluster-tab="revenue" class="cluster-tab" style="padding: 6px 12px; cursor: pointer; color: #64748b;">REVENUE</div>
<div data-cluster-tab="network" class="cluster-tab" style="padding: 6px 12px; cursor: pointer; color: #64748b;">NETWORK</div>
</div>
<div data-cluster-content="general" class="cluster-content" style="display: block;">
<table style="width: 100%; border-collapse: collapse; font-size: 12px;">
<tr style="background: #f8fafc;"><td style="padding: 6px 8px; font-weight: 600; color: #64748b;">Regional Name</td><td style="padding: 6px 8px;">${area.regionalName}</td></tr>
<tr><td style="padding: 6px 8px; font-weight: 600; color: #64748b;">Site ID</td><td style="padding: 6px 8px;">${area.siteId}</td></tr>
<tr style="background: #f8fafc;"><td style="padding: 6px 8px; font-weight: 600; color: #64748b;">NeId</td><td style="padding: 6px 8px;">${area.neId}</td></tr>
<tr><td style="padding: 6px 8px; font-weight: 600; color: #64748b;">Site Name</td><td style="padding: 6px 8px; font-weight: 600;">${area.siteName}</td></tr>
<tr style="background: #f8fafc;"><td style="padding: 6px 8px; font-weight: 600; color: #64748b;">BSCRNC Name</td><td style="padding: 6px 8px;">${area.bscrncName}</td></tr>
<tr><td style="padding: 6px 8px; font-weight: 600; color: #64748b;">LAC - CI</td><td style="padding: 6px 8px;">${area.lacCi}</td></tr>
<tr style="background: #f8fafc;"><td style="padding: 6px 8px; font-weight: 600; color: #64748b;">Node</td><td style="padding: 6px 8px;">${area.node}</td></tr>
</table>
</div>
<div data-cluster-content="revenue" class="cluster-content" style="display: none; padding: 10px 8px; font-size: 12px; color: #64748b;">
Informasi tren revenue klaster ${area.name} di sini.
</div>
<div data-cluster-content="network" class="cluster-content" style="display: none; padding: 10px 8px; font-size: 12px; color: #64748b;">
Informasi performa network klaster ${area.name} di sini.
</div>
</div>
`;
};
// Wires up tab switching untuk Popup Area Klaster
const wireClusterPopup = (container: HTMLElement) => {
const tabs = container.querySelectorAll<HTMLElement>('.cluster-tab');
const contents = container.querySelectorAll<HTMLElement>('.cluster-content');
tabs.forEach((btn) => {
btn.addEventListener('click', () => {
const target = btn.dataset.clusterTab;
tabs.forEach((t) => {
if (t === btn) {
t.style.color = '#2563eb';
t.style.borderBottom = '2px solid #2563eb';
} else {
t.style.color = '#64748b';
t.style.borderBottom = 'none';
}
});
contents.forEach((c) => {
c.style.display = c.dataset.clusterContent === target ? 'block' : 'none';
});
});
});
};
// --- POPUP PIN BIRU INDIVIDUAL (Bawaan Sebelumnya) ---
const QUALITY_LEVELS = ['Low', 'Moderate', 'Good', 'Very Good', 'Excellent'];
// Deterministic PLACEHOLDER quality derived from the site name — replace
// with a real lookup (RSRP/RSSI, KPI feed, etc.) once that data exists.
const placeholderQuality = (seed: string, offset: number) => {
let hash = 0;
for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
const level = (hash + offset) % QUALITY_LEVELS.length;
return { level: level + 1, label: QUALITY_LEVELS[level] };
};
const signalBarsHtml = (level: number) => {
const heights = [5, 9, 13, 17];
return `<div style="display:flex;align-items:flex-end;gap:2px;height:17px;">
${heights
.map(
(h, i) =>
`<div style="width:4px;height:${h}px;border-radius:1px;background:${
i < level ? '#2563eb' : '#dbeafe'
};"></div>`
)
.join('')}
${heights.map((h, i) => `<div style="width:4px;height:${h}px;border-radius:1px;background:${i < level ? '#2563eb' : '#dbeafe'};"></div>`).join('')}
</div>`;
};
const signalCardHtml = (title: string, desc: string, seed: string, offset: number) => {
const q = placeholderQuality(seed, offset);
return `
@ -104,24 +158,14 @@ const signalCardHtml = (title: string, desc: string, seed: string, offset: numbe
<div style="font-size:10px;color:#64748b;line-height:1.3;">${desc}</div>
</div>`;
};
const cellListHtml = (points: SitePoint[]) => {
if (points.length === 0) {
return `<div style="padding:10px 0;text-align:center;font-size:12px;color:#94a3b8;">No cells in this band.</div>`;
}
const rows = points
.map((p) => `<tr><td style="padding:2px 8px 2px 0;">${p.cell}</td><td>${p.sector}</td></tr>`)
.join('');
if (points.length === 0) return `<div style="padding:10px 0;text-align:center;font-size:12px;color:#94a3b8;">No cells in this band.</div>`;
const rows = points.map((p) => `<tr><td style="padding:2px 8px 2px 0;">${p.cell}</td><td>${p.sector}</td></tr>`).join('');
return `<table style="width:100%;border-collapse:collapse;font-size:12px;margin-top:6px;">${rows}</table>`;
};
const buildSignalPopupHtml = (points: SitePoint[], lat: number, lng: number) => {
const site = points[0].site;
const location = points[0].location;
// Placeholder split: cells ending in "E" shown under RAN 2G, cells ending
// in "F" under RAN 3G. The source data doesn't label technology
// explicitly — adjust this rule once the real mapping is known.
const ran2g = points.filter((p) => p.cell.trim().toUpperCase().endsWith('E'));
const ran3g = points.filter((p) => p.cell.trim().toUpperCase().endsWith('F'));
@ -131,81 +175,29 @@ const buildSignalPopupHtml = (points: SitePoint[], lat: number, lng: number) =>
<div style="font-weight:700;font-size:14px;color:#0f172a;">${site}</div>
<div style="font-size:11px;color:#64748b;">${location} &middot; ${lat.toFixed(5)}, ${lng.toFixed(5)}</div>
</div>
<div class="signal-tabs" style="display:flex;gap:4px;margin-bottom:10px;">
<button data-tab="signal" class="signal-tab signal-tab-active" style="${tabBtnStyle(true)}">Signal</button>
<button data-tab="ran2g" class="signal-tab" style="${tabBtnStyle(false)}">RAN 2G</button>
<button data-tab="ran3g" class="signal-tab" style="${tabBtnStyle(false)}">RAN 3G</button>
<button data-tab="alarm" class="signal-tab" style="${tabBtnStyle(false)}">Alarm</button>
<button data-tab="signal" class="signal-tab signal-tab-active" style="flex:1;padding:5px 0;font-size:11px;font-weight:600;border-radius:6px;border:none;cursor:pointer;background:#2563eb;color:#ffffff;">Signal</button>
<button data-tab="ran2g" class="signal-tab" style="flex:1;padding:5px 0;font-size:11px;font-weight:600;border-radius:6px;border:none;cursor:pointer;background:#f1f5f9;color:#475569;">RAN 2G</button>
<button data-tab="ran3g" class="signal-tab" style="flex:1;padding:5px 0;font-size:11px;font-weight:600;border-radius:6px;border:none;cursor:pointer;background:#f1f5f9;color:#475569;">RAN 3G</button>
<button data-tab="alarm" class="signal-tab" style="flex:1;padding:5px 0;font-size:11px;font-weight:600;border-radius:6px;border:none;cursor:pointer;background:#f1f5f9;color:#475569;">Alarm</button>
</div>
<div data-content="signal" class="signal-content">
<div style="display:flex;gap:6px;">
${signalCardHtml('3G', 'Browsing, email, video call', site, 0)}
${signalCardHtml('Mobile BB', 'Laptop browsing & downloads', site, 1)}
${signalCardHtml('4G', 'Browsing, email, video call', site, 0)}
${signalCardHtml('3G', 'Laptop browsing & downloads', site, 1)}
${signalCardHtml('2G', 'Calling and texting', site, 2)}
</div>
<div style="display:flex;gap:8px;margin-top:10px;">
<button data-action="suggest" style="${actionBtnStyle('#2563eb', '#ffffff')}">Suggest</button>
<button data-action="complaint" style="${actionBtnStyle('#ffffff', '#2563eb')}">Complaint</button>
</div>
</div>
<div data-content="ran2g" class="signal-content" style="display:none;">
<div style="font-size:11px;color:#64748b;">Cell / Sector on 2G band</div>
${cellListHtml(ran2g)}
</div>
<div data-content="ran3g" class="signal-content" style="display:none;">
<div style="font-size:11px;color:#64748b;">Cell / Sector on 3G band</div>
${cellListHtml(ran3g)}
</div>
<div data-content="alarm" class="signal-content" style="display:none;">
<div style="padding:14px 0;text-align:center;font-size:12px;color:#94a3b8;">No active alarms.</div>
</div>
</div>`;
};
const tabBtnStyle = (active: boolean) =>
`flex:1;padding:5px 0;font-size:11px;font-weight:600;border-radius:6px;border:none;cursor:pointer;` +
(active ? 'background:#2563eb;color:#ffffff;' : 'background:#f1f5f9;color:#475569;');
const actionBtnStyle = (bg: string, color: string) =>
`flex:1;padding:6px 0;font-size:12px;font-weight:600;border-radius:6px;cursor:pointer;` +
`background:${bg};color:${color};border:1px solid #2563eb;`;
// Wires up tab-switching and Suggest/Complaint clicks for a just-opened
// signal popup. Leaflet popups are plain HTML, so this is done imperatively
// rather than through React state.
const wireSignalPopup = (container: HTMLElement, site: string) => {
const tabs = container.querySelectorAll<HTMLButtonElement>('.signal-tab');
const contents = container.querySelectorAll<HTMLElement>('.signal-content');
tabs.forEach((btn) => {
btn.addEventListener('click', () => {
const target = btn.dataset.tab;
tabs.forEach((b) => {
const isActive = b === btn;
b.setAttribute('style', tabBtnStyle(isActive));
});
contents.forEach((c) => {
c.style.display = c.dataset.content === target ? 'block' : 'none';
});
});
});
container.querySelectorAll<HTMLButtonElement>('[data-action]').forEach((btn) => {
btn.addEventListener('click', () => {
// TODO: wire these up to real endpoints once available.
console.log(`[SitesSatelliteMap] "${btn.dataset.action}" clicked for site "${site}"`);
});
});
};
const SitesSatelliteMap = () => {
const mapContainerRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<L.Map | null>(null);
const markerLayerGroupRef = useRef<L.LayerGroup>(L.layerGroup());
const polygonLayerGroupRef = useRef<L.LayerGroup>(L.layerGroup());
const markerByKeyRef = useRef<Map<string, L.Marker>>(new Map());
const [search, setSearch] = useState('');
const [selectedCount, setSelectedCount] = useState(sitePoints.length);
@ -222,43 +214,72 @@ const SitesSatelliteMap = () => {
});
mapRef.current = map;
// Esri World Imagery — free satellite tiles, no API key required.
L.tileLayer(
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
{
attribution:
'Tiles &copy; Esri &mdash; Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community',
maxZoom: 19
}
).addTo(map);
L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: 'Tiles &copy; Esri &mdash; Source: Esri, Maxar, Earthstar Geographics',
maxZoom: 19
}).addTo(map);
// Optional labels/roads overlay on top of the satellite imagery.
L.tileLayer(
'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}',
{ maxZoom: 19, opacity: 0.9 }
).addTo(map);
L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}', {
maxZoom: 19,
opacity: 0.9
}).addTo(map);
// Every site gets its own blue pin placed directly on the map — no
// clustering, no group bubbles.
// Keduanya langsung dimasukkan secara permanen ke peta utama
markerLayerGroupRef.current.addTo(map);
polygonLayerGroupRef.current.addTo(map);
// 1. Render Seluruh Marker Pin Biru
grouped.forEach((points, key) => {
const [lat, lng] = key.split(',').map(Number);
const marker = L.marker([lat, lng], { icon: pinIcon() }).addTo(map);
const marker = L.marker([lat, lng], { icon: pinIcon() });
marker.bindPopup(buildSignalPopupHtml(points, lat, lng), { maxWidth: 320, minWidth: 290 });
marker.addTo(markerLayerGroupRef.current);
markerByKeyRef.current.set(key, marker);
});
// Wire up the rich popup's interactive bits (tabs + buttons) every time
// one is opened, since Leaflet re-renders the DOM content on each open.
// 2. Render Batas Klaster / Polygon (Selalu Menetap di Peta)
CLUSTER_AREAS.forEach((area) => {
const polygon = L.polygon(area.coords, {
color: '#b91c1c', // Warna merah tua garis batas luar
weight: 2,
fillColor: area.color, // Warna kemerahan transparan sesuai request gambar
fillOpacity: 0.25
});
// Bind dialog tab analisis ke area klaster ketika diklik
polygon.bindPopup(buildClusterPopupHtml(area), { maxWidth: 360, minWidth: 310 });
polygon.addTo(polygonLayerGroupRef.current);
});
// Logika pengontrol visibilitas Pin Biru berdasarkan tingkatan Zoom
const handleZoomVisibility = () => {
const currentZoom = map.getZoom();
if (currentZoom >= ZOOM_THRESHOLD) {
// Mode Zoom In: Tampilkan Pin Biru Individual di atas Polygon
if (!map.hasLayer(markerLayerGroupRef.current)) map.addLayer(markerLayerGroupRef.current);
} else {
// Mode Zoom Out: Sembunyikan Pin Biru, biarkan Area Klaster saja
if (map.hasLayer(markerLayerGroupRef.current)) map.removeLayer(markerLayerGroupRef.current);
}
};
handleZoomVisibility();
map.on('zoomend', handleZoomVisibility);
// Delegasi inisialisasi Event Listener interaktif Tab saat popup dibuka
const onPopupOpen = (e: L.PopupEvent) => {
const el = e.popup.getElement();
const content = el?.querySelector<HTMLElement>('.signal-popup');
if (!content) return;
const siteName = content.querySelector('div')?.textContent ?? '';
wireSignalPopup(content, siteName);
// Kasus A: Jika yang dibuka adalah Dialog Klaster Area
const clusterContent = el?.querySelector<HTMLElement>('.cluster-popup');
if (clusterContent) {
wireClusterPopup(clusterContent);
}
};
map.on('popupopen', onPopupOpen);
return () => {
map.off('zoomend', handleZoomVisibility);
map.off('popupopen', onPopupOpen);
map.remove();
mapRef.current = null;
@ -266,8 +287,6 @@ const SitesSatelliteMap = () => {
};
}, [grouped]);
// Simple search: pans/zooms to a matching cell, sector, or site name and
// opens its popup.
const handleSearch = (value: string) => {
setSearch(value);
if (!value) {
@ -276,17 +295,14 @@ const SitesSatelliteMap = () => {
}
const q = value.toLowerCase();
const matches = sitePoints.filter(
(p) =>
p.cell.toLowerCase().includes(q) ||
p.sector.toLowerCase().includes(q) ||
p.site.toLowerCase().includes(q)
(p) => p.cell.toLowerCase().includes(q) || p.sector.toLowerCase().includes(q) || p.site.toLowerCase().includes(q)
);
setSelectedCount(matches.length);
if (matches.length > 0 && mapRef.current) {
const first = matches[0];
const key = `${first.lat.toFixed(6)},${first.lng.toFixed(6)}`;
mapRef.current.setView([first.lat, first.lng], 17);
mapRef.current.setView([first.lat, first.lng], ZOOM_THRESHOLD + 2);
const marker = markerByKeyRef.current.get(key);
if (marker) marker.openPopup();
}
@ -296,8 +312,7 @@ const SitesSatelliteMap = () => {
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-sm text-gray-600">
Showing <span className="font-semibold">{selectedCount}</span> from {sitePoints.length} points
(Cell/Sector)
Showing <span className="font-semibold">{selectedCount}</span> from {sitePoints.length} points (Cell/Sector)
</div>
<input
type="text"