mirror of
https://github.com/arkorty/B.Tech-Project-III.git
synced 2026-04-19 12:41:48 +00:00
715 lines
30 KiB
TypeScript
715 lines
30 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useState, useCallback, useRef } from "react";
|
|
import Sidebar from "../components/Sidebar";
|
|
import TopBar from "../components/TopBar";
|
|
import {
|
|
fetchTimeline,
|
|
fetchGroups,
|
|
raiseJiraTicket,
|
|
TimelineSignal,
|
|
Group,
|
|
formatRelativeTime,
|
|
getSeverityColor,
|
|
getSignalIcon,
|
|
parseMetaList,
|
|
} from "../lib/api";
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function formatDate(iso: string): string {
|
|
if (!iso) return "—";
|
|
try {
|
|
return new Date(iso).toLocaleString("en-GB", {
|
|
day: "2-digit", month: "short", year: "numeric",
|
|
hour: "2-digit", minute: "2-digit",
|
|
});
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
function formatDateGroup(iso: string): string {
|
|
if (!iso) return "Unknown Date";
|
|
try {
|
|
return new Date(iso).toLocaleDateString("en-GB", {
|
|
weekday: "long", day: "numeric", month: "long", year: "numeric",
|
|
});
|
|
} catch {
|
|
return iso.slice(0, 10);
|
|
}
|
|
}
|
|
|
|
function getDay(iso: string): string {
|
|
try { return new Date(iso).toISOString().slice(0, 10); } catch { return ""; }
|
|
}
|
|
|
|
const SEVERITY_OPTIONS = ["low", "medium", "high", "critical"];
|
|
const LENS_OPTIONS = ["dev", "product", "client", "community", "meet"];
|
|
const SIGNAL_TYPE_OPTIONS = [
|
|
"architecture_decision", "tech_debt", "blocker", "risk", "decision",
|
|
"action_item", "feature_request", "promise", "scope_creep", "knowledge_gap",
|
|
"sentiment_spike", "recurring_bug", "meet_decision", "meet_action_item",
|
|
"meet_blocker", "meet_risk", "meet_open_q",
|
|
];
|
|
|
|
// ─── Signal Card ──────────────────────────────────────────────────────────────
|
|
|
|
function SignalCard({
|
|
signal,
|
|
onRaiseJira,
|
|
raisingId,
|
|
raisedIds,
|
|
}: {
|
|
signal: TimelineSignal;
|
|
onRaiseJira: (sig: TimelineSignal) => void;
|
|
raisingId: string | null;
|
|
raisedIds: Set<string>;
|
|
}) {
|
|
const meta = signal.metadata;
|
|
const color = getSeverityColor(meta.severity);
|
|
const icon = getSignalIcon(meta.type);
|
|
const entities = parseMetaList(meta.entities);
|
|
const keywords = parseMetaList(meta.keywords);
|
|
const isRaised = raisedIds.has(signal.id);
|
|
const isRaising = raisingId === signal.id;
|
|
|
|
const raiseable = [
|
|
"tech_debt", "recurring_bug", "architecture_decision", "blocker", "risk",
|
|
"feature_request", "priority_conflict", "promise", "scope_creep",
|
|
"meet_action_item", "meet_blocker", "meet_risk", "meet_decision", "decision", "action_item",
|
|
].includes(meta.type);
|
|
|
|
return (
|
|
<div
|
|
className="neon-card-gradient rounded-xl border border-white/5 border-l-[3px] p-4 transition-all duration-200 hover:border-[rgba(167,139,250,0.2)] group"
|
|
style={{ borderLeftColor: color }}
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex items-start gap-3 min-w-0">
|
|
<div className="p-2 rounded-lg flex-shrink-0" style={{ background: `${color}18` }}>
|
|
<span className="material-symbols-outlined" style={{ fontSize: "16px", color }}>
|
|
{icon}
|
|
</span>
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap mb-1">
|
|
<span
|
|
className="text-[9px] font-bold uppercase tracking-[0.1em] px-2 py-0.5 rounded-full border"
|
|
style={{ color, borderColor: `${color}40`, background: `${color}15` }}
|
|
>
|
|
{meta.type.replace(/_/g, " ")}
|
|
</span>
|
|
<span
|
|
className="text-[9px] font-bold uppercase tracking-[0.08em] px-2 py-0.5 rounded-full border border-white/10 text-zinc-500"
|
|
>
|
|
{meta.lens}
|
|
</span>
|
|
{signal.group_name && (
|
|
<span className="text-[9px] text-zinc-600 font-mono-data">
|
|
{signal.group_name}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-[13px] text-zinc-200 leading-relaxed">
|
|
{meta.summary || signal.document}
|
|
</p>
|
|
{meta.raw_quote && meta.raw_quote !== meta.summary && meta.type !== "meet_chunk_raw" && (
|
|
<p className="text-[11px] text-zinc-500 mt-1.5 italic border-l-2 border-zinc-700 pl-2">
|
|
"{meta.raw_quote.slice(0, 160)}"
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col items-end gap-2 flex-shrink-0">
|
|
<span className="text-[10px] text-zinc-600 font-mono-data whitespace-nowrap">
|
|
{formatRelativeTime(meta.timestamp)}
|
|
</span>
|
|
{raiseable && !isRaised && (
|
|
<button
|
|
onClick={() => onRaiseJira(signal)}
|
|
disabled={isRaising}
|
|
className="text-[9px] text-zinc-600 hover:text-[#A78BFA] transition-colors flex items-center gap-1 opacity-0 group-hover:opacity-100"
|
|
title="Raise Jira ticket"
|
|
>
|
|
{isRaising ? (
|
|
<div className="w-3 h-3 border border-[#A78BFA]/40 border-t-[#A78BFA] rounded-full animate-spin" />
|
|
) : (
|
|
<span className="material-symbols-outlined" style={{ fontSize: "13px" }}>bug_report</span>
|
|
)}
|
|
Raise Jira
|
|
</button>
|
|
)}
|
|
{isRaised && (
|
|
<span className="text-[9px] text-emerald-500 flex items-center gap-1">
|
|
<span className="material-symbols-outlined" style={{ fontSize: "12px" }}>check_circle</span>
|
|
Raised
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{(entities.length > 0 || keywords.length > 0) && (
|
|
<div className="flex flex-wrap gap-1 mt-3">
|
|
{entities.slice(0, 4).map((e, i) => (
|
|
<span key={i} className="text-[10px] text-[#A78BFA] bg-[rgba(167,139,250,0.08)] px-1.5 py-0.5 rounded">
|
|
{e}
|
|
</span>
|
|
))}
|
|
{keywords.slice(0, 4).map((k, i) => (
|
|
<span key={i} className="text-[10px] text-zinc-600 bg-[rgba(255,255,255,0.04)] px-1.5 py-0.5 rounded">
|
|
{k}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Timeline View ─────────────────────────────────────────────────────────────
|
|
|
|
function TimelineView({
|
|
signals,
|
|
onRaiseJira,
|
|
raisingId,
|
|
raisedIds,
|
|
}: {
|
|
signals: TimelineSignal[];
|
|
onRaiseJira: (sig: TimelineSignal) => void;
|
|
raisingId: string | null;
|
|
raisedIds: Set<string>;
|
|
}) {
|
|
// Group by day
|
|
const byDay: Map<string, TimelineSignal[]> = new Map();
|
|
for (const sig of signals) {
|
|
const day = getDay(sig.metadata.timestamp);
|
|
if (!byDay.has(day)) byDay.set(day, []);
|
|
byDay.get(day)!.push(sig);
|
|
}
|
|
|
|
if (signals.length === 0) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center h-64 text-zinc-600">
|
|
<span className="material-symbols-outlined text-4xl mb-2">chat_bubble</span>
|
|
<p className="text-[13px] uppercase tracking-wider">No signals found</p>
|
|
<p className="text-[10px] text-zinc-700 mt-1">Signals appear as your Telegram groups generate activity</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
{Array.from(byDay.entries()).map(([day, daySigs]) => (
|
|
<div key={day}>
|
|
{/* Day separator */}
|
|
<div className="flex items-center gap-4 mb-4">
|
|
<div className="flex-1 h-px bg-white/5" />
|
|
<div className="flex items-center gap-2 px-4 py-1.5 rounded-full border border-white/10 bg-[rgba(255,255,255,0.03)]">
|
|
<span className="material-symbols-outlined text-zinc-500" style={{ fontSize: "13px" }}>calendar_today</span>
|
|
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-wider">
|
|
{formatDateGroup(day)}
|
|
</span>
|
|
<span className="text-[10px] text-zinc-700">·</span>
|
|
<span className="text-[10px] text-zinc-600">{daySigs.length} signals</span>
|
|
</div>
|
|
<div className="flex-1 h-px bg-white/5" />
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{daySigs.map((sig) => (
|
|
<SignalCard
|
|
key={sig.id}
|
|
signal={sig}
|
|
onRaiseJira={onRaiseJira}
|
|
raisingId={raisingId}
|
|
raisedIds={raisedIds}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Table View ────────────────────────────────────────────────────────────────
|
|
|
|
function TableView({
|
|
signals,
|
|
sortKey,
|
|
sortAsc,
|
|
onSort,
|
|
onRaiseJira,
|
|
raisingId,
|
|
raisedIds,
|
|
}: {
|
|
signals: TimelineSignal[];
|
|
sortKey: string;
|
|
sortAsc: boolean;
|
|
onSort: (key: string) => void;
|
|
onRaiseJira: (sig: TimelineSignal) => void;
|
|
raisingId: string | null;
|
|
raisedIds: Set<string>;
|
|
}) {
|
|
const SortIcon = ({ k }: { k: string }) => (
|
|
<span className="material-symbols-outlined" style={{ fontSize: "11px" }}>
|
|
{sortKey !== k ? "unfold_more" : sortAsc ? "expand_less" : "expand_more"}
|
|
</span>
|
|
);
|
|
|
|
if (signals.length === 0) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center h-48 text-zinc-600">
|
|
<span className="material-symbols-outlined text-3xl mb-2">table_rows</span>
|
|
<p className="text-[12px] uppercase tracking-wider">No signals</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-white/5 bg-[rgba(255,255,255,0.02)]">
|
|
{[
|
|
{ label: "Type", key: "type" },
|
|
{ label: "Summary", key: null },
|
|
{ label: "Severity", key: "severity" },
|
|
{ label: "Lens", key: "lens" },
|
|
{ label: "Group", key: "group_name" },
|
|
{ label: "Time", key: "timestamp" },
|
|
{ label: "", key: null },
|
|
].map(({ label, key }) => (
|
|
<th
|
|
key={label}
|
|
className={`px-4 py-3 text-left text-[10px] font-bold text-zinc-500 uppercase tracking-wider ${key ? "cursor-pointer hover:text-zinc-300" : ""}`}
|
|
onClick={key ? () => onSort(key) : undefined}
|
|
>
|
|
<div className="flex items-center gap-1">
|
|
{label}
|
|
{key && <SortIcon k={key} />}
|
|
</div>
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{signals.map((sig) => {
|
|
const meta = sig.metadata;
|
|
const color = getSeverityColor(meta.severity);
|
|
const isRaised = raisedIds.has(sig.id);
|
|
const isRaising = raisingId === sig.id;
|
|
const raiseable = [
|
|
"tech_debt", "recurring_bug", "architecture_decision", "blocker", "risk",
|
|
"feature_request", "priority_conflict", "promise", "scope_creep",
|
|
"meet_action_item", "meet_blocker", "meet_risk", "meet_decision", "decision", "action_item",
|
|
].includes(meta.type);
|
|
|
|
return (
|
|
<tr key={sig.id} className="border-b border-white/5 hover:bg-[rgba(167,139,250,0.04)] transition-colors group">
|
|
<td className="px-4 py-3">
|
|
<span
|
|
className="text-[9px] font-bold uppercase tracking-[0.1em] px-2 py-0.5 rounded-full border"
|
|
style={{ color, borderColor: `${color}40`, background: `${color}15` }}
|
|
>
|
|
{meta.type.replace(/_/g, " ")}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 max-w-[280px]">
|
|
<p className="text-[12px] text-zinc-200 truncate">{meta.summary || sig.document}</p>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className="text-[11px] font-semibold" style={{ color }}>{meta.severity}</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className="text-[10px] text-zinc-500 uppercase tracking-wider">{meta.lens}</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className="text-[11px] text-zinc-500">{sig.group_name || meta.group_id}</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className="text-[10px] text-zinc-600 font-mono-data">{formatRelativeTime(meta.timestamp)}</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{raiseable && !isRaised && (
|
|
<button
|
|
onClick={() => onRaiseJira(sig)}
|
|
disabled={isRaising}
|
|
className="text-zinc-600 hover:text-[#A78BFA] transition-colors opacity-0 group-hover:opacity-100"
|
|
title="Raise Jira ticket"
|
|
>
|
|
{isRaising ? (
|
|
<div className="w-3.5 h-3.5 border border-[#A78BFA]/40 border-t-[#A78BFA] rounded-full animate-spin" />
|
|
) : (
|
|
<span className="material-symbols-outlined" style={{ fontSize: "15px" }}>bug_report</span>
|
|
)}
|
|
</button>
|
|
)}
|
|
{isRaised && (
|
|
<span className="material-symbols-outlined text-emerald-500" style={{ fontSize: "15px" }}>
|
|
check_circle
|
|
</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Main Page ─────────────────────────────────────────────────────────────────
|
|
|
|
export default function ChatsPage() {
|
|
const [signals, setSignals] = useState<TimelineSignal[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [truncated, setTruncated] = useState(false);
|
|
const [groups, setGroups] = useState<Group[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [viewMode, setViewMode] = useState<"timeline" | "table">("timeline");
|
|
const [raisingId, setRaisingId] = useState<string | null>(null);
|
|
const [raisedIds, setRaisedIds] = useState<Set<string>>(new Set());
|
|
const [raiseMsg, setRaiseMsg] = useState("");
|
|
|
|
// Filters
|
|
const [filterGroup, setFilterGroup] = useState("");
|
|
const [filterSeverity, setFilterSeverity] = useState("");
|
|
const [filterLens, setFilterLens] = useState("");
|
|
const [filterType, setFilterType] = useState("");
|
|
const [dateFrom, setDateFrom] = useState("");
|
|
const [dateTo, setDateTo] = useState("");
|
|
const [search, setSearch] = useState("");
|
|
|
|
// Sort (table view)
|
|
const [sortKey, setSortKey] = useState("timestamp");
|
|
const [sortAsc, setSortAsc] = useState(false);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [result, grps] = await Promise.all([
|
|
fetchTimeline({
|
|
group_id: filterGroup || undefined,
|
|
severity: filterSeverity || undefined,
|
|
lens: filterLens || undefined,
|
|
signal_type: filterType || undefined,
|
|
date_from: dateFrom || undefined,
|
|
date_to: dateTo || undefined,
|
|
limit: 300,
|
|
}),
|
|
fetchGroups(),
|
|
]);
|
|
setSignals(result.signals);
|
|
setTotal(result.total);
|
|
setTruncated(result.truncated);
|
|
setGroups(grps);
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [filterGroup, filterSeverity, filterLens, filterType, dateFrom, dateTo]);
|
|
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
const handleRaiseJira = async (sig: TimelineSignal) => {
|
|
setRaisingId(sig.id);
|
|
try {
|
|
const result = await raiseJiraTicket(sig.id, sig.metadata.group_id);
|
|
if (result.ok && result.key) {
|
|
setRaisedIds((prev) => new Set([...prev, sig.id]));
|
|
setRaiseMsg(`Ticket ${result.key} raised!`);
|
|
setTimeout(() => setRaiseMsg(""), 4000);
|
|
} else {
|
|
setRaiseMsg(result.reason === "already_raised" ? "Already raised" : (result.error || "Failed to raise ticket"));
|
|
setTimeout(() => setRaiseMsg(""), 4000);
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
setRaisingId(null);
|
|
}
|
|
};
|
|
|
|
// Client-side search filter
|
|
const filteredSignals = signals.filter((s) => {
|
|
if (!search) return true;
|
|
const q = search.toLowerCase();
|
|
const meta = s.metadata;
|
|
return (
|
|
(meta.summary || "").toLowerCase().includes(q) ||
|
|
(meta.type || "").toLowerCase().includes(q) ||
|
|
(meta.raw_quote || "").toLowerCase().includes(q) ||
|
|
(s.group_name || "").toLowerCase().includes(q)
|
|
);
|
|
});
|
|
|
|
// Sort for table view
|
|
const sortedSignals = [...filteredSignals].sort((a, b) => {
|
|
const metaA = a.metadata, metaB = b.metadata;
|
|
let cmp = 0;
|
|
if (sortKey === "timestamp") cmp = metaA.timestamp.localeCompare(metaB.timestamp);
|
|
else if (sortKey === "severity") {
|
|
const rank = { critical: 4, high: 3, medium: 2, low: 1 } as Record<string, number>;
|
|
cmp = (rank[metaA.severity] || 0) - (rank[metaB.severity] || 0);
|
|
} else if (sortKey === "type") cmp = metaA.type.localeCompare(metaB.type);
|
|
else if (sortKey === "lens") cmp = metaA.lens.localeCompare(metaB.lens);
|
|
else if (sortKey === "group_name") cmp = (a.group_name || "").localeCompare(b.group_name || "");
|
|
return sortAsc ? cmp : -cmp;
|
|
});
|
|
|
|
const toggleSort = (key: string) => {
|
|
if (sortKey === key) setSortAsc((a) => !a);
|
|
else { setSortKey(key); setSortAsc(false); }
|
|
};
|
|
|
|
const exportCSV = () => {
|
|
const header = ["Type", "Summary", "Severity", "Lens", "Group", "Timestamp"].join(",");
|
|
const rows = filteredSignals.map((s) => {
|
|
const m = s.metadata;
|
|
return [
|
|
m.type,
|
|
`"${(m.summary || "").replace(/"/g, '""').slice(0, 200)}"`,
|
|
m.severity,
|
|
m.lens,
|
|
s.group_name || m.group_id,
|
|
m.timestamp,
|
|
].join(",");
|
|
});
|
|
const blob = new Blob([[header, ...rows].join("\n")], { type: "text/csv" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url; a.download = "chat_signals.csv"; a.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const activeFilters = [filterGroup, filterSeverity, filterLens, filterType, dateFrom, dateTo, search].filter(Boolean).length;
|
|
|
|
const criticalCount = signals.filter(s => s.metadata.severity === "critical").length;
|
|
const highCount = signals.filter(s => s.metadata.severity === "high").length;
|
|
const uniqueGroups = new Set(signals.map(s => s.metadata.group_id)).size;
|
|
|
|
return (
|
|
<div className="min-h-screen flex" style={{ backgroundColor: "#09090B" }}>
|
|
<Sidebar />
|
|
<div className="flex-1 ml-[240px] flex flex-col min-h-screen">
|
|
<TopBar />
|
|
<main className="flex-1 p-8">
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between mb-8 animate-fade-in-up opacity-0">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-xl bg-[rgba(167,139,250,0.15)] flex items-center justify-center">
|
|
<span className="material-symbols-outlined text-[#A78BFA]">chat</span>
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white tracking-tight">Chat History</h1>
|
|
<p className="text-[11px] text-zinc-500 uppercase tracking-[0.2em]">
|
|
Signal timeline · Cross-group intelligence
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{raiseMsg && (
|
|
<span className="text-[11px] text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-3 py-1.5 rounded-lg animate-fade-in-scale opacity-0">
|
|
{raiseMsg}
|
|
</span>
|
|
)}
|
|
<button
|
|
onClick={exportCSV}
|
|
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-white/10 text-[11px] text-zinc-400 hover:text-zinc-200 hover:border-white/20 transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined" style={{ fontSize: "15px" }}>download</span>
|
|
Export CSV
|
|
</button>
|
|
<div className="flex bg-[#141419] border border-white/10 rounded-lg p-0.5">
|
|
{(["timeline", "table"] as const).map((mode) => (
|
|
<button
|
|
key={mode}
|
|
onClick={() => setViewMode(mode)}
|
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[11px] font-semibold uppercase tracking-wider transition-all ${
|
|
viewMode === mode
|
|
? "bg-[#A78BFA] text-[#1a0040]"
|
|
: "text-zinc-500 hover:text-zinc-300"
|
|
}`}
|
|
>
|
|
<span className="material-symbols-outlined" style={{ fontSize: "14px" }}>
|
|
{mode === "timeline" ? "view_timeline" : "table_rows"}
|
|
</span>
|
|
{mode}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-4 gap-4 mb-6 animate-fade-in-up opacity-0 delay-100">
|
|
{[
|
|
{ label: "Total Signals", value: total, icon: "sensors", color: "#A78BFA" },
|
|
{ label: "Critical", value: criticalCount, icon: "error", color: "#EF4444" },
|
|
{ label: "High Priority", value: highCount, icon: "warning", color: "#F97316" },
|
|
{ label: "Active Groups", value: uniqueGroups, icon: "group", color: "#60A5FA" },
|
|
].map((s) => (
|
|
<div key={s.label} className="neon-card-gradient rounded-xl p-4 border border-white/5 flex items-center gap-4">
|
|
<div className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0" style={{ background: `${s.color}18` }}>
|
|
<span className="material-symbols-outlined" style={{ fontSize: "18px", color: s.color }}>{s.icon}</span>
|
|
</div>
|
|
<div>
|
|
<p className="text-2xl font-bold text-white">{s.value}</p>
|
|
<p className="text-[10px] text-zinc-500 uppercase tracking-wider">{s.label}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="neon-card-gradient rounded-xl border border-white/5 p-4 mb-5 animate-fade-in-up opacity-0 delay-200">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className="material-symbols-outlined text-zinc-500" style={{ fontSize: "16px" }}>filter_alt</span>
|
|
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-wider">Filters</span>
|
|
{activeFilters > 0 && (
|
|
<span className="text-[9px] font-bold text-[#A78BFA] bg-[rgba(167,139,250,0.15)] px-2 py-0.5 rounded-full">
|
|
{activeFilters} active
|
|
</span>
|
|
)}
|
|
</div>
|
|
{activeFilters > 0 && (
|
|
<button
|
|
onClick={() => { setFilterGroup(""); setFilterSeverity(""); setFilterLens(""); setFilterType(""); setDateFrom(""); setDateTo(""); setSearch(""); }}
|
|
className="text-[10px] text-zinc-500 hover:text-zinc-300 uppercase tracking-wider flex items-center gap-1"
|
|
>
|
|
<span className="material-symbols-outlined" style={{ fontSize: "13px" }}>filter_alt_off</span>
|
|
Clear all
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
{/* Search */}
|
|
<div className="relative">
|
|
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" style={{ fontSize: "14px" }}>search</span>
|
|
<input
|
|
type="text"
|
|
placeholder="Search signals..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="bg-[#0C0C0E] border border-white/10 rounded-lg pl-8 pr-3 py-2 text-[12px] text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-[#A78BFA]/50 w-44"
|
|
/>
|
|
</div>
|
|
|
|
{/* Group */}
|
|
<select
|
|
value={filterGroup}
|
|
onChange={(e) => setFilterGroup(e.target.value)}
|
|
className="bg-[#0C0C0E] border border-white/10 rounded-lg px-3 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
|
|
>
|
|
<option value="">All Groups</option>
|
|
{groups.map((g) => (
|
|
<option key={g.group_id} value={g.group_id}>{g.group_name}</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Severity */}
|
|
<select
|
|
value={filterSeverity}
|
|
onChange={(e) => setFilterSeverity(e.target.value)}
|
|
className="bg-[#0C0C0E] border border-white/10 rounded-lg px-3 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
|
|
>
|
|
<option value="">All Severities</option>
|
|
{SEVERITY_OPTIONS.map((s) => (
|
|
<option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Lens */}
|
|
<select
|
|
value={filterLens}
|
|
onChange={(e) => setFilterLens(e.target.value)}
|
|
className="bg-[#0C0C0E] border border-white/10 rounded-lg px-3 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
|
|
>
|
|
<option value="">All Lenses</option>
|
|
{LENS_OPTIONS.map((l) => (
|
|
<option key={l} value={l}>{l.charAt(0).toUpperCase() + l.slice(1)}</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Signal type */}
|
|
<select
|
|
value={filterType}
|
|
onChange={(e) => setFilterType(e.target.value)}
|
|
className="bg-[#0C0C0E] border border-white/10 rounded-lg px-3 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
|
|
>
|
|
<option value="">All Types</option>
|
|
{SIGNAL_TYPE_OPTIONS.map((t) => (
|
|
<option key={t} value={t}>{t.replace(/_/g, " ")}</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Date range */}
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] text-zinc-600 uppercase tracking-wider">From</span>
|
|
<input
|
|
type="date"
|
|
value={dateFrom ? dateFrom.slice(0, 10) : ""}
|
|
onChange={(e) => setDateFrom(e.target.value ? new Date(e.target.value).toISOString() : "")}
|
|
className="bg-[#0C0C0E] border border-white/10 rounded-lg px-2 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
|
|
/>
|
|
<span className="text-[10px] text-zinc-600 uppercase tracking-wider">To</span>
|
|
<input
|
|
type="date"
|
|
value={dateTo ? dateTo.slice(0, 10) : ""}
|
|
onChange={(e) => setDateTo(e.target.value ? new Date(e.target.value).toISOString() : "")}
|
|
className="bg-[#0C0C0E] border border-white/10 rounded-lg px-2 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Count line */}
|
|
<div className="flex items-center justify-between mb-4">
|
|
<p className="text-[11px] text-zinc-600">
|
|
Showing <span className="text-zinc-400 font-semibold">{filteredSignals.length}</span>
|
|
{total > filteredSignals.length && ` of ${total}`} signals
|
|
{truncated && <span className="text-amber-500 ml-2">· Showing latest 300 — apply filters to narrow down</span>}
|
|
</p>
|
|
<button onClick={load} className="flex items-center gap-1 text-[11px] text-zinc-500 hover:text-[#A78BFA] transition-colors">
|
|
<span className="material-symbols-outlined" style={{ fontSize: "14px" }}>refresh</span>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
{loading ? (
|
|
<div className="flex flex-col items-center justify-center h-64 text-zinc-600">
|
|
<div className="w-8 h-8 border-2 border-[#A78BFA]/30 border-t-[#A78BFA] rounded-full animate-spin mb-3" />
|
|
<p className="text-[11px] uppercase tracking-wider">Loading signals...</p>
|
|
</div>
|
|
) : viewMode === "timeline" ? (
|
|
<TimelineView
|
|
signals={filteredSignals}
|
|
onRaiseJira={handleRaiseJira}
|
|
raisingId={raisingId}
|
|
raisedIds={raisedIds}
|
|
/>
|
|
) : (
|
|
<div className="neon-card-gradient rounded-2xl border border-white/5 overflow-hidden">
|
|
<TableView
|
|
signals={sortedSignals}
|
|
sortKey={sortKey}
|
|
sortAsc={sortAsc}
|
|
onSort={toggleSort}
|
|
onRaiseJira={handleRaiseJira}
|
|
raisingId={raisingId}
|
|
raisedIds={raisedIds}
|
|
/>
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|