Filter calls in ui
This commit is contained in:
@@ -1,10 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
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);
|
||||
@@ -13,16 +72,122 @@ export default function CallsPage() {
|
||||
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">
|
||||
@@ -51,14 +216,17 @@ export default function CallsPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* History */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">
|
||||
History
|
||||
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>
|
||||
) : ended.length === 0 ? (
|
||||
<p className="text-gray-600 text-sm font-mono">No calls recorded yet.</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">
|
||||
@@ -74,13 +242,13 @@ export default function CallsPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ended.map((c) => (
|
||||
{filtered.map((c) => (
|
||||
<CallRow key={c.call_id} call={c} systemName={systemMap[c.system_id ?? ""]?.name} isAdmin={isAdmin} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{ended.length >= limitCount && (
|
||||
{!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"
|
||||
|
||||
Reference in New Issue
Block a user