feat: introduce FixMate Flutter app and React dashboard
- Add Flutter app shell (FixMateApp/MainScreen) with tabs: Report, Map, My Reports, Settings - Implement capture and review flow (image_picker, geolocator, deterministic mock AI), and local storage (SharedPreferences + photo files on mobile) - Build Map screen with flutter_map, marker clustering, filters, legend, marker details, and external maps deeplink - Add My Reports list (view details, cycle status, delete) and Settings (language toggle via Provider, diagnostics, clear all data) - Introduce JSON i18n loader and LocaleProvider; add EN/BM assets - Define models (Report, enums) and UI badges (severity, status) - Add static React dashboard (Leaflet map with clustering, heatmap toggle, filters incl. date range, queue, detail drawer), i18n (EN/BM), and demo data - Update build/config and platform setup: - Extend pubspec with required packages and register i18n assets - Android: add CAMERA and location permissions; pin NDK version - iOS: add usage descriptions for camera, photo library, location - Gradle properties tuned for Windows/UNC stability - Register desktop plugins (Linux/macOS/Windows) - .gitignore: ignore .kilocode - Overhaul README and replace sample widget test
This commit is contained in:
397
dashboard/app.js
Normal file
397
dashboard/app.js
Normal file
@@ -0,0 +1,397 @@
|
||||
|
||||
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()); }
|
||||
|
||||
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(()=>{
|
||||
fetchJSON('./data/demo-reports.json').then(data=>{
|
||||
setRawData(data);
|
||||
setLoading(false);
|
||||
}).catch(err=>{ console.error(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: `<div style="width:18px;height:18px;border-radius:50%;background:${color};border:2px solid #fff;"></div>`,
|
||||
iconSize:[22,22],
|
||||
iconAnchor:[11,11]
|
||||
});
|
||||
const marker = L.marker([lat,lng], { icon });
|
||||
marker.on('click', ()=> {
|
||||
setSelected(r);
|
||||
});
|
||||
marker.bindPopup(`<strong>${r.category}</strong><br/>${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 = (reportId)=>{
|
||||
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 the currently selected item was updated, update the selected state too
|
||||
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');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-root">
|
||||
<header className="header">
|
||||
<div className="brand">{t('dashboard.brand') || 'FixMate'}</div>
|
||||
<div className="lang-toggle">
|
||||
<label style={{fontSize:12, color:'#374151'}}>{t('label.language') || 'Language'}</label>
|
||||
<select value={lang} onChange={e=>setLang(e.target.value)}>
|
||||
<option value="en">EN</option>
|
||||
<option value="ms">BM</option>
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="container">
|
||||
<div className="main">
|
||||
<aside className="panel filters">
|
||||
<h3>{t('dashboard.filters') || 'Filters'}</h3>
|
||||
|
||||
<div className="filter-group">
|
||||
<div className="row space-between"><strong>{t('filter.category') || 'Category'}</strong></div>
|
||||
<div className="checkbox-row" aria-label="categories">
|
||||
{CATEGORY_LIST.map(cat=>(
|
||||
<label key={cat} style={{display:'flex',alignItems:'center',gap:8}}>
|
||||
<input type="checkbox"
|
||||
checked={formCategories.has(cat)}
|
||||
onChange={()=> toggleSet(setFormCategories, formCategories, cat)}
|
||||
/>
|
||||
<span style={{textTransform:'capitalize'}}>{t(`category.${cat}`) || cat}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<div className="row space-between"><strong>{t('filter.severity') || 'Severity'}</strong></div>
|
||||
<div className="multi-select">
|
||||
{SEVERITIES.map(s=>(
|
||||
<button key={s} className={`chip severity-${s}`} onClick={()=> toggleSet(setFormSeverities, formSeverities, s)} aria-pressed={formSeverities.has(s)}>
|
||||
{t(`severity.${s}`) || s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<div className="row space-between"><strong>{t('filter.status') || 'Status'}</strong></div>
|
||||
<div className="multi-select">
|
||||
{STATUSES.map(s=>(
|
||||
<button key={s} className={`chip status-${s}`} onClick={()=> toggleSet(setFormStatuses, formStatuses, s)} aria-pressed={formStatuses.has(s)}>
|
||||
{t(`status.${s}`) || s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<div className="row space-between"><strong>{t('filter.dateRange') || 'Date Range'}</strong></div>
|
||||
<div style={{display:'flex',gap:8,marginTop:8}}>
|
||||
<div style={{display:'flex',flexDirection:'column'}}>
|
||||
<label style={{fontSize:12}}>{t('filter.dateFrom') || 'From'}</label>
|
||||
<input type="date" value={formFrom} onChange={e=>setFormFrom(e.target.value)} />
|
||||
</div>
|
||||
<div style={{display:'flex',flexDirection:'column'}}>
|
||||
<label style={{fontSize:12}}>{t('filter.dateTo') || 'To'}</label>
|
||||
<input type="date" value={formTo} onChange={e=>setFormTo(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{display:'flex',gap:8,marginTop:12}}>
|
||||
<button className="btn" onClick={applyFilters}>{t('btn.apply') || 'Apply'}</button>
|
||||
<button className="btn secondary" onClick={resetFilters}>{t('btn.reset') || 'Reset'}</button>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
|
||||
<section className="panel map-panel" ref={mapContainerRef}>
|
||||
<div id="map"></div>
|
||||
<div className="map-empty">{t('map.noReports') || 'No reports match filters'}</div>
|
||||
</section>
|
||||
|
||||
<aside className="panel">
|
||||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center'}}>
|
||||
<h3>{t('queue.title') || 'Tickets'}</h3>
|
||||
</div>
|
||||
|
||||
<div className="queue-list" role="list">
|
||||
{sortedQueue.map(r=>(
|
||||
<div key={r.id} className="queue-item" role="listitem">
|
||||
<div className="thumb">{t(`category.${r.category}`) || r.category}</div>
|
||||
<div className="item-main">
|
||||
<div className="item-title">{t(`category.${r.category}`) || r.category}</div>
|
||||
<div className="item-meta">
|
||||
<span className={`chip severity-${r.severity}`}>{t(`severity.${r.severity}`) || r.severity}</span>
|
||||
<span className={`chip status-${r.status}`}>{t(`status.${r.status}`) || r.status}</span>
|
||||
<span className="time-ago">{dayjs(r.createdAt).fromNow()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="item-actions">
|
||||
<button className="btn ghost" onClick={()=> { setSelected(r); }}>{t('btn.view') || 'View'}</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<footer className="footer">
|
||||
<div className="stats">
|
||||
<div><strong>{t('stats.total') || 'Total'}: </strong> {filtered.length}</div>
|
||||
<div className="chip severity-high">{filtered.filter(x=>x.severity==='high').length} {t('severity.high') || 'High'}</div>
|
||||
<div className="chip severity-medium">{filtered.filter(x=>x.severity==='medium').length} {t('severity.medium') || 'Medium'}</div>
|
||||
<div className="chip severity-low">{filtered.filter(x=>x.severity==='low').length} {t('severity.low') || 'Low'}</div>
|
||||
</div>
|
||||
<div style={{display:'flex',gap:12,alignItems:'center'}}>
|
||||
<label style={{display:'flex',alignItems:'center',gap:8}}>
|
||||
<input type="checkbox" checked={heatEnabled} onChange={e=>setHeatEnabled(e.target.checked)} /> {t('stats.heatmap') || 'Heatmap'}
|
||||
</label>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Detail Drawer */}
|
||||
<div className={`drawer ${selected ? 'open' : ''}`} role="dialog" aria-hidden={!selected}>
|
||||
{selected ? (
|
||||
<div className="drawer-content" aria-live="polite">
|
||||
<button className="drawer-close" onClick={()=>setSelected(null)} aria-label="Close">×</button>
|
||||
<div className="drawer-header">
|
||||
<div className="drawer-thumb large">{/* placeholder */}{t(`category.${selected.category}`) || selected.category}</div>
|
||||
<div style={{marginLeft:12}}>
|
||||
<h3 style={{margin:0}}>{t(`category.${selected.category}`) || selected.category}</h3>
|
||||
<div style={{display:'flex',gap:8,alignItems:'center',marginTop:6}}>
|
||||
<span className={`chip severity-${selected.severity}`}>{t(`severity.${selected.severity}`) || selected.severity}</span>
|
||||
<span className={`chip status-${selected.status}`}>{t(`status.${selected.status}`) || selected.status}</span>
|
||||
<span style={{color:'#6b7280',fontSize:12}}>{dayjs(selected.createdAt).fromNow()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="drawer-body">
|
||||
<p style={{marginTop:8}}><strong>{t('drawer.details') || 'Details'}</strong></p>
|
||||
{selected.notes ? <p>{selected.notes}</p> : <p style={{opacity:0.7}}>{t('drawer.noNotes') || 'No additional notes'}</p>}
|
||||
<p><strong>{t('label.location') || 'Location'}:</strong> {selected.location.lat.toFixed(5)}, {selected.location.lng.toFixed(5)}</p>
|
||||
<p><strong>{t('label.createdAt') || 'Created'}:</strong> {dayjs(selected.createdAt).format('YYYY-MM-DD HH:mm')}</p>
|
||||
</div>
|
||||
|
||||
<div className="drawer-actions">
|
||||
<button className="btn" onClick={()=>{ cycleStatus(selected.id); }}>
|
||||
{t('drawer.changeStatus') || 'Change Status'}
|
||||
</button>
|
||||
<button className="btn secondary" onClick={()=>openInMaps(selected)}>
|
||||
{t('drawer.openMap') || 'Open Map'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// mount
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<App />);
|
||||
18
dashboard/data/demo-reports.json
Normal file
18
dashboard/data/demo-reports.json
Normal file
@@ -0,0 +1,18 @@
|
||||
[
|
||||
{"id":"r-1","category":"pothole","severity":"high","status":"submitted","location":{"lat":3.1390,"lng":101.6869},"createdAt":"2025-09-20T02:10:00.000Z","updatedAt":"2025-09-20T02:10:00.000Z","notes":"Large pothole near Jalan Ampang intersection"},
|
||||
{"id":"r-2","category":"streetlight","severity":"medium","status":"in_progress","location":{"lat":3.1505,"lng":101.6932},"createdAt":"2025-09-22T10:15:00.000Z","updatedAt":"2025-09-23T09:00:00.000Z","notes":"Flickering streetlight on Jalan Sultan Ismail"},
|
||||
{"id":"r-3","category":"signage","severity":"low","status":"fixed","location":{"lat":3.1321,"lng":101.6800},"createdAt":"2025-09-10T08:30:00.000Z","updatedAt":"2025-09-12T11:00:00.000Z","notes":"Damaged signpost replaced"},
|
||||
{"id":"r-4","category":"trash","severity":"low","status":"submitted","location":{"lat":3.1405,"lng":101.7000},"createdAt":"2025-09-01T07:20:00.000Z","updatedAt":"2025-09-01T07:20:00.000Z","notes":"Illegal dumping behind shoplot"},
|
||||
{"id":"r-5","category":"drainage","severity":"medium","status":"in_progress","location":{"lat":3.1250,"lng":101.6950},"createdAt":"2025-08-20T09:45:00.000Z","updatedAt":"2025-09-02T10:00:00.000Z","notes":"Clogged drain after heavy rain"},
|
||||
{"id":"r-6","category":"other","severity":"medium","status":"fixed","location":{"lat":3.1600,"lng":101.6820},"createdAt":"2025-09-18T14:05:00.000Z","updatedAt":"2025-09-19T08:00:00.000Z","notes":"Broken bench repaired"},
|
||||
{"id":"r-7","category":"pothole","severity":"high","status":"in_progress","location":{"lat":3.1395,"lng":101.6900},"createdAt":"2025-09-24T16:40:00.000Z","updatedAt":"2025-09-24T17:00:00.000Z","notes":"Pothole expanding near bus stop"},
|
||||
{"id":"r-8","category":"streetlight","severity":"low","status":"submitted","location":{"lat":3.1450,"lng":101.6785},"createdAt":"2025-09-05T21:10:00.000Z","updatedAt":"2025-09-05T21:10:00.000Z","notes":"Light out since last week"},
|
||||
{"id":"r-9","category":"signage","severity":"medium","status":"submitted","location":{"lat":3.1422,"lng":101.6891},"createdAt":"2025-09-12T11:30:00.000Z","updatedAt":"2025-09-12T11:30:00.000Z","notes":"Missing directional sign"},
|
||||
{"id":"r-10","category":"trash","severity":"high","status":"fixed","location":{"lat":3.1378,"lng":101.6835},"createdAt":"2025-09-02T05:00:00.000Z","updatedAt":"2025-09-04T09:00:00.000Z","notes":"Overflowing dumpster - cleared"},
|
||||
{"id":"r-11","category":"drainage","severity":"low","status":"submitted","location":{"lat":3.1389,"lng":101.6888},"createdAt":"2025-09-21T12:00:00.000Z","updatedAt":"2025-09-21T12:00:00.000Z","notes":"Slow water flow in gutter"},
|
||||
{"id":"r-12","category":"other","severity":"high","status":"submitted","location":{"lat":3.1285,"lng":101.6812},"createdAt":"2025-08-28T18:20:00.000Z","updatedAt":"2025-08-28T18:20:00.000Z","notes":"Collapsed temporary structure on sidewalk"},
|
||||
{"id":"r-13","category":"pothole","severity":"medium","status":"fixed","location":{"lat":3.1200,"lng":101.6700},"createdAt":"2025-08-10T10:00:00.000Z","updatedAt":"2025-08-15T08:00:00.000Z","notes":"Repaired earlier this month"},
|
||||
{"id":"r-14","category":"streetlight","severity":"medium","status":"in_progress","location":{"lat":3.1523,"lng":101.6920},"createdAt":"2025-09-23T19:00:00.000Z","updatedAt":"2025-09-24T09:30:00.000Z","notes":"Pole leaning, inspection scheduled"},
|
||||
{"id":"r-15","category":"signage","severity":"low","status":"submitted","location":{"lat":3.1399,"lng":101.6870},"createdAt":"2025-09-25T02:00:00.000Z","updatedAt":"2025-09-25T02:00:00.000Z","notes":"Graffiti on traffic sign"},
|
||||
{"id":"r-16","category":"trash","severity":"medium","status":"in_progress","location":{"lat":3.1412,"lng":101.6902},"createdAt":"2025-09-15T13:25:00.000Z","updatedAt":"2025-09-16T08:00:00.000Z","notes":"Community clean-up in progress"}
|
||||
]
|
||||
40
dashboard/i18n/en.json
Normal file
40
dashboard/i18n/en.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"dashboard.brand": "FixMate",
|
||||
"dashboard.filters": "Filters",
|
||||
"queue.title": "Tickets",
|
||||
"drawer.details": "Details",
|
||||
"drawer.changeStatus": "Change Status",
|
||||
"drawer.openMap": "Open Map",
|
||||
"drawer.noNotes": "No additional notes",
|
||||
"btn.apply": "Apply",
|
||||
"btn.reset": "Reset",
|
||||
"btn.view": "View",
|
||||
"label.language": "Language",
|
||||
"label.location": "Location",
|
||||
"label.createdAt": "Created At",
|
||||
"filter.category": "Category",
|
||||
"filter.severity": "Severity",
|
||||
"filter.status": "Status",
|
||||
"filter.dateRange": "Date Range",
|
||||
"filter.dateFrom": "From",
|
||||
"filter.dateTo": "To",
|
||||
"map.noReports": "No reports match filters",
|
||||
"stats.total": "Total",
|
||||
"stats.heatmap": "Heatmap",
|
||||
"severity.high": "High",
|
||||
"severity.medium": "Medium",
|
||||
"severity.low": "Low",
|
||||
"status.submitted": "Submitted",
|
||||
"status.in_progress": "In Progress",
|
||||
"status.fixed": "Fixed",
|
||||
"category.pothole": "Pothole",
|
||||
"category.streetlight": "Streetlight",
|
||||
"category.signage": "Signage",
|
||||
"category.trash": "Trash",
|
||||
"category.drainage": "Drainage",
|
||||
"category.other": "Other",
|
||||
"nav.map": "Map",
|
||||
"nav.settings": "Settings",
|
||||
"label.viewOnMap": "View on Map",
|
||||
"map.legend": "Legend"
|
||||
}
|
||||
40
dashboard/i18n/ms.json
Normal file
40
dashboard/i18n/ms.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"dashboard.brand": "FixMate",
|
||||
"dashboard.filters": "Penapis",
|
||||
"queue.title": "Tiket",
|
||||
"drawer.details": "Maklumat",
|
||||
"drawer.changeStatus": "Tukar Status",
|
||||
"drawer.openMap": "Buka Peta",
|
||||
"drawer.noNotes": "Tiada nota tambahan",
|
||||
"btn.apply": "Terapkan",
|
||||
"btn.reset": "Tetapkan Semula",
|
||||
"btn.view": "Lihat",
|
||||
"label.language": "Bahasa",
|
||||
"label.location": "Lokasi",
|
||||
"label.createdAt": "Dicipta Pada",
|
||||
"filter.category": "Kategori",
|
||||
"filter.severity": "Keparahan",
|
||||
"filter.status": "Status",
|
||||
"filter.dateRange": "Julat Tarikh",
|
||||
"filter.dateFrom": "Dari",
|
||||
"filter.dateTo": "Hingga",
|
||||
"map.noReports": "Tiada laporan sepadan dengan penapis",
|
||||
"stats.total": "Jumlah",
|
||||
"stats.heatmap": "Peta Panas",
|
||||
"severity.high": "Tinggi",
|
||||
"severity.medium": "Sederhana",
|
||||
"severity.low": "Rendah",
|
||||
"status.submitted": "Dihantar",
|
||||
"status.in_progress": "Sedang Diproses",
|
||||
"status.fixed": "Dibaiki",
|
||||
"category.pothole": "Lubang Jalan",
|
||||
"category.streetlight": "Lampu Jalan",
|
||||
"category.signage": "Papan Tanda",
|
||||
"category.trash": "Sampah",
|
||||
"category.drainage": "Saliran",
|
||||
"category.other": "Lain-lain",
|
||||
"nav.map": "Peta",
|
||||
"nav.settings": "Tetapan",
|
||||
"label.viewOnMap": "Lihat di Peta",
|
||||
"map.legend": "Legenda"
|
||||
}
|
||||
32
dashboard/index.html
Normal file
32
dashboard/index.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>FixMate Dashboard</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" />
|
||||
<style>
|
||||
/* small inline to ensure map icon z-index etc maybe not needed */
|
||||
body { margin:0; font-family: Arial, sans-serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<!-- Libraries -->
|
||||
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
|
||||
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
|
||||
<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
|
||||
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
|
||||
<script src="https://unpkg.com/dayjs/dayjs.min.js"></script>
|
||||
<script src="https://unpkg.com/dayjs/plugin/relativeTime.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
<script src="https://unpkg.com/leaflet.heat/dist/leaflet-heat.js"></script>
|
||||
|
||||
<!-- App -->
|
||||
<script type="text/babel" src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
170
dashboard/styles.css
Normal file
170
dashboard/styles.css
Normal file
@@ -0,0 +1,170 @@
|
||||
:root{
|
||||
--bg:#f8fafc;
|
||||
--panel:#ffffff;
|
||||
--muted:#6b7280;
|
||||
--accent:#0ea5a4;
|
||||
--severity-high:#D32F2F;
|
||||
--severity-medium:#F57C00;
|
||||
--severity-low:#388E3C;
|
||||
--status-submitted:#1976D2;
|
||||
--status-in_progress:#7B1FA2;
|
||||
--status-fixed:#455A64;
|
||||
--shadow: 0 6px 18px rgba(15,23,42,0.08);
|
||||
--surface-contrast: #111827;
|
||||
}
|
||||
|
||||
*{box-sizing:border-box}
|
||||
html,body,#root{height:100%}
|
||||
body{
|
||||
margin:0;
|
||||
font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
|
||||
background:var(--bg);
|
||||
color:var(--surface-contrast);
|
||||
-webkit-font-smoothing:antialiased;
|
||||
-moz-osx-font-smoothing:grayscale;
|
||||
font-size:14px;
|
||||
}
|
||||
|
||||
.header{
|
||||
height:56px;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
padding:0 16px;
|
||||
background:var(--panel);
|
||||
border-bottom:1px solid #e6eef3;
|
||||
box-shadow: none;
|
||||
z-index:100;
|
||||
}
|
||||
|
||||
.brand{font-weight:700;font-size:18px;color:#111827}
|
||||
.lang-toggle select{padding:6px;border-radius:6px;border:1px solid #e6eef3;background:white}
|
||||
|
||||
.app-root{height:100vh;display:flex;flex-direction:column}
|
||||
.container{
|
||||
height:calc(100vh - 56px);
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:8px;
|
||||
padding:12px;
|
||||
}
|
||||
|
||||
/* main area */
|
||||
.main{
|
||||
display:grid;
|
||||
grid-template-columns:300px 1fr 340px;
|
||||
gap:12px;
|
||||
align-items:stretch;
|
||||
flex:1;
|
||||
}
|
||||
|
||||
/* panels */
|
||||
.panel{
|
||||
background:var(--panel);
|
||||
border-radius:8px;
|
||||
box-shadow:var(--shadow);
|
||||
padding:12px;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
min-height:0;
|
||||
}
|
||||
|
||||
.filters h3{margin:0 0 8px 0}
|
||||
.filter-group{margin-bottom:12px}
|
||||
.checkbox-row{display:flex;flex-direction:column;gap:6px;max-height:220px;overflow:auto;padding-right:6px}
|
||||
.checkbox-row label{font-size:13px;color:#111827}
|
||||
|
||||
/* chips/buttons */
|
||||
.btn{
|
||||
background:var(--accent);
|
||||
color:white;
|
||||
border:none;
|
||||
padding:8px 12px;
|
||||
border-radius:6px;
|
||||
cursor:pointer;
|
||||
font-weight:600;
|
||||
}
|
||||
.btn.secondary{background:#f1f5f9;color:#0f172a}
|
||||
.btn.ghost{background:transparent;border:1px solid #e6eef3;color:#0f172a;padding:6px 10px}
|
||||
.btn:focus{outline:2px solid rgba(14,165,164,0.25)}
|
||||
|
||||
.multi-select{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.chip{display:inline-block;padding:4px 8px;border-radius:14px;font-size:13px;color:white}
|
||||
.chip.severity-high{background:var(--severity-high)}
|
||||
.chip.severity-medium{background:var(--severity-medium)}
|
||||
.chip.severity-low{background:var(--severity-low)}
|
||||
.chip.status-submitted{background:var(--status-submitted)}
|
||||
.chip.status-in_progress{background:var(--status-in_progress)}
|
||||
.chip.status-fixed{background:var(--status-fixed)}
|
||||
|
||||
/* severity buttons in filter */
|
||||
button.chip{border:none;cursor:pointer;opacity:0.95}
|
||||
button.chip[aria-pressed="false"]{opacity:0.55;filter:grayscale(0.15)}
|
||||
|
||||
/* map panel */
|
||||
.map-panel{position:relative;min-height:0;height:100%;padding:0;overflow:hidden}
|
||||
#map{width:100%;height:100%}
|
||||
.map-panel .map-empty{
|
||||
display:none;
|
||||
position:absolute;
|
||||
left:0;right:0;top:0;bottom:0;
|
||||
align-items:center;justify-content:center;
|
||||
font-size:18px;color:#374151;background:rgba(255,255,255,0.85);
|
||||
z-index:800;
|
||||
}
|
||||
.map-panel.no-reports .map-empty{display:flex}
|
||||
|
||||
/* queue list */
|
||||
.queue-list{display:flex;flex-direction:column;gap:8px;overflow:auto;padding-right:6px}
|
||||
.queue-item{display:flex;align-items:center;gap:12px;padding:8px;border-radius:8px;border:1px solid #eef2f7;background:linear-gradient(180deg,#fff,#fbfdff)}
|
||||
.thumb{width:56px;height:56px;border-radius:6px;background:linear-gradient(180deg,#eef2ff,#fff);display:flex;align-items:center;justify-content:center;color:#0f172a;font-weight:700}
|
||||
.item-main{flex:1;min-width:0}
|
||||
.item-title{font-weight:600;text-overflow:ellipsis;overflow:hidden;white-space:nowrap}
|
||||
.item-meta{display:flex;gap:8px;align-items:center;margin-top:6px;font-size:12px;color:var(--muted)}
|
||||
.item-actions{display:flex;align-items:center}
|
||||
|
||||
/* drawer */
|
||||
.drawer{
|
||||
position:fixed;
|
||||
top:56px;
|
||||
right:0;
|
||||
bottom:0;
|
||||
width:380px;
|
||||
transform:translateX(100%);
|
||||
transition:transform .28s ease;
|
||||
z-index:1200;
|
||||
display:flex;
|
||||
align-items:flex-start;
|
||||
pointer-events:none;
|
||||
}
|
||||
.drawer.open{transform:translateX(0);pointer-events:auto}
|
||||
.drawer-content{
|
||||
width:100%;
|
||||
height:100%;
|
||||
background:var(--panel);
|
||||
box-shadow:-12px 0 30px rgba(2,6,23,0.12);
|
||||
padding:16px;
|
||||
overflow:auto;
|
||||
}
|
||||
.drawer-close{position:absolute;right:12px;top:8px;background:transparent;border:none;font-size:20px;cursor:pointer}
|
||||
.drawer-header{display:flex;align-items:center}
|
||||
.drawer-thumb.large{width:84px;height:84px;border-radius:8px;background:#f3f4f6;display:flex;align-items:center;justify-content:center;font-weight:700}
|
||||
.drawer-body{margin-top:12px;color:#111827}
|
||||
.drawer-actions{display:flex;gap:8px;margin-top:16px}
|
||||
|
||||
/* marker custom */
|
||||
.leaflet-container .custom-marker{display:flex;align-items:center;justify-content:center}
|
||||
|
||||
/* small screens */
|
||||
@media (max-width:900px){
|
||||
.main{grid-template-columns:1fr;grid-auto-rows:auto}
|
||||
.drawer{top:56px;width:100%}
|
||||
.drawer.open{transform:none}
|
||||
.header{padding:8px 12px}
|
||||
.filters{order:2}
|
||||
.map-panel{order:1}
|
||||
.panel{padding:10px}
|
||||
}
|
||||
|
||||
/* accessibility tweaks */
|
||||
.chip, .btn{font-family:inherit}
|
||||
Reference in New Issue
Block a user