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 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()); } // Normalize API data to expected format function normalizeReportData(report) { // If it's already in the expected format (from demo data), return as is if (report.location && report.location.lat !== undefined) { return report; } // Convert API format to expected format return { id: report.ticket_id, category: report.category || 'other', severity: report.severity || 'low', status: report.status || 'submitted', notes: report.description || '', location: { lat: report.latitude, lng: report.longitude }, createdAt: report.created_at, updatedAt: report.updated_at, // Add missing fields with defaults userId: report.user_id, imagePath: report.image_path }; } 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); useEffect(()=>{ // Try to fetch from backend API first, fallback to demo data fetch('http://127.0.0.1:8000/api/tickets') .then(r => r.ok ? r.json() : Promise.reject('API not available')) .then(data => { console.log('Loaded data from API:', data.length, 'reports'); const normalizedData = data.map(normalizeReportData); setRawData(normalizedData); setLoading(false); }) .catch(err => { console.log('API not available, using demo data:', err); return fetchJSON('./data/demo-reports.json'); }) .then(data => { if (data) { console.log('Loaded demo data:', data.length, 'reports'); // Demo data is already in the correct format, but normalize just in case const normalizedData = data.map(normalizeReportData); setRawData(normalizedData); } setLoading(false); }) .catch(err => { console.error('Error loading data:', err); 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 cycleStatus = async (reportId)=>{ try { // Find the current report to get its status const currentReport = rawData.find(r => r.id === reportId); if (!currentReport) return; const idx = STATUSES.indexOf(currentReport.status); const nextStatus = STATUSES[(idx + 1) % STATUSES.length]; // Try to update via API first const success = await fetch(`http://127.0.0.1:8000/api/tickets/${reportId}?new_status=${encodeURIComponent(nextStatus)}`, { method: 'PATCH' }).then(r => r.ok); if (success) { // If API update successful, refresh data from API const response = await fetch('http://127.0.0.1:8000/api/tickets'); if (response.ok) { const data = await response.json(); const normalizedData = data.map(normalizeReportData); setRawData(normalizedData); // Update selected item const updatedReport = normalizedData.find(r => r.id === reportId); setSelected(updatedReport || null); } } else { console.error('Failed to update status via API'); // Fallback to local update setRawData(prev=>{ const out = prev.map(r=>{ if(r.id !== reportId) return r; return {...r, status: nextStatus, updatedAt: new Date().toISOString() }; }); if(selected && selected.id === reportId){ const newSel = out.find(r=>r.id === reportId); setSelected(newSel || null); } return out; }); } } catch (error) { console.error('Error updating status:', error); // Fallback to local update setRawData(prev=>{ const out = prev.map(r=>{ if(r.id !== reportId) return r; const idx = STATUSES.indexOf(r.status); const ni = (idx + 1) % STATUSES.length; return {...r, status: STATUSES[ni], updatedAt: new Date().toISOString() }; }); if(selected && selected.id === reportId){ const newSel = out.find(r=>r.id === reportId); setSelected(newSel || null); } return out; }); } }; 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 ? (
{/* placeholder */}{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.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();