diff --git a/drb-c2-core/app/internal/incident_correlator.py b/drb-c2-core/app/internal/incident_correlator.py index 8df1670..3e5d476 100644 --- a/drb-c2-core/app/internal/incident_correlator.py +++ b/drb-c2-core/app/internal/incident_correlator.py @@ -44,6 +44,7 @@ async def correlate_call( tags: list[str], incident_type: Optional[str], location: Optional[str] = None, + location_coords: Optional[dict] = None, ) -> Optional[str]: """ Link call_id to an existing incident or create a new one. @@ -113,10 +114,10 @@ async def correlate_call( # ---------------------------------------------------------------- if matched_incident: incident_id = matched_incident["incident_id"] - await _update_incident(matched_incident, call_id, talkgroup_id, system_id, tags, location, now) + await _update_incident(matched_incident, call_id, talkgroup_id, system_id, tags, location, location_coords, now) else: incident_id = await _create_incident( - call_id, incident_type, talkgroup_id, talkgroup_name, system_id, tags, location, now + call_id, incident_type, talkgroup_id, talkgroup_name, system_id, tags, location, location_coords, now ) # Back-link the call @@ -131,6 +132,7 @@ async def _update_incident( system_id: Optional[str], tags: list[str], location: Optional[str], + location_coords: Optional[dict], now: datetime, ) -> None: incident_id = inc["incident_id"] @@ -151,11 +153,11 @@ async def _update_incident( merged_tags = list(dict.fromkeys(inc.get("tags", []) + tags)) # Location — append to mentions; update display location if new one is non-null - # TODO: replace "newest wins" with Maps geocoding bbox comparison for true specificity location_mentions = inc.get("location_mentions", []) if location and location not in location_mentions: location_mentions.append(location) best_location = location if location else inc.get("location") + best_coords = location_coords if location_coords else inc.get("location_coords") # Update centroid embedding embedding_updates = await _merge_embedding(inc, call_id) @@ -172,6 +174,8 @@ async def _update_incident( } if best_location: updates["location"] = best_location + if best_coords: + updates["location_coords"] = best_coords await fstore.doc_set("incidents", incident_id, updates) logger.info(f"Correlator: linked call {call_id} to existing incident {incident_id}") @@ -185,10 +189,11 @@ async def _create_incident( system_id: Optional[str], tags: list[str], location: Optional[str], + location_coords: Optional[dict], now: datetime, ) -> str: incident_id = str(uuid.uuid4()) - tg_label = talkgroup_name or "Unknown Talkgroup" + tg_label = talkgroup_name or (f"TGID {talkgroup_id}" if talkgroup_id else "Unknown Talkgroup") call_doc = await fstore.doc_get("calls", call_id) call_embedding = call_doc.get("embedding") if call_doc else None @@ -202,6 +207,7 @@ async def _create_incident( "type": incident_type, "status": "active", "location": location, + "location_coords": location_coords, "location_mentions": [location] if location else [], "call_ids": [call_id], "talkgroup_ids": [str(talkgroup_id)] if talkgroup_id is not None else [], diff --git a/drb-c2-core/app/internal/intelligence.py b/drb-c2-core/app/internal/intelligence.py index bce4a73..14c95ee 100644 --- a/drb-c2-core/app/internal/intelligence.py +++ b/drb-c2-core/app/internal/intelligence.py @@ -1,10 +1,10 @@ """ -Gemini-powered intelligence extraction from call transcripts. +GPT-4o-mini intelligence extraction from call transcripts. -Sends the transcript to Gemini Flash with a tight JSON schema prompt. +Sends the transcript to GPT-4o mini with a tight JSON schema prompt. Returns structured data: incident type, tags, location, vehicles, units, severity. -Falls back gracefully if Gemini is unavailable or returns malformed output. +Falls back gracefully if the API is unavailable or returns malformed output. """ import asyncio import json @@ -36,6 +36,9 @@ System: {system_id} Talkgroup: {talkgroup_name} {transcript_block}""" +# Nominatim viewbox half-width in degrees (~35 km at mid-latitudes) +_GEO_DELTA = 0.3 + async def extract_tags( call_id: str, @@ -44,38 +47,52 @@ async def extract_tags( talkgroup_id: Optional[int] = None, system_id: Optional[str] = None, segments: Optional[list[dict]] = None, -) -> tuple[list[str], Optional[str], Optional[str]]: + node_id: Optional[str] = None, +) -> tuple[list[str], Optional[str], Optional[str], Optional[dict]]: """ - Extract incident tags, type, location, and corrected transcript via Gemini. + Extract incident tags, type, location, and corrected transcript via GPT-4o mini. + Geocodes the extracted location string via Nominatim using the node's position as bias. Returns: - (tags, primary_type, location) + (tags, primary_type, location_str, location_coords) + where location_coords is {"lat": float, "lng": float} or None. Side-effect: updates calls/{call_id} in Firestore with tags, location, - vehicles, units, severity, transcript_corrected; also stores the call embedding. + location_coords, vehicles, units, severity, transcript_corrected; also stores embedding. """ - result = await asyncio.to_thread(_sync_extract, transcript, talkgroup_name, talkgroup_id, system_id, segments) + result = await asyncio.to_thread( + _sync_extract, transcript, talkgroup_name, talkgroup_id, system_id, segments + ) - tags: list[str] = result.get("tags") or [] + tags: list[str] = result.get("tags") or [] incident_type: Optional[str] = result.get("incident_type") or None - location: Optional[str] = result.get("location") or None - vehicles: list[str] = result.get("vehicles") or [] - units: list[str] = result.get("units") or [] - severity: str = result.get("severity") or "unknown" + location: Optional[str] = result.get("location") or None + vehicles: list[str] = result.get("vehicles") or [] + units: list[str] = result.get("units") or [] + severity: str = result.get("severity") or "unknown" transcript_corrected: Optional[str] = result.get("transcript_corrected") or None if incident_type in ("unknown", "other", ""): incident_type = None + # Geocode the location string if we have one and a node to bias toward + location_coords: Optional[dict] = None + if location and node_id: + node_doc = await fstore.doc_get("nodes", node_id) + if node_doc: + node_lat = node_doc.get("lat") + node_lon = node_doc.get("lon") + if node_lat is not None and node_lon is not None: + location_coords = await _geocode_location(location, node_lat, node_lon) + # Store embedding alongside structured data embedding = await asyncio.to_thread(_sync_embed, _embed_text(transcript, incident_type)) - updates: dict = { - "tags": tags, - "severity": severity, - } + updates: dict = {"tags": tags, "severity": severity} if location: updates["location"] = location + if location_coords: + updates["location_coords"] = location_coords if vehicles: updates["vehicles"] = vehicles if units: @@ -92,10 +109,49 @@ async def extract_tags( logger.info( f"Intelligence: call {call_id} → type={incident_type}, " - f"tags={tags}, location={location!r}, severity={severity}, " + f"tags={tags}, location={location!r}, coords={location_coords}, severity={severity}, " f"corrected={transcript_corrected is not None}" ) - return tags, incident_type, location + return tags, incident_type, location, location_coords + + +async def _geocode_location( + location_str: str, node_lat: float, node_lon: float +) -> Optional[dict]: + """ + Geocode a location string using Nominatim, biased toward the node's area. + Returns {"lat": float, "lng": float} or None if geocoding fails. + """ + import httpx + + viewbox = ( + f"{node_lon - _GEO_DELTA},{node_lat - _GEO_DELTA}," + f"{node_lon + _GEO_DELTA},{node_lat + _GEO_DELTA}" + ) + params = { + "q": location_str, + "format": "json", + "limit": 1, + "viewbox": viewbox, + "bounded": 1, + } + headers = {"User-Agent": "DRB-Dispatch/1.0 (public-safety radio monitor)"} + try: + async with httpx.AsyncClient(timeout=5.0) as client: + r = await client.get( + "https://nominatim.openstreetmap.org/search", + params=params, + headers=headers, + ) + r.raise_for_status() + results = r.json() + if results: + coords = {"lat": float(results[0]["lat"]), "lng": float(results[0]["lon"])} + logger.info(f"Geocoded '{location_str}' → {coords}") + return coords + except Exception as e: + logger.warning(f"Geocoding failed for '{location_str}': {e}") + return None def _build_transcript_block(transcript: str, segments: Optional[list[dict]]) -> str: @@ -154,10 +210,7 @@ def _sync_embed(text: str) -> Optional[list[float]]: try: client = OpenAI(api_key=settings.openai_api_key) - result = client.embeddings.create( - model="text-embedding-3-small", - input=text, - ) + result = client.embeddings.create(model="text-embedding-3-small", input=text) return result.data[0].embedding except Exception as e: logger.warning(f"Embedding generation failed: {e}") @@ -165,6 +218,5 @@ def _sync_embed(text: str) -> Optional[list[float]]: def _embed_text(transcript: str, incident_type: Optional[str]) -> str: - """Build the text string to embed — transcript + type context.""" prefix = f"[{incident_type}] " if incident_type else "" return f"{prefix}{transcript}" diff --git a/drb-c2-core/app/routers/upload.py b/drb-c2-core/app/routers/upload.py index 6df2f7f..20c8cd2 100644 --- a/drb-c2-core/app/routers/upload.py +++ b/drb-c2-core/app/routers/upload.py @@ -95,9 +95,10 @@ async def _run_extraction_pipeline( """Run steps 2-4 of the intelligence pipeline using an existing transcript.""" from app.internal import intelligence, incident_correlator, alerter - tags, incident_type, location = await intelligence.extract_tags( + tags, incident_type, location, location_coords = await intelligence.extract_tags( call_id, transcript, talkgroup_name, talkgroup_id=talkgroup_id, system_id=system_id, segments=segments, + node_id=node_id, ) if incident_type: @@ -110,6 +111,7 @@ async def _run_extraction_pipeline( tags=tags, incident_type=incident_type, location=location, + location_coords=location_coords, ) await alerter.check_and_dispatch( @@ -150,10 +152,12 @@ async def _run_intelligence_pipeline( tags: list[str] = [] incident_type: Optional[str] = None location: Optional[str] = None + location_coords: Optional[dict] = None if transcript: - tags, incident_type, location = await intelligence.extract_tags( + tags, incident_type, location, location_coords = await intelligence.extract_tags( call_id, transcript, talkgroup_name, talkgroup_id=talkgroup_id, system_id=system_id, segments=segments, + node_id=node_id, ) # Step 3: Incident correlation @@ -167,6 +171,7 @@ async def _run_intelligence_pipeline( tags=tags, incident_type=incident_type, location=location, + location_coords=location_coords, ) # Step 4: Alert dispatch (always runs — talkgroup ID rules don't need a transcript) diff --git a/drb-frontend/app/incidents/[id]/page.tsx b/drb-frontend/app/incidents/[id]/page.tsx new file mode 100644 index 0000000..70fe363 --- /dev/null +++ b/drb-frontend/app/incidents/[id]/page.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { useParams, useRouter } from "next/navigation"; +import { useState } from "react"; +import { useIncident } from "@/lib/useIncidents"; +import { useCallsByIncident } from "@/lib/useCalls"; +import { useSystems } from "@/lib/useSystems"; +import { useAuth } from "@/components/AuthProvider"; +import { CallRow } from "@/components/CallRow"; +import { c2api } from "@/lib/c2api"; +import type { IncidentRecord } from "@/lib/types"; + +const TYPE_COLORS: Record = { + fire: "bg-red-900 text-red-300", + police: "bg-blue-900 text-blue-300", + ems: "bg-yellow-900 text-yellow-300", + accident: "bg-orange-900 text-orange-300", + other: "bg-gray-800 text-gray-300", +}; + +function TypeBadge({ type }: { type: string | null }) { + const cls = TYPE_COLORS[type ?? "other"] ?? TYPE_COLORS.other; + return ( + + {type ?? "other"} + + ); +} + +function StatusBadge({ status }: { status: IncidentRecord["status"] }) { + return ( + + {status} + + ); +} + +export default function IncidentDetailPage() { + const params = useParams(); + const id = params.id as string; + const router = useRouter(); + + const { incident, loading } = useIncident(id); + const { calls, loading: callsLoading } = useCallsByIncident(id); + const { systems } = useSystems(); + const { isAdmin } = useAuth(); + + const [summarizing, setSummarizing] = useState(false); + const [resolving, setResolving] = useState(false); + + const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s])); + + async function handleResolve() { + setResolving(true); + try { await c2api.updateIncident(id, { status: "resolved" }); } + catch (e) { console.error(e); } + finally { setResolving(false); } + } + + async function handleSummarize() { + setSummarizing(true); + try { await c2api.summarizeIncident(id); } + catch (e) { console.error(e); } + finally { setSummarizing(false); } + } + + if (loading) { + return

Loading…

; + } + if (!incident) { + return

Incident not found.

; + } + + return ( +
+ {/* Back */} + + + {/* Header */} +
+
+ +

+ {incident.title ?? "Incident"} +

+ +
+ {isAdmin && ( +
+ + {incident.status === "active" && ( + + )} +
+ )} +
+ + {/* Summary */} +
+

+ Summary +

+ {incident.summary ? ( +

+ {incident.summary} +

+ ) : ( +

+ No summary yet.{" "} + {isAdmin && ( + + )} +

+ )} +
+ + {/* Tags + Location */} +
+
+

Tags

+ {incident.tags.length > 0 ? ( +
+ {incident.tags.map((t) => ( + + {t} + + ))} +
+ ) : ( +

No tags.

+ )} +
+ + {incident.location && ( +
+

Location

+

{incident.location}

+
+ )} +
+ + {/* Metadata */} +
+ Started: {new Date(incident.started_at).toLocaleString()} + Updated: {new Date(incident.updated_at).toLocaleString()} + Calls: {incident.call_ids.length} + {incident.talkgroup_ids?.length > 0 && ( + Talkgroups: {incident.talkgroup_ids.join(", ")} + )} +
+ + {/* Calls */} +
+

Calls

+ {callsLoading ? ( +

Loading calls…

+ ) : calls.length === 0 ? ( +

No calls linked yet.

+ ) : ( +
+ + + + + + + + + + + + + + {calls.map((c) => ( + + ))} + +
TimeTalkgroupSystemNodeDurationAudio
+
+ )} +
+
+ ); +} diff --git a/drb-frontend/app/incidents/page.tsx b/drb-frontend/app/incidents/page.tsx index 7a91c8a..888ed9c 100644 --- a/drb-frontend/app/incidents/page.tsx +++ b/drb-frontend/app/incidents/page.tsx @@ -1,10 +1,11 @@ "use client"; -import { useState } from "react"; +import { useRouter } from "next/navigation"; import { useAuth } from "@/components/AuthProvider"; import { useIncidents } from "@/lib/useIncidents"; import { c2api } from "@/lib/c2api"; import type { IncidentRecord } from "@/lib/types"; +import { useState } from "react"; const TYPE_COLORS: Record = { fire: "bg-red-900 text-red-300", @@ -32,65 +33,38 @@ function IncidentRow({ incident, isAdmin, onResolve }: { isAdmin: boolean; onResolve: (id: string) => void; }) { - const [expanded, setExpanded] = useState(false); + const router = useRouter(); return ( - <> - setExpanded((v) => !v)} - > - {typeBadge(incident.type)} - {incident.title ?? "—"} - - - {incident.status} - - - {incident.call_ids.length} - {fmtTime(incident.started_at)} - {fmtTime(incident.updated_at)} - - {isAdmin && incident.status === "active" && ( - - )} - - - {expanded && ( - - - {incident.summary && ( -

{incident.summary}

- )} - {incident.tags.length > 0 && ( -
- {incident.tags.map((t) => ( - {t} - ))} -
- )} -
- Linked calls: - {incident.call_ids.length === 0 ? "none" : incident.call_ids.map((id, i) => ( - - {id.slice(0, 8)}… - {i < incident.call_ids.length - 1 && ", "} - - ))} -
- - - )} - + router.push(`/incidents/${incident.incident_id}`)} + > + {typeBadge(incident.type)} + {incident.title ?? "—"} + + + {incident.status} + + + {incident.call_ids.length} + {fmtTime(incident.started_at)} + {fmtTime(incident.updated_at)} + + {isAdmin && incident.status === "active" && ( + + )} + + ); } @@ -98,10 +72,10 @@ function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (body: object) => Promise; }) { - const [title, setTitle] = useState(""); - const [type, setType] = useState("other"); + const [title, setTitle] = useState(""); + const [type, setType] = useState("other"); const [summary, setSummary] = useState(""); - const [saving, setSaving] = useState(false); + const [saving, setSaving] = useState(false); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -162,24 +136,51 @@ function CreateModal({ onClose, onCreate }: { ); } +function IncidentTable({ incidents, isAdmin, onResolve }: { + incidents: IncidentRecord[]; + isAdmin: boolean; + onResolve: (id: string) => void; +}) { + return ( +
+ + + + + + + + + + + + + + {incidents.map((inc) => ( + + ))} + +
TypeTitleStatusCallsStartedUpdated
+
+ ); +} + export default function IncidentsPage() { - const { isAdmin } = useAuth(); - const { incidents, loading } = useIncidents(); + const { isAdmin } = useAuth(); + const { incidents, loading } = useIncidents(); const [showCreate, setShowCreate] = useState(false); - const active = incidents.filter((i) => i.status === "active"); + const active = incidents.filter((i) => i.status === "active"); const resolved = incidents.filter((i) => i.status === "resolved"); async function handleResolve(id: string) { - try { - await c2api.updateIncident(id, { status: "resolved" }); - } catch (e) { - console.error(e); - } - } - - async function handleCreate(body: object) { - await c2api.createIncident(body); + try { await c2api.updateIncident(id, { status: "resolved" }); } + catch (e) { console.error(e); } } return ( @@ -207,67 +208,17 @@ export default function IncidentsPage() {

Loading…

) : ( <> - {/* Active incidents */} {active.length > 0 && (

Active

-
- - - - - - - - - - - - - - {active.map((inc) => ( - - ))} - -
TypeTitleStatusCallsStartedUpdated
-
+
)} - {/* Resolved incidents */} {resolved.length > 0 && (

Resolved

-
- - - - - - - - - - - - - - {resolved.map((inc) => ( - - ))} - -
TypeTitleStatusCallsStartedUpdated
-
+
)} @@ -278,7 +229,7 @@ export default function IncidentsPage() { )} {showCreate && ( - setShowCreate(false)} onCreate={handleCreate} /> + setShowCreate(false)} onCreate={(b) => c2api.createIncident(b)} /> )} ); diff --git a/drb-frontend/app/map/page.tsx b/drb-frontend/app/map/page.tsx index b7e4e56..54901b6 100644 --- a/drb-frontend/app/map/page.tsx +++ b/drb-frontend/app/map/page.tsx @@ -1,17 +1,52 @@ "use client"; import dynamic from "next/dynamic"; +import Link from "next/link"; import { useNodes } from "@/lib/useNodes"; import { useActiveCalls } from "@/lib/useCalls"; import { useActiveIncidents } from "@/lib/useIncidents"; +import type { IncidentRecord } from "@/lib/types"; -// Leaflet is browser-only — must be dynamically imported with no SSR const MapView = dynamic(() => import("@/components/MapView"), { ssr: false }); +const TYPE_COLORS: Record = { + fire: "border-red-800 bg-red-950 text-red-300", + police: "border-blue-800 bg-blue-950 text-blue-300", + ems: "border-yellow-800 bg-yellow-950 text-yellow-300", + accident: "border-orange-800 bg-orange-950 text-orange-300", + other: "border-gray-700 bg-gray-900 text-gray-300", +}; + +function IncidentCard({ incident }: { incident: IncidentRecord }) { + const cls = TYPE_COLORS[incident.type ?? "other"] ?? TYPE_COLORS.other; + return ( + +
+ + {incident.type ?? "other"} + + + {incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""} + +
+

{incident.title ?? "Incident"}

+ {incident.location && ( +

{incident.location}

+ )} + {!incident.location_coords && ( +

location not geocoded yet

+ )} + + ); +} + export default function MapPage() { const { nodes, loading } = useNodes(); const activeCalls = useActiveCalls(); - const incidents = useActiveIncidents(); + const incidents = useActiveIncidents(); return (
@@ -34,10 +69,24 @@ export default function MapPage() { Loading map…
) : ( -
+
)} + + {/* Active incidents — shown even without geocoded location */} + {incidents.length > 0 && ( +
+

+ Active Incidents ({incidents.length}) +

+
+ {incidents.map((inc) => ( + + ))} +
+
+ )}
); } diff --git a/drb-frontend/components/MapView.tsx b/drb-frontend/components/MapView.tsx index c97d1eb..247432c 100644 --- a/drb-frontend/components/MapView.tsx +++ b/drb-frontend/components/MapView.tsx @@ -59,9 +59,11 @@ export default function MapView({ nodes, activeCalls, incidents = [] }: Props) { activeCalls.map((c) => [c.node_id, c]) ); - // Only show incidents that have coordinates - const mappableIncidents = incidents.filter( - (i) => i.location && i.location.lat != null && i.location.lng != null + // Only show incidents that have been geocoded (location_coords set by the server). + const plottedIncidents = incidents.flatMap((inc) => + inc.location_coords + ? [{ inc, pos: [inc.location_coords.lat, inc.location_coords.lng] as [number, number] }] + : [] ); const center: [number, number] = @@ -102,22 +104,26 @@ export default function MapView({ nodes, activeCalls, incidents = [] }: Props) { ))} - {/* Incident markers */} - {mappableIncidents.map((inc) => ( + {/* Incident markers — positioned at the node covering the incident's system */} + {plottedIncidents.map(({ inc, pos }) => (

{inc.title ?? "Incident"}

-

+

{inc.type ?? "other"}

{inc.status}

+ {inc.location &&

{inc.location}

}

{inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""}

{inc.summary &&

{inc.summary}

} + + View incident → +
diff --git a/drb-frontend/lib/c2api.ts b/drb-frontend/lib/c2api.ts index f9372a5..e308c0e 100644 --- a/drb-frontend/lib/c2api.ts +++ b/drb-frontend/lib/c2api.ts @@ -72,6 +72,8 @@ export const c2api = { request(`/incidents/${id}`, { method: "DELETE" }), linkCallToIncident: (incidentId: string, callId: string) => request(`/incidents/${incidentId}/calls/${callId}`, { method: "POST" }), + summarizeIncident: (id: string) => + request(`/incidents/${id}/summarize`, { method: "POST" }), // Alerts getAlerts: (acknowledged?: boolean) => { diff --git a/drb-frontend/lib/types.ts b/drb-frontend/lib/types.ts index 8ba947a..7c522fd 100644 --- a/drb-frontend/lib/types.ts +++ b/drb-frontend/lib/types.ts @@ -40,7 +40,7 @@ export interface CallRecord { transcript_corrected: string | null; segments: TranscriptSegment[] | null; incident_id: string | null; - location: { lat: number; lng: number } | null; + location: string | null; tags: string[]; status: "active" | "ended"; } @@ -50,8 +50,11 @@ export interface IncidentRecord { title: string | null; type: string | null; status: "active" | "resolved"; - location: { lat: number; lng: number } | null; + location: string | null; + location_coords: { lat: number; lng: number } | null; call_ids: string[]; + system_ids: string[]; + talkgroup_ids: string[]; started_at: string; updated_at: string; summary: string | null; diff --git a/drb-frontend/lib/useCalls.ts b/drb-frontend/lib/useCalls.ts index c61793e..808f742 100644 --- a/drb-frontend/lib/useCalls.ts +++ b/drb-frontend/lib/useCalls.ts @@ -48,6 +48,39 @@ export function useCalls(limitCount = 50) { return { calls, loading, error }; } +export function useCallsByIncident(incidentId: string | null) { + const [calls, setCalls] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!incidentId) { setLoading(false); return; } + let unsubFirestore: (() => void) | undefined; + + const unsubAuth = onAuthStateChanged(auth, (user) => { + if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; } + if (!user) { setLoading(false); return; } + + const toISO = (v: any): string | null => + v?.toDate?.()?.toISOString?.() ?? (typeof v === "string" ? v : null); + + const q = query(collection(db, "calls"), where("incident_id", "==", incidentId)); + unsubFirestore = onSnapshot(q, (snap) => { + const docs = snap.docs.map((d) => { + const data = d.data(); + return { ...data, started_at: toISO(data.started_at) ?? "", ended_at: toISO(data.ended_at) } as CallRecord; + }); + docs.sort((a, b) => a.started_at.localeCompare(b.started_at)); + setCalls(docs); + setLoading(false); + }, (err: FirestoreError) => { console.error("useCallsByIncident:", err); setLoading(false); }); + }); + + return () => { unsubAuth(); if (unsubFirestore) unsubFirestore(); }; + }, [incidentId]); + + return { calls, loading }; +} + export function useActiveCalls() { const [calls, setCalls] = useState([]); diff --git a/drb-frontend/lib/useIncidents.ts b/drb-frontend/lib/useIncidents.ts index a2ecae5..f2714f0 100644 --- a/drb-frontend/lib/useIncidents.ts +++ b/drb-frontend/lib/useIncidents.ts @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { collection, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore"; +import { collection, doc, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore"; import { onAuthStateChanged } from "firebase/auth"; import { db, auth } from "@/lib/firebase"; import type { IncidentRecord } from "@/lib/types"; @@ -58,6 +58,43 @@ export function useIncidents(limitCount = 100) { return { incidents, loading, error }; } +export function useIncident(incidentId: string | null) { + const [incident, setIncident] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!incidentId) { setLoading(false); return; } + let unsubFirestore: (() => void) | undefined; + + const unsubAuth = onAuthStateChanged(auth, (user) => { + if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; } + if (!user) { setLoading(false); return; } + + const ref = doc(db, "incidents", incidentId); + unsubFirestore = onSnapshot(ref, (snap) => { + if (snap.exists()) { + const data = snap.data(); + setIncident({ + ...data, + started_at: toISO(data.started_at), + updated_at: toISO(data.updated_at), + } as IncidentRecord); + } else { + setIncident(null); + } + setLoading(false); + }, (err: FirestoreError) => { + console.error("useIncident:", err); + setLoading(false); + }); + }); + + return () => { unsubAuth(); if (unsubFirestore) unsubFirestore(); }; + }, [incidentId]); + + return { incident, loading }; +} + export function useActiveIncidents() { const [incidents, setIncidents] = useState([]);