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:
2025-09-25 18:38:18 +08:00
parent d16e56bdcf
commit 6518df8ac1
39 changed files with 4377 additions and 162 deletions

397
dashboard/app.js Normal file
View 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 />);

View 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
View 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
View 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
View 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
View 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}