diff --git a/src/pages/dashboards/home/blocks/SitesateliteMap.tsx b/src/pages/dashboards/home/blocks/SitesateliteMap.tsx
index 51b0bc2..584dd70 100644
--- a/src/pages/dashboards/home/blocks/SitesateliteMap.tsx
+++ b/src/pages/dashboards/home/blocks/SitesateliteMap.tsx
@@ -64,6 +64,145 @@ const pinIcon = (multi: boolean) => {
});
};
+// ---------------------------------------------------------------------------
+// "Signal detail" popup (blue pins only) — mimics the tabbed
+// Signal / RAN 2G / RAN 3G / Alarm dialog from the reference screenshot.
+// ---------------------------------------------------------------------------
+
+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 `
+ ${heights
+ .map(
+ (h, i) =>
+ `
`
+ )
+ .join('')}
+
`;
+};
+
+const signalCardHtml = (title: string, desc: string, seed: string, offset: number) => {
+ const q = placeholderQuality(seed, offset);
+ return `
+
+
${title}
+
${signalBarsHtml(q.level)}
+
${q.label}
+
${desc}
+
`;
+};
+
+const cellListHtml = (points: SitePoint[]) => {
+ if (points.length === 0) {
+ return `No cells in this band.
`;
+ }
+ const rows = points
+ .map((p) => `| ${p.cell} | ${p.sector} |
`)
+ .join('');
+ return ``;
+};
+
+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'));
+
+ return `
+ `;
+};
+
+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('.signal-tab');
+ const contents = container.querySelectorAll('.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('[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(null);
const mapRef = useRef(null);
@@ -105,21 +244,27 @@ const SitesSatelliteMap = () => {
grouped.forEach((points, key) => {
const [lat, lng] = key.split(',').map(Number);
- const marker = L.marker([lat, lng], { icon: pinIcon(points.length > 1) });
+ const isMulti = points.length > 1;
+ const marker = L.marker([lat, lng], { icon: pinIcon(isMulti) });
- const site = points[0].site;
- const location = points[0].location;
- const rows = points
- .map((p) => `| ${p.cell} | ${p.sector} |
`)
- .join('');
-
- marker.bindPopup(`
-
-
${site}
-
${location} · ${lat.toFixed(5)}, ${lng.toFixed(5)}
-
-
- `);
+ if (isMulti) {
+ // Blue pin -> rich "Signal" dialog.
+ marker.bindPopup(buildSignalPopupHtml(points, lat, lng), { maxWidth: 320, minWidth: 290 });
+ } else {
+ // Red pin -> simple cell/sector info (unchanged).
+ const site = points[0].site;
+ const location = points[0].location;
+ const rows = points
+ .map((p) => `| ${p.cell} | ${p.sector} |
`)
+ .join('');
+ marker.bindPopup(`
+
+
${site}
+
${location} · ${lat.toFixed(5)}, ${lng.toFixed(5)}
+
+
+ `);
+ }
markerByKeyRef.current.set(key, marker);
cluster.addLayer(marker);
@@ -127,7 +272,19 @@ const SitesSatelliteMap = () => {
map.addLayer(cluster);
+ // 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.
+ const onPopupOpen = (e: L.PopupEvent) => {
+ const el = e.popup.getElement();
+ const content = el?.querySelector('.signal-popup');
+ if (!content) return;
+ const siteName = content.querySelector('div')?.textContent ?? '';
+ wireSignalPopup(content, siteName);
+ };
+ map.on('popupopen', onPopupOpen);
+
return () => {
+ map.off('popupopen', onPopupOpen);
map.remove();
mapRef.current = null;
clusterRef.current = null;