big ui and intel updates
This commit is contained in:
@@ -44,6 +44,7 @@ async def correlate_call(
|
|||||||
tags: list[str],
|
tags: list[str],
|
||||||
incident_type: Optional[str],
|
incident_type: Optional[str],
|
||||||
location: Optional[str] = None,
|
location: Optional[str] = None,
|
||||||
|
location_coords: Optional[dict] = None,
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Link call_id to an existing incident or create a new one.
|
Link call_id to an existing incident or create a new one.
|
||||||
@@ -113,10 +114,10 @@ async def correlate_call(
|
|||||||
# ----------------------------------------------------------------
|
# ----------------------------------------------------------------
|
||||||
if matched_incident:
|
if matched_incident:
|
||||||
incident_id = matched_incident["incident_id"]
|
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:
|
else:
|
||||||
incident_id = await _create_incident(
|
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
|
# Back-link the call
|
||||||
@@ -131,6 +132,7 @@ async def _update_incident(
|
|||||||
system_id: Optional[str],
|
system_id: Optional[str],
|
||||||
tags: list[str],
|
tags: list[str],
|
||||||
location: Optional[str],
|
location: Optional[str],
|
||||||
|
location_coords: Optional[dict],
|
||||||
now: datetime,
|
now: datetime,
|
||||||
) -> None:
|
) -> None:
|
||||||
incident_id = inc["incident_id"]
|
incident_id = inc["incident_id"]
|
||||||
@@ -151,11 +153,11 @@ async def _update_incident(
|
|||||||
merged_tags = list(dict.fromkeys(inc.get("tags", []) + tags))
|
merged_tags = list(dict.fromkeys(inc.get("tags", []) + tags))
|
||||||
|
|
||||||
# Location — append to mentions; update display location if new one is non-null
|
# 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", [])
|
location_mentions = inc.get("location_mentions", [])
|
||||||
if location and location not in location_mentions:
|
if location and location not in location_mentions:
|
||||||
location_mentions.append(location)
|
location_mentions.append(location)
|
||||||
best_location = location if location else inc.get("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
|
# Update centroid embedding
|
||||||
embedding_updates = await _merge_embedding(inc, call_id)
|
embedding_updates = await _merge_embedding(inc, call_id)
|
||||||
@@ -172,6 +174,8 @@ async def _update_incident(
|
|||||||
}
|
}
|
||||||
if best_location:
|
if best_location:
|
||||||
updates["location"] = best_location
|
updates["location"] = best_location
|
||||||
|
if best_coords:
|
||||||
|
updates["location_coords"] = best_coords
|
||||||
|
|
||||||
await fstore.doc_set("incidents", incident_id, updates)
|
await fstore.doc_set("incidents", incident_id, updates)
|
||||||
logger.info(f"Correlator: linked call {call_id} to existing incident {incident_id}")
|
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],
|
system_id: Optional[str],
|
||||||
tags: list[str],
|
tags: list[str],
|
||||||
location: Optional[str],
|
location: Optional[str],
|
||||||
|
location_coords: Optional[dict],
|
||||||
now: datetime,
|
now: datetime,
|
||||||
) -> str:
|
) -> str:
|
||||||
incident_id = str(uuid.uuid4())
|
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_doc = await fstore.doc_get("calls", call_id)
|
||||||
call_embedding = call_doc.get("embedding") if call_doc else None
|
call_embedding = call_doc.get("embedding") if call_doc else None
|
||||||
@@ -202,6 +207,7 @@ async def _create_incident(
|
|||||||
"type": incident_type,
|
"type": incident_type,
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"location": location,
|
"location": location,
|
||||||
|
"location_coords": location_coords,
|
||||||
"location_mentions": [location] if location else [],
|
"location_mentions": [location] if location else [],
|
||||||
"call_ids": [call_id],
|
"call_ids": [call_id],
|
||||||
"talkgroup_ids": [str(talkgroup_id)] if talkgroup_id is not None else [],
|
"talkgroup_ids": [str(talkgroup_id)] if talkgroup_id is not None else [],
|
||||||
|
|||||||
@@ -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.
|
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 asyncio
|
||||||
import json
|
import json
|
||||||
@@ -36,6 +36,9 @@ System: {system_id}
|
|||||||
Talkgroup: {talkgroup_name}
|
Talkgroup: {talkgroup_name}
|
||||||
{transcript_block}"""
|
{transcript_block}"""
|
||||||
|
|
||||||
|
# Nominatim viewbox half-width in degrees (~35 km at mid-latitudes)
|
||||||
|
_GEO_DELTA = 0.3
|
||||||
|
|
||||||
|
|
||||||
async def extract_tags(
|
async def extract_tags(
|
||||||
call_id: str,
|
call_id: str,
|
||||||
@@ -44,17 +47,22 @@ async def extract_tags(
|
|||||||
talkgroup_id: Optional[int] = None,
|
talkgroup_id: Optional[int] = None,
|
||||||
system_id: Optional[str] = None,
|
system_id: Optional[str] = None,
|
||||||
segments: Optional[list[dict]] = 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:
|
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,
|
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
|
incident_type: Optional[str] = result.get("incident_type") or None
|
||||||
@@ -67,15 +75,24 @@ async def extract_tags(
|
|||||||
if incident_type in ("unknown", "other", ""):
|
if incident_type in ("unknown", "other", ""):
|
||||||
incident_type = None
|
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
|
# Store embedding alongside structured data
|
||||||
embedding = await asyncio.to_thread(_sync_embed, _embed_text(transcript, incident_type))
|
embedding = await asyncio.to_thread(_sync_embed, _embed_text(transcript, incident_type))
|
||||||
|
|
||||||
updates: dict = {
|
updates: dict = {"tags": tags, "severity": severity}
|
||||||
"tags": tags,
|
|
||||||
"severity": severity,
|
|
||||||
}
|
|
||||||
if location:
|
if location:
|
||||||
updates["location"] = location
|
updates["location"] = location
|
||||||
|
if location_coords:
|
||||||
|
updates["location_coords"] = location_coords
|
||||||
if vehicles:
|
if vehicles:
|
||||||
updates["vehicles"] = vehicles
|
updates["vehicles"] = vehicles
|
||||||
if units:
|
if units:
|
||||||
@@ -92,10 +109,49 @@ async def extract_tags(
|
|||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Intelligence: call {call_id} → type={incident_type}, "
|
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}"
|
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:
|
def _build_transcript_block(transcript: str, segments: Optional[list[dict]]) -> str:
|
||||||
@@ -154,10 +210,7 @@ def _sync_embed(text: str) -> Optional[list[float]]:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
client = OpenAI(api_key=settings.openai_api_key)
|
client = OpenAI(api_key=settings.openai_api_key)
|
||||||
result = client.embeddings.create(
|
result = client.embeddings.create(model="text-embedding-3-small", input=text)
|
||||||
model="text-embedding-3-small",
|
|
||||||
input=text,
|
|
||||||
)
|
|
||||||
return result.data[0].embedding
|
return result.data[0].embedding
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Embedding generation failed: {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:
|
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 ""
|
prefix = f"[{incident_type}] " if incident_type else ""
|
||||||
return f"{prefix}{transcript}"
|
return f"{prefix}{transcript}"
|
||||||
|
|||||||
@@ -95,9 +95,10 @@ async def _run_extraction_pipeline(
|
|||||||
"""Run steps 2-4 of the intelligence pipeline using an existing transcript."""
|
"""Run steps 2-4 of the intelligence pipeline using an existing transcript."""
|
||||||
from app.internal import intelligence, incident_correlator, alerter
|
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,
|
call_id, transcript, talkgroup_name,
|
||||||
talkgroup_id=talkgroup_id, system_id=system_id, segments=segments,
|
talkgroup_id=talkgroup_id, system_id=system_id, segments=segments,
|
||||||
|
node_id=node_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if incident_type:
|
if incident_type:
|
||||||
@@ -110,6 +111,7 @@ async def _run_extraction_pipeline(
|
|||||||
tags=tags,
|
tags=tags,
|
||||||
incident_type=incident_type,
|
incident_type=incident_type,
|
||||||
location=location,
|
location=location,
|
||||||
|
location_coords=location_coords,
|
||||||
)
|
)
|
||||||
|
|
||||||
await alerter.check_and_dispatch(
|
await alerter.check_and_dispatch(
|
||||||
@@ -150,10 +152,12 @@ async def _run_intelligence_pipeline(
|
|||||||
tags: list[str] = []
|
tags: list[str] = []
|
||||||
incident_type: Optional[str] = None
|
incident_type: Optional[str] = None
|
||||||
location: Optional[str] = None
|
location: Optional[str] = None
|
||||||
|
location_coords: Optional[dict] = None
|
||||||
if transcript:
|
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,
|
call_id, transcript, talkgroup_name,
|
||||||
talkgroup_id=talkgroup_id, system_id=system_id, segments=segments,
|
talkgroup_id=talkgroup_id, system_id=system_id, segments=segments,
|
||||||
|
node_id=node_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 3: Incident correlation
|
# Step 3: Incident correlation
|
||||||
@@ -167,6 +171,7 @@ async def _run_intelligence_pipeline(
|
|||||||
tags=tags,
|
tags=tags,
|
||||||
incident_type=incident_type,
|
incident_type=incident_type,
|
||||||
location=location,
|
location=location,
|
||||||
|
location_coords=location_coords,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 4: Alert dispatch (always runs — talkgroup ID rules don't need a transcript)
|
# Step 4: Alert dispatch (always runs — talkgroup ID rules don't need a transcript)
|
||||||
|
|||||||
@@ -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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<span className={`text-xs font-mono px-2 py-0.5 rounded-full capitalize ${cls}`}>
|
||||||
|
{type ?? "other"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: IncidentRecord["status"] }) {
|
||||||
|
return (
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full font-mono ${
|
||||||
|
status === "active" ? "bg-green-900 text-green-300" : "bg-gray-800 text-gray-400"
|
||||||
|
}`}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <p className="text-gray-500 text-sm font-mono p-6">Loading…</p>;
|
||||||
|
}
|
||||||
|
if (!incident) {
|
||||||
|
return <p className="text-gray-500 text-sm font-mono p-6">Incident not found.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Back */}
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors"
|
||||||
|
>
|
||||||
|
← Incidents
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<TypeBadge type={incident.type} />
|
||||||
|
<h1 className="text-xl font-bold text-white font-mono">
|
||||||
|
{incident.title ?? "Incident"}
|
||||||
|
</h1>
|
||||||
|
<StatusBadge status={incident.status} />
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="flex gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={handleSummarize}
|
||||||
|
disabled={summarizing}
|
||||||
|
className="text-xs bg-indigo-700 hover:bg-indigo-600 disabled:opacity-50 text-white px-3 py-1.5 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{summarizing ? "Generating…" : "Regenerate summary"}
|
||||||
|
</button>
|
||||||
|
{incident.status === "active" && (
|
||||||
|
<button
|
||||||
|
onClick={handleResolve}
|
||||||
|
disabled={resolving}
|
||||||
|
className="text-xs bg-gray-800 hover:bg-gray-700 disabled:opacity-50 text-gray-300 px-3 py-1.5 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{resolving ? "Resolving…" : "Resolve"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-2">
|
||||||
|
Summary
|
||||||
|
</h2>
|
||||||
|
{incident.summary ? (
|
||||||
|
<p className="text-sm text-gray-300 bg-gray-900 border border-gray-800 rounded-lg p-4 leading-relaxed">
|
||||||
|
{incident.summary}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-600 font-mono italic">
|
||||||
|
No summary yet.{" "}
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
onClick={handleSummarize}
|
||||||
|
disabled={summarizing}
|
||||||
|
className="text-indigo-400 hover:text-indigo-300 not-italic transition-colors"
|
||||||
|
>
|
||||||
|
Generate now
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Tags + Location */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-2">Tags</h2>
|
||||||
|
{incident.tags.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{incident.tags.map((t) => (
|
||||||
|
<span key={t} className="text-xs bg-gray-800 text-gray-300 px-2 py-0.5 rounded-full">
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-600 text-sm font-mono">No tags.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{incident.location && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-2">Location</h2>
|
||||||
|
<p className="text-sm text-gray-300 font-mono">{incident.location}</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<div className="text-xs text-gray-600 font-mono flex flex-wrap gap-x-6 gap-y-1">
|
||||||
|
<span>Started: <span className="text-gray-400">{new Date(incident.started_at).toLocaleString()}</span></span>
|
||||||
|
<span>Updated: <span className="text-gray-400">{new Date(incident.updated_at).toLocaleString()}</span></span>
|
||||||
|
<span>Calls: <span className="text-gray-400">{incident.call_ids.length}</span></span>
|
||||||
|
{incident.talkgroup_ids?.length > 0 && (
|
||||||
|
<span>Talkgroups: <span className="text-gray-400">{incident.talkgroup_ids.join(", ")}</span></span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calls */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Calls</h2>
|
||||||
|
{callsLoading ? (
|
||||||
|
<p className="text-gray-600 text-sm font-mono">Loading calls…</p>
|
||||||
|
) : calls.length === 0 ? (
|
||||||
|
<p className="text-gray-600 text-sm font-mono">No calls linked yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
||||||
|
<th className="px-4 py-2 text-left">Time</th>
|
||||||
|
<th className="px-4 py-2 text-left">Talkgroup</th>
|
||||||
|
<th className="px-4 py-2 text-left">System</th>
|
||||||
|
<th className="px-4 py-2 text-left">Node</th>
|
||||||
|
<th className="px-4 py-2 text-left">Duration</th>
|
||||||
|
<th className="px-4 py-2 text-left">Audio</th>
|
||||||
|
<th className="px-4 py-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{calls.map((c) => (
|
||||||
|
<CallRow
|
||||||
|
key={c.call_id}
|
||||||
|
call={c}
|
||||||
|
systemName={systemMap[c.system_id ?? ""]?.name}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import { useIncidents } from "@/lib/useIncidents";
|
import { useIncidents } from "@/lib/useIncidents";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
import type { IncidentRecord } from "@/lib/types";
|
import type { IncidentRecord } from "@/lib/types";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
const TYPE_COLORS: Record<string, string> = {
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
fire: "bg-red-900 text-red-300",
|
fire: "bg-red-900 text-red-300",
|
||||||
@@ -32,13 +33,12 @@ function IncidentRow({ incident, isAdmin, onResolve }: {
|
|||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
onResolve: (id: string) => void;
|
onResolve: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<tr
|
<tr
|
||||||
className="border-b border-gray-800 hover:bg-gray-900 cursor-pointer"
|
className="border-b border-gray-800 hover:bg-gray-900 cursor-pointer"
|
||||||
onClick={() => setExpanded((v) => !v)}
|
onClick={() => router.push(`/incidents/${incident.incident_id}`)}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3">{typeBadge(incident.type)}</td>
|
<td className="px-4 py-3">{typeBadge(incident.type)}</td>
|
||||||
<td className="px-4 py-3 text-white text-sm">{incident.title ?? "—"}</td>
|
<td className="px-4 py-3 text-white text-sm">{incident.title ?? "—"}</td>
|
||||||
@@ -65,32 +65,6 @@ function IncidentRow({ incident, isAdmin, onResolve }: {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{expanded && (
|
|
||||||
<tr className="bg-gray-900 border-b border-gray-800">
|
|
||||||
<td colSpan={7} className="px-6 py-3">
|
|
||||||
{incident.summary && (
|
|
||||||
<p className="text-sm text-gray-300 mb-2">{incident.summary}</p>
|
|
||||||
)}
|
|
||||||
{incident.tags.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1 mb-2">
|
|
||||||
{incident.tags.map((t) => (
|
|
||||||
<span key={t} className="text-xs bg-gray-800 text-gray-400 px-2 py-0.5 rounded-full">{t}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="text-xs text-gray-500 font-mono">
|
|
||||||
<span className="text-gray-400">Linked calls: </span>
|
|
||||||
{incident.call_ids.length === 0 ? "none" : incident.call_ids.map((id, i) => (
|
|
||||||
<span key={id}>
|
|
||||||
<a href={`/calls?highlight=${id}`} className="text-indigo-400 hover:underline">{id.slice(0, 8)}…</a>
|
|
||||||
{i < incident.call_ids.length - 1 && ", "}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +136,40 @@ function CreateModal({ onClose, onCreate }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function IncidentTable({ incidents, isAdmin, onResolve }: {
|
||||||
|
incidents: IncidentRecord[];
|
||||||
|
isAdmin: boolean;
|
||||||
|
onResolve: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase">
|
||||||
|
<th className="px-4 py-3">Type</th>
|
||||||
|
<th className="px-4 py-3">Title</th>
|
||||||
|
<th className="px-4 py-3">Status</th>
|
||||||
|
<th className="px-4 py-3">Calls</th>
|
||||||
|
<th className="px-4 py-3">Started</th>
|
||||||
|
<th className="px-4 py-3">Updated</th>
|
||||||
|
<th className="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{incidents.map((inc) => (
|
||||||
|
<IncidentRow
|
||||||
|
key={inc.incident_id}
|
||||||
|
incident={inc}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
onResolve={onResolve}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function IncidentsPage() {
|
export default function IncidentsPage() {
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
const { incidents, loading } = useIncidents();
|
const { incidents, loading } = useIncidents();
|
||||||
@@ -171,15 +179,8 @@ export default function IncidentsPage() {
|
|||||||
const resolved = incidents.filter((i) => i.status === "resolved");
|
const resolved = incidents.filter((i) => i.status === "resolved");
|
||||||
|
|
||||||
async function handleResolve(id: string) {
|
async function handleResolve(id: string) {
|
||||||
try {
|
try { await c2api.updateIncident(id, { status: "resolved" }); }
|
||||||
await c2api.updateIncident(id, { status: "resolved" });
|
catch (e) { console.error(e); }
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreate(body: object) {
|
|
||||||
await c2api.createIncident(body);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -207,67 +208,17 @@ export default function IncidentsPage() {
|
|||||||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Active incidents */}
|
|
||||||
{active.length > 0 && (
|
{active.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Active</h2>
|
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Active</h2>
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
<IncidentTable incidents={active} isAdmin={isAdmin} onResolve={handleResolve} />
|
||||||
<table className="w-full text-left">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase">
|
|
||||||
<th className="px-4 py-3">Type</th>
|
|
||||||
<th className="px-4 py-3">Title</th>
|
|
||||||
<th className="px-4 py-3">Status</th>
|
|
||||||
<th className="px-4 py-3">Calls</th>
|
|
||||||
<th className="px-4 py-3">Started</th>
|
|
||||||
<th className="px-4 py-3">Updated</th>
|
|
||||||
<th className="px-4 py-3"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{active.map((inc) => (
|
|
||||||
<IncidentRow
|
|
||||||
key={inc.incident_id}
|
|
||||||
incident={inc}
|
|
||||||
isAdmin={isAdmin}
|
|
||||||
onResolve={handleResolve}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Resolved incidents */}
|
|
||||||
{resolved.length > 0 && (
|
{resolved.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Resolved</h2>
|
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Resolved</h2>
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
<IncidentTable incidents={resolved} isAdmin={isAdmin} onResolve={handleResolve} />
|
||||||
<table className="w-full text-left">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase">
|
|
||||||
<th className="px-4 py-3">Type</th>
|
|
||||||
<th className="px-4 py-3">Title</th>
|
|
||||||
<th className="px-4 py-3">Status</th>
|
|
||||||
<th className="px-4 py-3">Calls</th>
|
|
||||||
<th className="px-4 py-3">Started</th>
|
|
||||||
<th className="px-4 py-3">Updated</th>
|
|
||||||
<th className="px-4 py-3"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{resolved.map((inc) => (
|
|
||||||
<IncidentRow
|
|
||||||
key={inc.incident_id}
|
|
||||||
incident={inc}
|
|
||||||
isAdmin={isAdmin}
|
|
||||||
onResolve={handleResolve}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -278,7 +229,7 @@ export default function IncidentsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showCreate && (
|
{showCreate && (
|
||||||
<CreateModal onClose={() => setShowCreate(false)} onCreate={handleCreate} />
|
<CreateModal onClose={() => setShowCreate(false)} onCreate={(b) => c2api.createIncident(b)} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,48 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
import Link from "next/link";
|
||||||
import { useNodes } from "@/lib/useNodes";
|
import { useNodes } from "@/lib/useNodes";
|
||||||
import { useActiveCalls } from "@/lib/useCalls";
|
import { useActiveCalls } from "@/lib/useCalls";
|
||||||
import { useActiveIncidents } from "@/lib/useIncidents";
|
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 MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
||||||
|
|
||||||
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
|
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 (
|
||||||
|
<Link
|
||||||
|
href={`/incidents/${incident.incident_id}`}
|
||||||
|
className={`block border rounded-lg p-3 hover:brightness-110 transition-all ${cls}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
|
<span className="text-xs font-mono font-semibold uppercase tracking-wide">
|
||||||
|
{incident.type ?? "other"}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs opacity-60 font-mono">
|
||||||
|
{incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-bold leading-tight">{incident.title ?? "Incident"}</p>
|
||||||
|
{incident.location && (
|
||||||
|
<p className="text-xs opacity-70 mt-1 font-mono truncate">{incident.location}</p>
|
||||||
|
)}
|
||||||
|
{!incident.location_coords && (
|
||||||
|
<p className="text-xs opacity-40 mt-1 font-mono italic">location not geocoded yet</p>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function MapPage() {
|
export default function MapPage() {
|
||||||
const { nodes, loading } = useNodes();
|
const { nodes, loading } = useNodes();
|
||||||
const activeCalls = useActiveCalls();
|
const activeCalls = useActiveCalls();
|
||||||
@@ -34,10 +69,24 @@ export default function MapPage() {
|
|||||||
Loading map…
|
Loading map…
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ height: "calc(100vh - 160px)" }}>
|
<div style={{ height: "calc(100vh - 280px)", minHeight: "400px" }}>
|
||||||
<MapView nodes={nodes} activeCalls={activeCalls} incidents={incidents} />
|
<MapView nodes={nodes} activeCalls={activeCalls} incidents={incidents} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Active incidents — shown even without geocoded location */}
|
||||||
|
{incidents.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">
|
||||||
|
Active Incidents ({incidents.length})
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||||
|
{incidents.map((inc) => (
|
||||||
|
<IncidentCard key={inc.incident_id} incident={inc} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,9 +59,11 @@ export default function MapView({ nodes, activeCalls, incidents = [] }: Props) {
|
|||||||
activeCalls.map((c) => [c.node_id, c])
|
activeCalls.map((c) => [c.node_id, c])
|
||||||
);
|
);
|
||||||
|
|
||||||
// Only show incidents that have coordinates
|
// Only show incidents that have been geocoded (location_coords set by the server).
|
||||||
const mappableIncidents = incidents.filter(
|
const plottedIncidents = incidents.flatMap((inc) =>
|
||||||
(i) => i.location && i.location.lat != null && i.location.lng != null
|
inc.location_coords
|
||||||
|
? [{ inc, pos: [inc.location_coords.lat, inc.location_coords.lng] as [number, number] }]
|
||||||
|
: []
|
||||||
);
|
);
|
||||||
|
|
||||||
const center: [number, number] =
|
const center: [number, number] =
|
||||||
@@ -102,22 +104,26 @@ export default function MapView({ nodes, activeCalls, incidents = [] }: Props) {
|
|||||||
</Marker>
|
</Marker>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Incident markers */}
|
{/* Incident markers — positioned at the node covering the incident's system */}
|
||||||
{mappableIncidents.map((inc) => (
|
{plottedIncidents.map(({ inc, pos }) => (
|
||||||
<Marker
|
<Marker
|
||||||
key={inc.incident_id}
|
key={inc.incident_id}
|
||||||
position={[inc.location!.lat, inc.location!.lng]}
|
position={pos}
|
||||||
icon={incidentIcon(inc.type)}
|
icon={incidentIcon(inc.type)}
|
||||||
>
|
>
|
||||||
<Popup className="font-mono">
|
<Popup className="font-mono">
|
||||||
<div className="text-gray-900">
|
<div className="text-gray-900">
|
||||||
<p className="font-bold">{inc.title ?? "Incident"}</p>
|
<p className="font-bold">{inc.title ?? "Incident"}</p>
|
||||||
<p className="text-xs capitalize" style={{ color: INCIDENT_COLORS[inc.type ?? "other"] }}>
|
<p className="text-xs capitalize" style={{ color: INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other }}>
|
||||||
{inc.type ?? "other"}
|
{inc.type ?? "other"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs mt-1 capitalize">{inc.status}</p>
|
<p className="text-xs mt-1 capitalize">{inc.status}</p>
|
||||||
|
{inc.location && <p className="text-xs text-gray-600 mt-1">{inc.location}</p>}
|
||||||
<p className="text-xs text-gray-500">{inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""}</p>
|
<p className="text-xs text-gray-500">{inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""}</p>
|
||||||
{inc.summary && <p className="text-xs mt-1">{inc.summary}</p>}
|
{inc.summary && <p className="text-xs mt-1">{inc.summary}</p>}
|
||||||
|
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline mt-1 block">
|
||||||
|
View incident →
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ export const c2api = {
|
|||||||
request(`/incidents/${id}`, { method: "DELETE" }),
|
request(`/incidents/${id}`, { method: "DELETE" }),
|
||||||
linkCallToIncident: (incidentId: string, callId: string) =>
|
linkCallToIncident: (incidentId: string, callId: string) =>
|
||||||
request(`/incidents/${incidentId}/calls/${callId}`, { method: "POST" }),
|
request(`/incidents/${incidentId}/calls/${callId}`, { method: "POST" }),
|
||||||
|
summarizeIncident: (id: string) =>
|
||||||
|
request(`/incidents/${id}/summarize`, { method: "POST" }),
|
||||||
|
|
||||||
// Alerts
|
// Alerts
|
||||||
getAlerts: (acknowledged?: boolean) => {
|
getAlerts: (acknowledged?: boolean) => {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export interface CallRecord {
|
|||||||
transcript_corrected: string | null;
|
transcript_corrected: string | null;
|
||||||
segments: TranscriptSegment[] | null;
|
segments: TranscriptSegment[] | null;
|
||||||
incident_id: string | null;
|
incident_id: string | null;
|
||||||
location: { lat: number; lng: number } | null;
|
location: string | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
status: "active" | "ended";
|
status: "active" | "ended";
|
||||||
}
|
}
|
||||||
@@ -50,8 +50,11 @@ export interface IncidentRecord {
|
|||||||
title: string | null;
|
title: string | null;
|
||||||
type: string | null;
|
type: string | null;
|
||||||
status: "active" | "resolved";
|
status: "active" | "resolved";
|
||||||
location: { lat: number; lng: number } | null;
|
location: string | null;
|
||||||
|
location_coords: { lat: number; lng: number } | null;
|
||||||
call_ids: string[];
|
call_ids: string[];
|
||||||
|
system_ids: string[];
|
||||||
|
talkgroup_ids: string[];
|
||||||
started_at: string;
|
started_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
summary: string | null;
|
summary: string | null;
|
||||||
|
|||||||
@@ -48,6 +48,39 @@ export function useCalls(limitCount = 50) {
|
|||||||
return { calls, loading, error };
|
return { calls, loading, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useCallsByIncident(incidentId: string | null) {
|
||||||
|
const [calls, setCalls] = useState<CallRecord[]>([]);
|
||||||
|
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() {
|
export function useActiveCalls() {
|
||||||
const [calls, setCalls] = useState<CallRecord[]>([]);
|
const [calls, setCalls] = useState<CallRecord[]>([]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
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 { onAuthStateChanged } from "firebase/auth";
|
||||||
import { db, auth } from "@/lib/firebase";
|
import { db, auth } from "@/lib/firebase";
|
||||||
import type { IncidentRecord } from "@/lib/types";
|
import type { IncidentRecord } from "@/lib/types";
|
||||||
@@ -58,6 +58,43 @@ export function useIncidents(limitCount = 100) {
|
|||||||
return { incidents, loading, error };
|
return { incidents, loading, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useIncident(incidentId: string | null) {
|
||||||
|
const [incident, setIncident] = useState<IncidentRecord | null>(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() {
|
export function useActiveIncidents() {
|
||||||
const [incidents, setIncidents] = useState<IncidentRecord[]>([]);
|
const [incidents, setIncidents] = useState<IncidentRecord[]>([]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user