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('No image'); 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}
${r.notes || ''}`); markersLayer.addLayer(marker); }); try{ const boundsObj = L.latLngBounds(bounds); if(bounds.length === 1){ map.setView(bounds[0],14); } else { map.fitBounds(boundsObj.pad(0.1)); } }catch(e){ map.setView([3.1390,101.6869],12); } // heatmap if(heatEnabled){ const heatPoints = filtered.map(r=> [r.location.lat, r.location.lng, 0.6]); if(heatRef.current){ heatRef.current.setLatLngs(heatPoints); } else { heatRef.current = L.heatLayer(heatPoints, {radius:25,blur:15,maxZoom:17}).addTo(map); } } else { if(heatRef.current){ heatRef.current.remove(); heatRef.current = null; } } },[filtered, heatEnabled]); const applyFilters = ()=> { setAppliedFilters({ categories: new Set(formCategories), severities: new Set(formSeverities), statuses: new Set(formStatuses), from: formFrom, to: formTo }); }; const resetFilters = ()=> { const cats = new Set(CATEGORY_LIST); const sevs = new Set(SEVERITIES); const stats = new Set(STATUSES); setFormCategories(cats); setFormSeverities(sevs); setFormStatuses(stats); setFormFrom(defaultFrom); setFormTo(defaultTo); setAppliedFilters({ categories: cats, severities: sevs, statuses: stats, from:defaultFrom, to:defaultTo }); }; // helper toggle functions const toggleSet = (setState, currentSet, val) => { const s = new Set(currentSet); if(s.has(val)) s.delete(val); else s.add(val); setState(s); }; // sorted queue const sortedQueue = useMemo(()=>{ const order = { high:0, medium:1, low:2 }; return [...filtered].sort((a,b)=>{ const sa = order[a.severity] ?? 3; const sb = order[b.severity] ?? 3; if(sa !== sb) return sa - sb; return dayjs(b.createdAt).valueOf() - dayjs(a.createdAt).valueOf(); }); },[filtered]); const availableStatuses = useMemo(()=>{ const s = new Set(STATUSES); rawData.forEach(r=>{ if(r && r.status) s.add(r.status); }); return Array.from(s); }, [rawData]); const updateTicketStatus = async (reportId, newStatus) => { try { const res = await fetch(`${BACKEND_BASE}/api/tickets/${reportId}/status`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: newStatus }) }); if (res.ok) { // Prefer using returned updated ticket if provided let updated = null; try { updated = await res.json(); } catch(e){ updated = null; } if (updated) { const normalized = normalizeReportData(updated); setRawData(prev => prev.map(r => r.id === reportId ? normalized : r)); if (selected && selected.id === reportId) setSelected(normalized); } else { // No body returned - update local state setRawData(prev=> prev.map(r=> r.id === reportId ? {...r, status: newStatus, updatedAt: new Date().toISOString()} : r)); if(selected && selected.id === reportId) setSelected(prev => ({...prev, status: newStatus, updatedAt: new Date().toISOString()})); } showToast('Status updated'); return true; } else { const text = await res.text().catch(()=> ''); console.warn('Status update failed', text); showToast('Failed to update status', 'Retry', ()=> updateTicketStatus(reportId, newStatus)); return false; } } catch (err) { console.error('Error updating status:', err); showToast('Failed to update status', 'Retry', ()=> updateTicketStatus(reportId, newStatus)); return false; } }; const cycleStatus = async (reportId) => { const currentReport = rawData.find(r => r.id === reportId); if (!currentReport) return; const idx = availableStatuses.indexOf(currentReport.status); const nextStatus = availableStatuses[(idx + 1) % availableStatuses.length] || STATUSES[(STATUSES.indexOf(currentReport.status) + 1) % STATUSES.length]; await updateTicketStatus(reportId, nextStatus); }; const openInMaps = (r)=>{ const lat = r.location.lat; const lng = r.location.lng; window.open(`https://www.google.com/maps/search/?api=1&query=${lat},${lng}`, '_blank'); }; const navigateToLocation = (r) => { const map = mapRef.current; if (!map || !r.location) return; const { lat, lng } = r.location; const currentZoom = map.getZoom(); const targetZoom = 20; // Maximum zoom level for focusing on a specific location // First zoom out a bit for animation effect, then zoom to target map.flyTo([lat, lng], targetZoom, { animate: true, duration: 1.5, easeLinearity: 0.25 }); // Also set the selected item to show details setSelected(r); }; return (
{t('dashboard.brand') || 'FixMate'}
{t('map.noReports') || 'No reports match filters'}
{t('stats.total') || 'Total'}: {filtered.length}
{filtered.filter(x=>x.severity==='high').length} {t('severity.high') || 'High'}
{filtered.filter(x=>x.severity==='medium').length} {t('severity.medium') || 'Medium'}
{filtered.filter(x=>x.severity==='low').length} {t('severity.low') || 'Low'}
{/* Detail Drawer */}
{selected ? (
{(selected.image_url || selected.imagePath) ? ( {selected.category}{ e.currentTarget.style.display='none'; }} /> ) : (t(`category.${selected.category}`) || selected.category)}

{t(`category.${selected.category}`) || selected.category}

{t(`severity.${selected.severity}`) || selected.severity} {t(`status.${selected.status}`) || selected.status} {dayjs(selected.createdAt).fromNow()}

{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')}

) : null}
); } // mount const root = ReactDOM.createRoot(document.getElementById('root')); root.render();