265 lines
9.5 KiB
TypeScript
265 lines
9.5 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useMemo } from "react";
|
|
import { useCalls } from "@/lib/useCalls";
|
|
import { useSystems } from "@/lib/useSystems";
|
|
import { CallRow } from "@/components/CallRow";
|
|
import { useAuth } from "@/components/AuthProvider";
|
|
import type { CallRecord } from "@/lib/types";
|
|
|
|
const inputCls =
|
|
"bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm text-white font-mono " +
|
|
"placeholder:text-gray-600 focus:outline-none focus:border-indigo-500 w-full";
|
|
|
|
function filterCalls(calls: CallRecord[], filters: Filters): CallRecord[] {
|
|
const q = filters.query.trim().toLowerCase();
|
|
const tgid = filters.tgid.trim();
|
|
const fromMs = filters.dateFrom ? new Date(filters.dateFrom).getTime() : null;
|
|
const toMs = filters.dateTo ? new Date(filters.dateTo + "T23:59:59").getTime() : null;
|
|
|
|
return calls.filter((c) => {
|
|
// System filter
|
|
if (filters.systemId && c.system_id !== filters.systemId) return false;
|
|
|
|
// TGID filter (exact match on the number)
|
|
if (tgid && String(c.talkgroup_id ?? "") !== tgid) return false;
|
|
|
|
// Date range
|
|
const ts = new Date(c.started_at).getTime();
|
|
if (fromMs !== null && ts < fromMs) return false;
|
|
if (toMs !== null && ts > toMs) return false;
|
|
|
|
// Free-text: talkgroup name, node_id, transcript, tags
|
|
if (q) {
|
|
const hay = [
|
|
c.talkgroup_name ?? "",
|
|
c.node_id,
|
|
c.transcript ?? "",
|
|
c.transcript_corrected ?? "",
|
|
...(c.tags ?? []),
|
|
].join(" ").toLowerCase();
|
|
if (!hay.includes(q)) return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
}
|
|
|
|
interface Filters {
|
|
query: string;
|
|
tgid: string;
|
|
systemId: string;
|
|
dateFrom: string;
|
|
dateTo: string;
|
|
}
|
|
|
|
const DEFAULT_FILTERS: Filters = {
|
|
query: "",
|
|
tgid: "",
|
|
systemId: "",
|
|
dateFrom: "",
|
|
dateTo: "",
|
|
};
|
|
|
|
function isActive(f: Filters) {
|
|
return f.query || f.tgid || f.systemId || f.dateFrom || f.dateTo;
|
|
}
|
|
|
|
export default function CallsPage() {
|
|
const [limitCount, setLimitCount] = useState(100);
|
|
const { calls, loading } = useCalls(limitCount);
|
|
const { systems } = useSystems();
|
|
const { isAdmin } = useAuth();
|
|
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
|
|
|
|
const [filters, setFilters] = useState<Filters>(DEFAULT_FILTERS);
|
|
const [showFilters, setShowFilters] = useState(false);
|
|
|
|
function set<K extends keyof Filters>(key: K, value: string) {
|
|
setFilters((f) => ({ ...f, [key]: value }));
|
|
}
|
|
|
|
const active = calls.filter((c) => c.status === "active");
|
|
const ended = calls.filter((c) => c.status === "ended");
|
|
const filtered = useMemo(() => filterCalls(ended, filters), [ended, filters]);
|
|
|
|
const activeFilters = isActive(filters);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-xl font-bold text-white font-mono">Calls</h1>
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-xs text-gray-500 font-mono">{calls.length} loaded</span>
|
|
<button
|
|
onClick={() => setShowFilters((v) => !v)}
|
|
className={`text-xs font-mono px-3 py-1.5 rounded-lg border transition-colors ${
|
|
activeFilters
|
|
? "border-indigo-600 bg-indigo-950 text-indigo-300"
|
|
: "border-gray-700 bg-gray-900 text-gray-400 hover:text-gray-200"
|
|
}`}
|
|
>
|
|
{showFilters ? "Hide filters" : "Filter"}
|
|
{activeFilters && " •"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filter bar */}
|
|
{showFilters && (
|
|
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-3">
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
|
{/* Text search */}
|
|
<div className="lg:col-span-2">
|
|
<label className="text-xs text-gray-500 block mb-1">Search (talkgroup, node, transcript, tags)</label>
|
|
<input
|
|
type="text"
|
|
value={filters.query}
|
|
onChange={(e) => set("query", e.target.value)}
|
|
placeholder="fire, Engine 5, dispatch…"
|
|
className={inputCls}
|
|
/>
|
|
</div>
|
|
|
|
{/* TGID */}
|
|
<div>
|
|
<label className="text-xs text-gray-500 block mb-1">Talkgroup ID</label>
|
|
<input
|
|
type="number"
|
|
value={filters.tgid}
|
|
onChange={(e) => set("tgid", e.target.value)}
|
|
placeholder="e.g. 9048"
|
|
className={inputCls}
|
|
/>
|
|
</div>
|
|
|
|
{/* System */}
|
|
<div>
|
|
<label className="text-xs text-gray-500 block mb-1">System</label>
|
|
<select
|
|
value={filters.systemId}
|
|
onChange={(e) => set("systemId", e.target.value)}
|
|
className={inputCls}
|
|
>
|
|
<option value="">All systems</option>
|
|
{systems.map((s) => (
|
|
<option key={s.system_id} value={s.system_id}>{s.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Date from */}
|
|
<div>
|
|
<label className="text-xs text-gray-500 block mb-1">From date</label>
|
|
<input
|
|
type="date"
|
|
value={filters.dateFrom}
|
|
onChange={(e) => set("dateFrom", e.target.value)}
|
|
className={inputCls}
|
|
/>
|
|
</div>
|
|
|
|
{/* Date to */}
|
|
<div>
|
|
<label className="text-xs text-gray-500 block mb-1">To date</label>
|
|
<input
|
|
type="date"
|
|
value={filters.dateTo}
|
|
onChange={(e) => set("dateTo", e.target.value)}
|
|
className={inputCls}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{activeFilters && (
|
|
<div className="flex items-center justify-between pt-1">
|
|
<p className="text-xs text-gray-500 font-mono">
|
|
{filtered.length} of {ended.length} calls match
|
|
</p>
|
|
<button
|
|
onClick={() => setFilters(DEFAULT_FILTERS)}
|
|
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors"
|
|
>
|
|
Clear all
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Live calls — never filtered */}
|
|
{active.length > 0 && (
|
|
<section>
|
|
<h2 className="text-sm font-semibold text-orange-400 uppercase tracking-wider mb-3">
|
|
Live ({active.length})
|
|
</h2>
|
|
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
|
<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>
|
|
{active.map((c) => (
|
|
<CallRow key={c.call_id} call={c} systemName={systemMap[c.system_id ?? ""]?.name} isAdmin={isAdmin} />
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* History */}
|
|
<section>
|
|
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">
|
|
History{activeFilters && <span className="ml-2 text-indigo-400">({filtered.length} filtered)</span>}
|
|
</h2>
|
|
{loading ? (
|
|
<p className="text-gray-600 text-sm font-mono">Loading…</p>
|
|
) : filtered.length === 0 ? (
|
|
<p className="text-gray-600 text-sm font-mono">
|
|
{activeFilters ? "No calls match the current filters." : "No calls recorded yet."}
|
|
</p>
|
|
) : (
|
|
<>
|
|
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
|
<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>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filtered.map((c) => (
|
|
<CallRow key={c.call_id} call={c} systemName={systemMap[c.system_id ?? ""]?.name} isAdmin={isAdmin} />
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{!activeFilters && ended.length >= limitCount && (
|
|
<button
|
|
onClick={() => setLimitCount((n) => n + 100)}
|
|
className="mt-4 text-sm text-indigo-400 hover:text-indigo-300 font-mono transition-colors"
|
|
>
|
|
Load more
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|