diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 453bf7e..c94ccdf 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,10 @@ { "permissions": { "allow": [ - "Bash(docker compose:*)" + "Bash(docker compose:*)", + "Bash(curl:*)", + "Bash(python3:*)", + "WebSearch" ] } } diff --git a/frontend/src/assets/hospital_map_icon.png b/frontend/src/assets/hospital_map_icon.png new file mode 100644 index 0000000..d127f19 Binary files /dev/null and b/frontend/src/assets/hospital_map_icon.png differ diff --git a/frontend/src/components/AmbulancePanel.tsx b/frontend/src/components/AmbulancePanel.tsx index a4f7372..33b7305 100644 --- a/frontend/src/components/AmbulancePanel.tsx +++ b/frontend/src/components/AmbulancePanel.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useMemo, useEffect, useRef } from "react"; import "./AmbulancePanel.css"; import AmbulanceCard from "./AmbulanceCard"; @@ -26,6 +26,7 @@ interface PanelProps { handleViewChange: (view: ActiveView) => void; units: UnitInfo[]; onUnitClick: (unit: UnitInfo) => void; + focusedUnitId?: string | null; } const STATUS_FILTER_ORDER: { key: UnitStatus; cssClass: string }[] = [ @@ -40,7 +41,25 @@ export default function AmbulancePanel({ handleViewChange, units, onUnitClick, + focusedUnitId, }: PanelProps) { + const unitListRef = useRef(null); + + const sortedUnits = useMemo(() => { + if (!focusedUnitId) return units; + return [...units].sort((a, b) => { + if (a.id === focusedUnitId) return -1; + if (b.id === focusedUnitId) return 1; + return 0; + }); + }, [units, focusedUnitId]); + + useEffect(() => { + if (focusedUnitId && unitListRef.current) { + unitListRef.current.scrollTop = 0; + } + }, [focusedUnitId]); + const statusCounts = useMemo(() => { const counts: Record = {}; for (const u of units) { @@ -72,8 +91,8 @@ export default function AmbulancePanel({

Active Units

{/* Unit List */} -
- {units.map((u) => ( +
+ {sortedUnits.map((u) => ( // Wrap the card in a div to handle the click
void; dispatchLoading?: boolean; dispatchInfoMap?: Record; + focusedIncidentId?: string | null; } export default function CasesPanel({ @@ -35,7 +37,26 @@ export default function CasesPanel({ onDispatch, dispatchLoading, dispatchInfoMap = {}, + focusedIncidentId, }: PanelProps): JSX.Element { + const caseListRef = useRef(null); + + // Sort incidents so the focused one is at the top + const sortedIncidents = useMemo(() => { + if (!focusedIncidentId) return incidents; + return [...incidents].sort((a, b) => { + if (a.id === focusedIncidentId) return -1; + if (b.id === focusedIncidentId) return 1; + return 0; + }); + }, [incidents, focusedIncidentId]); + + // Scroll the list container to the top when a focused incident changes + useEffect(() => { + if (focusedIncidentId && caseListRef.current) { + caseListRef.current.scrollTop = 0; + } + }, [focusedIncidentId]); const priorityCounts: PriorityCounts = incidents.reduce((acc, c) => { acc[c.priority] = (acc[c.priority] || 0) + 1; @@ -74,13 +95,13 @@ export default function CasesPanel({

Active Cases

{/* Case List */} -
+
{loading ? (

Loading incidents...

- ) : incidents.length === 0 ? ( + ) : sortedIncidents.length === 0 ? (

No incidents reported.

) : ( - incidents.map((c) => ( + sortedIncidents.map((c) => ( { const [activeView, setActiveView] = useState("Ambulances"); const [focusedUnit, setFocusedUnit] = useState(null); + const [focusedUnitId, setFocusedUnitId] = useState(null); + const [focusedIncidentId, setFocusedIncidentId] = useState(null); const [selectedSessionId, setSelectedSessionId] = useState( null ); @@ -46,9 +49,19 @@ const Dashboard = () => { loading: dispatchLoading, findBest, accept, + decline, declineAndReassign, } = useDispatchSuggestion(); + const [hospitals, setHospitals] = useState([]); + const apiUrl = import.meta.env.VITE_API_URL || "http://localhost:8000"; + useEffect(() => { + fetch(`${apiUrl}/hospitals`) + .then((res) => res.json()) + .then((data) => setHospitals(data)) + .catch((err) => console.error("Failed to fetch hospitals:", err)); + }, [apiUrl]); + // Server-side auto-dispatch handles new incidents now — no client-side callback needed const { incidents } = useIncidents(); const activeIncidents = incidents.filter((i) => i.status !== "resolved"); @@ -116,6 +129,16 @@ const Dashboard = () => { setActiveView(view); }; + const handleIncidentClick = (incidentId: string) => { + setActiveView("Cases"); + setFocusedIncidentId(incidentId); + }; + + const handleAmbulanceClick = (unitId: string) => { + setActiveView("Ambulances"); + setFocusedUnitId(unitId); + }; + const renderLeftPanel = () => { if (activeView === "Ambulances") { return ( @@ -124,6 +147,7 @@ const Dashboard = () => { handleViewChange={handleViewChange} units={units} onUnitClick={(unit) => setFocusedUnit(unit)} + focusedUnitId={focusedUnitId} /> ); } else if (activeView === "Cases") { @@ -136,6 +160,7 @@ const Dashboard = () => { onDispatch={findBest} dispatchLoading={dispatchLoading} dispatchInfoMap={dispatchInfoMap} + focusedIncidentId={focusedIncidentId} /> ); } else { @@ -161,9 +186,15 @@ const Dashboard = () => { focusedUnit={focusedUnit} routes={routes} incidents={activeIncidents} + hospitals={hospitals} dispatchSuggestion={suggestion} onAcceptSuggestion={accept} + onDismissSuggestion={decline} onDeclineSuggestion={declineAndReassign} + onIncidentClick={handleIncidentClick} + onDispatch={findBest} + dispatchLoading={dispatchLoading} + onAmbulanceClick={handleAmbulanceClick} />
diff --git a/frontend/src/components/MapPanel.tsx b/frontend/src/components/MapPanel.tsx index a0b7317..55e2fe2 100644 --- a/frontend/src/components/MapPanel.tsx +++ b/frontend/src/components/MapPanel.tsx @@ -10,7 +10,8 @@ import "./MapPanel.css"; import { AmbulanceMapIcon } from "./AmbulanceMapIcon"; import { IncidentMapIcon } from "./IncidentMapIcon"; import { UnitInfo } from "./AmbulancePanel"; -import { CaseInfo, CasePriority, DispatchSuggestion } from "./types"; +import { CaseInfo, CasePriority, DispatchSuggestion, Hospital } from "./types"; +import hospitalIcon from "../assets/hospital_map_icon.png"; const getPriorityColor = (p: string) => { switch (p) { @@ -34,9 +35,15 @@ interface MapPanelProps { focusedUnit: UnitInfo | null; routes?: Array<[string, google.maps.LatLngLiteral[]]>; incidents?: CaseInfo[]; + hospitals?: Hospital[]; dispatchSuggestion?: DispatchSuggestion | null; onAcceptSuggestion?: () => void; + onDismissSuggestion?: () => void; onDeclineSuggestion?: () => void; + onIncidentClick?: (incidentId: string) => void; + onDispatch?: (incidentId: string) => void; + dispatchLoading?: boolean; + onAmbulanceClick?: (unitId: string) => void; } const PRIORITY_COLORS: Record = { @@ -97,9 +104,15 @@ export default function MapPanel({ focusedUnit, routes = [], incidents = [], + hospitals = [], dispatchSuggestion, onAcceptSuggestion, + onDismissSuggestion, onDeclineSuggestion, + onIncidentClick, + onDispatch, + dispatchLoading, + onAmbulanceClick, }: MapPanelProps) { const apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY; const { isLoaded } = useLoadScript({ googleMapsApiKey: apiKey! }); @@ -128,6 +141,7 @@ export default function MapPanel({ mapRef.current = map; }} mapContainerStyle={{ width: "100%", height: "100%" }} + options={{ clickableIcons: false }} > {units.map((unit) => { const position = parseCoords(unit.coords); @@ -148,6 +162,8 @@ export default function MapPanel({ >
onAmbulanceClick?.(unit.id)} + style={{ cursor: "pointer" }} >
@@ -177,7 +193,7 @@ export default function MapPanel({ options={{ pixelOffset: new google.maps.Size(0, -30), }} - onCloseClick={onDeclineSuggestion} + onCloseClick={onDismissSuggestion} >
setHoveredIncidentId(incident.id)} onMouseLeave={() => setHoveredIncidentId(null)} - style={{ position: "relative" }} + onClick={() => onIncidentClick?.(incident.id)} + style={{ position: "relative", cursor: "pointer" }} > +
{incident.location}
-
+
Reported at {formatTime(incident.reported_at)}
+ {incident.status === "open" && onDispatch && ( + + )} +
)}
))} + {hospitals.map((hospital) => ( + +
+ {hospital.name} +
+
+ ))}
); diff --git a/frontend/src/components/types.ts b/frontend/src/components/types.ts index 5e0fd74..005c1f5 100644 --- a/frontend/src/components/types.ts +++ b/frontend/src/components/types.ts @@ -15,6 +15,13 @@ export interface CaseInfo { updated_at: string; } +export interface Hospital { + id: number; + name: string; + lat: number; + lon: number; +} + export interface DispatchSuggestion { suggestionId: string; vehicleId: string; diff --git a/infrastructure/postgres/init.sql b/infrastructure/postgres/init.sql index acf5501..1f5ef65 100644 --- a/infrastructure/postgres/init.sql +++ b/infrastructure/postgres/init.sql @@ -61,3 +61,11 @@ CREATE TABLE IF NOT EXISTS incidents ( CREATE INDEX IF NOT EXISTS idx_incidents_status ON incidents(status); CREATE INDEX IF NOT EXISTS idx_incidents_session_id ON incidents(session_id); +-- Hospitals table +CREATE TABLE IF NOT EXISTS hospitals ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + lat DECIMAL(10, 8) NOT NULL, + lon DECIMAL(11, 8) NOT NULL +); + diff --git a/services/dashboard-api/main.py b/services/dashboard-api/main.py index 6ae04a2..15bd7d8 100644 --- a/services/dashboard-api/main.py +++ b/services/dashboard-api/main.py @@ -589,6 +589,22 @@ async def geocode_address(req: GeocodeRequest): # --- ACR Code & Suggestion Accept/Dismiss endpoints --- +@app.get("/hospitals") +async def list_hospitals(): + """Return all hospitals.""" + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute("SELECT id, name, lat, lon FROM hospitals ORDER BY name") + rows = cur.fetchall() + return [ + {"id": r[0], "name": r[1], "lat": float(r[2]), "lon": float(r[3])} + for r in rows + ] + finally: + conn.close() + + @app.get("/acr-codes") async def list_acr_codes(): """Return all Ontario ACR Problem Codes for frontend dropdowns."""