const { useState, useEffect, useRef, useMemo } = React; dayjs.extend(window.dayjs_plugin_relativeTime); const CATEGORY_LIST = ['pothole','streetlight','signage','trash','drainage','other']; const SEVERITIES = ['high','medium','low']; const STATUSES = ['submitted','in_progress','fixed']; const BACKEND_BASE = "http://192.168.100.59:8000"; const SEVERITY_COLOR = { high:'#D32F2F', medium:'#F57C00', low:'#388E3C' }; const STATUS_COLOR = { submitted:'#1976D2', in_progress:'#7B1FA2', fixed:'#455A64' }; function fetchJSON(path){ return fetch(path).then(r=>r.json()); } // Fetch tickets from backend async function fetchTickets(){ const res = await fetch(`${BACKEND_BASE}/api/tickets`); if(!res.ok) throw new Error('Failed to fetch tickets'); const data = await res.json(); return data; } // Normalize API data to expected format function normalizeReportData(report) { // Already normalized demo format (has location.lat) if (report.location && report.location.lat !== undefined) { return { id: report.id || report.ticket_id || report.ticketId, category: report.category || 'other', severity: report.severity || 'low', status: report.status || 'submitted', notes: report.notes || report.description || '', location: report.location, createdAt: report.createdAt || report.created_at, updatedAt: report.updatedAt || report.updated_at, userId: report.userId || report.user_id, userName: report.userName || report.user_name || null, address: report.address || null, image_url: report.image_url || report.imagePath || report.image_path || null }; } // Convert backend API format to the app format return { id: report.id || report.ticket_id || report.ticketId, category: report.category || 'other', severity: report.severity || 'low', status: report.status || 'submitted', notes: report.description || report.notes || '', location: { lat: (report.latitude !== undefined ? report.latitude : (report.lat !== undefined ? report.lat : null)), lng: (report.longitude !== undefined ? report.longitude : (report.lng !== undefined ? report.lng : null)) }, createdAt: report.created_at || report.createdAt, updatedAt: report.updated_at || report.updatedAt, userId: report.user_id || report.userId, userName: report.user_name || report.userName || null, address: report.address || null, image_url: report.image_url || report.image_path || report.imagePath || null }; } function useI18n(initialLang='en'){ const [lang,setLang] = useState(localStorage.getItem('lang') || initialLang); const [map,setMap] = useState({en:null,ms:null}); useEffect(()=>{ Promise.all([fetchJSON('./i18n/en.json'), fetchJSON('./i18n/ms.json')]) .then(([en,ms])=> setMap({en,ms})) .catch(err=> console.error('i18n load',err)); },[]); const t = (key)=> (map[lang] && map[lang][key]) || (map['en'] && map['en'][key]) || key; useEffect(()=> localStorage.setItem('lang',lang),[lang]); return {lang,setLang,t,i18nMap:map}; } function App(){ const {lang,setLang,t,i18nMap} = useI18n(); const [rawData,setRawData] = useState([]); const [loading,setLoading] = useState(true); const defaultFrom = dayjs().subtract(30,'day').format('YYYY-MM-DD'); const defaultTo = dayjs().format('YYYY-MM-DD'); // form state const [formCategories,setFormCategories] = useState(new Set(CATEGORY_LIST)); const [formSeverities,setFormSeverities] = useState(new Set(SEVERITIES)); const [formStatuses,setFormStatuses] = useState(new Set(STATUSES)); const [formFrom,setFormFrom] = useState(defaultFrom); const [formTo,setFormTo] = useState(defaultTo); // applied filters const [appliedFilters,setAppliedFilters] = useState({ categories:new Set(CATEGORY_LIST), severities:new Set(SEVERITIES), statuses:new Set(STATUSES), from:defaultFrom, to:defaultTo }); const [filtered,setFiltered] = useState([]); const [selected,setSelected] = useState(null); const mapRef = useRef(null); const markersRef = useRef(null); const heatRef = useRef(null); const mapContainerRef = useRef(null); const [heatEnabled,setHeatEnabled] = useState(false); // simple toast container for non-blocking errors / retry actions const toastContainerRef = useRef(null); useEffect(()=> { const c = document.createElement('div'); c.style.position = 'fixed'; c.style.right = '12px'; c.style.bottom = '12px'; c.style.zIndex = 9999; toastContainerRef.current = c; document.body.appendChild(c); return ()=> { if(c.parentNode) c.parentNode.removeChild(c); }; }, []); const showToast = (msg, actionLabel, action) => { const c = toastContainerRef.current; if(!c) { console.warn(msg); return; } const el = document.createElement('div'); el.style.background = '#111'; el.style.color = '#fff'; el.style.padding = '8px 12px'; el.style.marginTop = '8px'; el.style.borderRadius = '6px'; el.style.boxShadow = '0 2px 6px rgba(0,0,0,0.3)'; el.style.display = 'flex'; el.style.alignItems = 'center'; el.textContent = msg; if(actionLabel && action){ const btn = document.createElement('button'); btn.textContent = actionLabel; btn.style.marginLeft = '12px'; btn.style.background = 'transparent'; btn.style.color = '#4FC3F7'; btn.style.border = 'none'; btn.style.cursor = 'pointer'; btn.onclick = ()=> { action(); if(el.parentNode) el.parentNode.removeChild(el); }; el.appendChild(btn); } c.appendChild(el); setTimeout(()=> { if(el.parentNode) el.parentNode.removeChild(el); }, 8000); }; const PLACEHOLDER_SRC = 'data:image/svg+xml;utf8,' + encodeURIComponent(''); useEffect(()=>{ setLoading(true); fetchTickets() .then(data => { console.log('Loaded data from backend:', (Array.isArray(data) ? data.length : 0), 'reports'); const normalizedData = (data || []).map(normalizeReportData); setRawData(normalizedData); setLoading(false); }) .catch(err => { console.warn('Failed to load tickets from backend:', err); showToast('Failed to load tickets from backend.'); setRawData([]); setLoading(false); }); },[]); useEffect(()=>{ // init map once const map = L.map('map', { center:[3.1390,101.6869], zoom:12, preferCanvas:true }); L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom:19, attribution:'© OpenStreetMap' }).addTo(map); mapRef.current = map; markersRef.current = L.markerClusterGroup(); map.addLayer(markersRef.current); return ()=> { map.remove(); mapRef.current=null; markersRef.current=null; }; },[]); // compute filtered when rawData or appliedFilters change useEffect(()=>{ if(!rawData) return; const from = dayjs(appliedFilters.from).startOf('day'); const to = dayjs(appliedFilters.to).endOf('day'); const out = rawData.filter(r=>{ if(!r || !r.createdAt) return false; const created = dayjs(r.createdAt); if(created.isBefore(from) || created.isAfter(to)) return false; if(!appliedFilters.categories.has(r.category)) return false; if(!appliedFilters.severities.has(r.severity)) return false; if(!appliedFilters.statuses.has(r.status)) return false; return true; }); setFiltered(out); },[rawData, appliedFilters]); // update markers and heatmap when filtered changes useEffect(()=>{ const map = mapRef.current; const markersLayer = markersRef.current; if(!map || !markersLayer) return; markersLayer.clearLayers(); if(filtered.length === 0){ // remove heat if present if(heatRef.current){ heatRef.current.remove(); heatRef.current = null; } map.setView([3.1390,101.6869],12); const container = document.querySelector('.map-panel'); if(container) container.classList.add('no-reports'); return; } else { const container = document.querySelector('.map-panel'); if(container) container.classList.remove('no-reports'); } const bounds = []; filtered.forEach(r=>{ if(!r.location) return; const lat = r.location.lat; const lng = r.location.lng; bounds.push([lat,lng]); const color = SEVERITY_COLOR[r.severity] || '#333'; const icon = L.divIcon({ className: 'custom-marker', html: `
`, iconSize:[22,22], iconAnchor:[11,11] }); const marker = L.marker([lat,lng], { icon }); marker.on('click', ()=> { setSelected(r); }); marker.bindPopup(`${r.category}{t('drawer.details') || 'Details'}
{selected.notes ?{selected.notes}
:{t('drawer.noNotes') || 'No additional notes'}
}{t('label.submittedBy') || 'Submitted by'}: {selected.userName || (t('label.guest') || 'Guest')}
{t('label.place') || 'Place'}: {selected.address ? selected.address : `${selected.location.lat.toFixed(5)}, ${selected.location.lng.toFixed(5)}`}
{t('label.location') || 'Location'}: {selected.location.lat.toFixed(5)}, {selected.location.lng.toFixed(5)}
{t('label.createdAt') || 'Created'}: {dayjs(selected.createdAt).format('YYYY-MM-DD HH:mm')}