Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d67b2057e6 | ||
|
|
c1c3e89e1d |
@@ -100,6 +100,7 @@ export default function IncidentDetailPage() {
|
|||||||
const displayTags = incident.tags.filter((t) => t !== "auto-generated");
|
const displayTags = incident.tags.filter((t) => t !== "auto-generated");
|
||||||
const unitsActive = incident.units_active ?? incident.units ?? [];
|
const unitsActive = incident.units_active ?? incident.units ?? [];
|
||||||
const unitsCleared = incident.units_cleared ?? [];
|
const unitsCleared = incident.units_cleared ?? [];
|
||||||
|
const vehicles = incident.vehicles ?? [];
|
||||||
const active = incident.status === "active";
|
const active = incident.status === "active";
|
||||||
|
|
||||||
const visible = newestFirst.slice(0, earlierShown);
|
const visible = newestFirst.slice(0, earlierShown);
|
||||||
@@ -213,11 +214,11 @@ export default function IncidentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{incident.vehicles?.length > 0 && (
|
{vehicles.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-ink-muted uppercase tracking-wide mb-2">Vehicles</p>
|
<p className="text-xs text-ink-muted uppercase tracking-wide mb-2">Vehicles</p>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{incident.vehicles.map((v) => (
|
{vehicles.map((v) => (
|
||||||
<span key={v} className="text-xs bg-raised text-ink-2 px-2 py-0.5 rounded font-mono">{v}</span>
|
<span key={v} className="text-xs bg-raised text-ink-2 px-2 py-0.5 rounded font-mono">{v}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ function DiscordJoinModal({
|
|||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="bg-gray-900 border border-gray-700 rounded-xl p-6 space-y-4 font-mono w-full max-w-sm"
|
className="bg-gray-900 border border-gray-700 rounded-xl p-6 space-y-4 font-mono w-full max-w-sm max-h-[90vh] overflow-y-auto"
|
||||||
>
|
>
|
||||||
<h3 className="text-white font-semibold">Join Discord Voice</h3>
|
<h3 className="text-white font-semibold">Join Discord Voice</h3>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default function NodesPage() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{pending.map((n) => (
|
{pending.map((n) => (
|
||||||
<div key={n.node_id} onClick={() => setConfigNode(n)} className="cursor-pointer">
|
<div key={n.node_id} onClick={() => setConfigNode(n)} className="cursor-pointer">
|
||||||
<NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} />
|
<NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} linkToDetail={false} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ function TripCard({ trip, isAdmin, onDelete }: {
|
|||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const upcoming = trip.start_date >= today;
|
// Bucket and badge must agree: the list groups on end_date (page.tsx ~L176),
|
||||||
|
// so a trip isn't "Past" until it's over, not when it starts.
|
||||||
|
const upcoming = trip.end_date >= today;
|
||||||
const attendeeCount = Object.keys(trip.attendees ?? {}).length;
|
const attendeeCount = Object.keys(trip.attendees ?? {}).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -97,10 +99,10 @@ function CreateModal({ onClose, onCreate }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4"
|
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4 max-h-[90vh] overflow-y-auto"
|
||||||
>
|
>
|
||||||
<h2 className="text-white font-bold">New Trip</h2>
|
<h2 className="text-white font-bold">New Trip</h2>
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function fmtClock(s: number): string {
|
|||||||
return `${m}:${r.toString().padStart(2, "0")}`;
|
return `${m}:${r.toString().padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function InlinePlayer({ callId, hasAudio }: { callId: string; hasAudio: boolean }) {
|
function InlinePlayer({ callId }: { callId: string }) {
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -32,8 +32,6 @@ function InlinePlayer({ callId, hasAudio }: { callId: string; hasAudio: boolean
|
|||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
if (!hasAudio) return null;
|
|
||||||
|
|
||||||
async function ensureUrl() {
|
async function ensureUrl() {
|
||||||
if (url || loading) return;
|
if (url || loading) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -169,7 +167,7 @@ export function CallSpineEntry({
|
|||||||
|
|
||||||
{hasAudio && (
|
{hasAudio && (
|
||||||
<div className="mt-1.5">
|
<div className="mt-1.5">
|
||||||
<InlinePlayer callId={call.call_id} hasAudio={hasAudio} />
|
<InlinePlayer callId={call.call_id} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -5,15 +5,20 @@ import type { NodeRecord, SystemRecord } from "@/lib/types";
|
|||||||
interface Props {
|
interface Props {
|
||||||
node: NodeRecord;
|
node: NodeRecord;
|
||||||
system?: SystemRecord;
|
system?: SystemRecord;
|
||||||
|
/**
|
||||||
|
* When false, the card renders without its `/nodes/[id]` Link wrapper so a
|
||||||
|
* parent click handler can take the interaction (pending nodes open the
|
||||||
|
* config modal instead of navigating). Defaults to true.
|
||||||
|
*/
|
||||||
|
linkToDetail?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NodeCard({ node, system }: Props) {
|
export function NodeCard({ node, system, linkToDetail = true }: Props) {
|
||||||
const lastSeen = node.last_seen
|
const lastSeen = node.last_seen
|
||||||
? new Date(node.last_seen).toLocaleTimeString()
|
? new Date(node.last_seen).toLocaleTimeString()
|
||||||
: "never";
|
: "never";
|
||||||
|
|
||||||
return (
|
const body = (
|
||||||
<Link href={`/nodes/${node.node_id}`}>
|
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4 hover:border-gray-600 transition-colors cursor-pointer">
|
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4 hover:border-gray-600 transition-colors cursor-pointer">
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -58,6 +63,7 @@ export function NodeCard({ node, system }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return linkToDetail ? <Link href={`/nodes/${node.node_id}`}>{body}</Link> : body;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
|||||||
const selectedPreset = PRESETS.find((p) => p.value === preset);
|
const selectedPreset = PRESETS.find((p) => p.value === preset);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md font-mono">
|
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md font-mono max-h-[90vh] overflow-y-auto">
|
||||||
<h2 className="text-white font-semibold mb-1">Configure Node</h2>
|
<h2 className="text-white font-semibold mb-1">Configure Node</h2>
|
||||||
<p className="text-gray-400 text-sm mb-5">
|
<p className="text-gray-400 text-sm mb-5">
|
||||||
<span className="text-indigo-400">{node.node_id}</span> connected for the first time.
|
<span className="text-indigo-400">{node.node_id}</span> connected for the first time.
|
||||||
|
|||||||
@@ -143,8 +143,9 @@ export interface IncidentRecord {
|
|||||||
call_ids: string[];
|
call_ids: string[];
|
||||||
system_ids: string[];
|
system_ids: string[];
|
||||||
talkgroup_ids: string[];
|
talkgroup_ids: string[];
|
||||||
units: string[];
|
/** Omitted on incident docs written before these fields existed. */
|
||||||
vehicles: string[];
|
units?: string[];
|
||||||
|
vehicles?: string[];
|
||||||
/** Units currently believed on scene — maintained by incident_correlator.py `_attach`. */
|
/** Units currently believed on scene — maintained by incident_correlator.py `_attach`. */
|
||||||
units_active?: string[];
|
units_active?: string[];
|
||||||
/** Units that reported clearing/back in service on this incident. */
|
/** Units that reported clearing/back in service on this incident. */
|
||||||
|
|||||||
Reference in New Issue
Block a user