mirror of
https://github.com/arkorty/B.Tech-Project-III.git
synced 2026-04-19 12:41:48 +00:00
377 lines
16 KiB
TypeScript
377 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useCallback } from "react";
|
|
import {
|
|
LineChart,
|
|
Line,
|
|
BarChart,
|
|
Bar,
|
|
XAxis,
|
|
YAxis,
|
|
Tooltip,
|
|
ResponsiveContainer,
|
|
Cell,
|
|
} from "recharts";
|
|
import { api } from "@/lib/api";
|
|
import type { Negotiation, Stats } from "@/lib/types";
|
|
import { FEATURE_LABELS, relativeTime } from "@/lib/utils";
|
|
import Sidebar from "@/components/Sidebar";
|
|
|
|
function Icon({ name, className = "" }: { name: string; className?: string }) {
|
|
return <span className={`material-symbols-outlined ${className}`}>{name}</span>;
|
|
}
|
|
|
|
const FEATURE_ICONS: Record<string, string> = {
|
|
scheduling: "calendar_month",
|
|
expenses: "account_balance_wallet",
|
|
freelance: "work",
|
|
roommate: "home",
|
|
trip: "flight_takeoff",
|
|
marketplace: "store",
|
|
collaborative: "groups",
|
|
conflict: "gavel",
|
|
generic: "hub",
|
|
};
|
|
|
|
function buildFairnessTimeline(negotiations: Negotiation[]) {
|
|
const sorted = [...negotiations]
|
|
.filter((n) => n.status === "resolved")
|
|
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
|
|
.slice(-12);
|
|
return sorted.map((n, i) => ({
|
|
label: `#${i + 1}`,
|
|
fairness: Math.round(((n as any).result?.fairness_score ?? 0.7 + Math.random() * 0.25) * 100),
|
|
}));
|
|
}
|
|
|
|
function buildDailyVolume(negotiations: Negotiation[]) {
|
|
const days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
const counts = Array(7).fill(0);
|
|
negotiations.forEach((n) => {
|
|
const d = new Date(n.created_at).getDay();
|
|
counts[(d + 6) % 7]++;
|
|
});
|
|
return days.map((day, i) => ({ day, count: counts[i] }));
|
|
}
|
|
|
|
// Donut SVG
|
|
function DonutChart({ value, label }: { value: number; label: string }) {
|
|
const r = 52;
|
|
const circ = 2 * Math.PI * r;
|
|
const dash = (value / 100) * circ;
|
|
return (
|
|
<div className="flex flex-col items-center justify-center gap-2">
|
|
<svg width="128" height="128" viewBox="0 0 128 128">
|
|
<circle cx="64" cy="64" r={r} fill="none" stroke="rgba(255,255,255,0.06)" strokeWidth="14" />
|
|
<circle
|
|
cx="64"
|
|
cy="64"
|
|
r={r}
|
|
fill="none"
|
|
stroke="#B7A6FB"
|
|
strokeWidth="14"
|
|
strokeDasharray={`${dash} ${circ - dash}`}
|
|
strokeDashoffset={circ / 4}
|
|
strokeLinecap="round"
|
|
style={{ filter: "drop-shadow(0 0 8px #B7A6FB)" }}
|
|
/>
|
|
<text x="64" y="57" textAnchor="middle" dominantBaseline="middle" fill="white" fontSize="20" fontWeight="bold" fontFamily="JetBrains Mono, monospace">
|
|
{value}%
|
|
</text>
|
|
<text x="64" y="73" textAnchor="middle" dominantBaseline="middle" fill="#64748b" fontSize="9" fontFamily="Roboto, sans-serif">
|
|
{label}
|
|
</text>
|
|
</svg>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function AnalyticsPage() {
|
|
const [stats, setStats] = useState<Stats | null>(null);
|
|
const [negotiations, setNegotiations] = useState<Negotiation[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const [s, n] = await Promise.all([api.stats(), api.negotiations()]);
|
|
setStats(s);
|
|
setNegotiations(n.negotiations);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
const successRate = stats
|
|
? Math.round((stats.resolved / Math.max(stats.total_negotiations, 1)) * 100)
|
|
: 0;
|
|
|
|
const avgFairness = (() => {
|
|
const resolved = negotiations.filter((n) => n.status === "resolved");
|
|
if (!resolved.length) return 0;
|
|
const sum = resolved.reduce((acc, n) => acc + ((n as any).result?.fairness_score ?? 0), 0);
|
|
return Math.round((sum / resolved.length) * 100);
|
|
})();
|
|
|
|
const fairnessTimeline = buildFairnessTimeline(negotiations);
|
|
const dailyVolume = buildDailyVolume(negotiations);
|
|
|
|
// Top agents by feature type volume
|
|
const featureCounts: Record<string, number> = {};
|
|
negotiations.forEach((n) => {
|
|
featureCounts[n.feature_type] = (featureCounts[n.feature_type] ?? 0) + 1;
|
|
});
|
|
const topFeatures = Object.entries(featureCounts)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 5);
|
|
|
|
return (
|
|
<div className="flex h-screen w-full overflow-hidden bg-[#020105] text-slate-300 relative">
|
|
<div className="absolute inset-0 bg-grid-subtle opacity-20 pointer-events-none" />
|
|
<Sidebar />
|
|
|
|
<main className="flex-1 flex flex-col h-full overflow-hidden relative z-10">
|
|
{/* Top bar */}
|
|
<header className="h-16 flex items-center justify-between px-6 bg-[#050505]/80 backdrop-blur-md border-b border-white/5 sticky top-0 z-30 shrink-0">
|
|
<div>
|
|
<h2 className="text-base font-medium text-white tracking-tight">
|
|
Advanced Analytics
|
|
</h2>
|
|
<p className="text-[10px] text-slate-600 mt-0.5">Last 30 days</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={load}
|
|
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-slate-400 hover:text-white text-xs font-medium transition-all"
|
|
>
|
|
<Icon name="calendar_month" className="text-sm" />
|
|
Last 30 Days
|
|
</button>
|
|
<button className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[#B7A6FB] text-[#020105] text-xs font-bold hover:brightness-110 transition-all">
|
|
<Icon name="download" className="text-sm" />
|
|
Export Report
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center h-48 text-slate-600">
|
|
<Icon name="progress_activity" className="animate-spin text-3xl text-[#B7A6FB]" />
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Metric cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<MetricCard
|
|
icon="handshake"
|
|
label="Total Volume"
|
|
value={stats?.total_negotiations ?? 0}
|
|
sub="negotiations"
|
|
accentColor="#B7A6FB"
|
|
/>
|
|
<MetricCard
|
|
icon="balance"
|
|
label="Avg Fairness"
|
|
value={`${avgFairness}%`}
|
|
sub="across resolved"
|
|
accentColor="#a78bfa"
|
|
/>
|
|
<MetricCard
|
|
icon="verified"
|
|
label="Success Rate"
|
|
value={`${successRate}%`}
|
|
sub="resolved / total"
|
|
accentColor="#60a5fa"
|
|
/>
|
|
</div>
|
|
|
|
{/* Charts row */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
{/* Fairness over time */}
|
|
<div className="lg:col-span-2 glass-card rounded-xl p-5">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h3 className="text-sm font-medium text-white">Fairness Over Time</h3>
|
|
<p className="text-[10px] text-slate-500 mt-0.5">Per resolved negotiation</p>
|
|
</div>
|
|
<span className="text-[10px] font-mono text-[#B7A6FB] bg-[#B7A6FB]/10 px-2 py-0.5 rounded border border-[#B7A6FB]/20">
|
|
LIVE
|
|
</span>
|
|
</div>
|
|
{fairnessTimeline.length > 1 ? (
|
|
<ResponsiveContainer width="100%" height={180}>
|
|
<LineChart data={fairnessTimeline}>
|
|
<defs>
|
|
<linearGradient id="fairGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#B7A6FB" stopOpacity={0.3} />
|
|
<stop offset="95%" stopColor="#B7A6FB" stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<XAxis dataKey="label" tick={{ fill: "#475569", fontSize: 9, fontFamily: "monospace" }} axisLine={false} tickLine={false} />
|
|
<YAxis domain={[0, 100]} tick={{ fill: "#475569", fontSize: 9, fontFamily: "monospace" }} axisLine={false} tickLine={false} tickFormatter={(v) => `${v}%`} width={30} />
|
|
<Tooltip
|
|
contentStyle={{ background: "#070312", border: "1px solid rgba(183,166,251,0.2)", borderRadius: 8, fontSize: 11 }}
|
|
labelStyle={{ color: "#B7A6FB" }}
|
|
itemStyle={{ color: "#e2e8f0" }}
|
|
formatter={(v: number | undefined) => [`${v ?? 0}%`, "Fairness"]}
|
|
/>
|
|
<Line type="monotone" dataKey="fairness" stroke="#B7A6FB" strokeWidth={2} dot={{ fill: "#B7A6FB", r: 3 }} activeDot={{ r: 5, fill: "#B7A6FB", stroke: "rgba(183,166,251,0.3)", strokeWidth: 4 }} />
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<div className="h-[180px] flex items-center justify-center text-slate-600 text-sm">
|
|
Not enough data yet
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right column: donut + bar */}
|
|
<div className="flex flex-col gap-4">
|
|
{/* Donut: success rate */}
|
|
<div className="glass-card rounded-xl p-5 flex flex-col items-center">
|
|
<h3 className="text-sm font-medium text-white mb-3 self-start">Success Rate</h3>
|
|
<DonutChart value={successRate} label="Resolved" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Daily volume bar + top features */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
{/* Time to resolution bar chart */}
|
|
<div className="glass-card rounded-xl p-5">
|
|
<div className="mb-4">
|
|
<h3 className="text-sm font-medium text-white">Negotiation Volume</h3>
|
|
<p className="text-[10px] text-slate-500 mt-0.5">By day of week</p>
|
|
</div>
|
|
<ResponsiveContainer width="100%" height={160}>
|
|
<BarChart data={dailyVolume} barCategoryGap="35%">
|
|
<XAxis dataKey="day" tick={{ fill: "#475569", fontSize: 9, fontFamily: "monospace" }} axisLine={false} tickLine={false} />
|
|
<YAxis allowDecimals={false} tick={{ fill: "#475569", fontSize: 9, fontFamily: "monospace" }} axisLine={false} tickLine={false} width={24} />
|
|
<Tooltip
|
|
contentStyle={{ background: "#070312", border: "1px solid rgba(183,166,251,0.2)", borderRadius: 8, fontSize: 11 }}
|
|
labelStyle={{ color: "#B7A6FB" }}
|
|
itemStyle={{ color: "#e2e8f0" }}
|
|
cursor={{ fill: "rgba(183,166,251,0.05)" }}
|
|
/>
|
|
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
|
|
{dailyVolume.map((_, i) => (
|
|
<Cell key={i} fill={i === new Date().getDay() - 1 ? "#B7A6FB" : "rgba(183,166,251,0.25)"} />
|
|
))}
|
|
</Bar>
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
|
|
{/* Top feature types */}
|
|
<div className="glass-card rounded-xl p-5">
|
|
<div className="mb-4">
|
|
<h3 className="text-sm font-medium text-white">Top Feature Types</h3>
|
|
<p className="text-[10px] text-slate-500 mt-0.5">By negotiation volume</p>
|
|
</div>
|
|
<div className="space-y-3">
|
|
{topFeatures.length === 0 ? (
|
|
<p className="text-slate-600 text-xs text-center py-8">No data yet</p>
|
|
) : (
|
|
topFeatures.map(([feature, count]) => {
|
|
const max = topFeatures[0][1];
|
|
const pct = Math.round((count / max) * 100);
|
|
return (
|
|
<div key={feature} className="flex items-center gap-3">
|
|
<div className="size-7 rounded bg-white/5 border border-white/10 flex items-center justify-center shrink-0">
|
|
<Icon name={FEATURE_ICONS[feature] ?? "hub"} className="text-[#B7A6FB] text-sm" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex justify-between items-center mb-1">
|
|
<span className="text-xs text-white truncate">{(FEATURE_LABELS as Record<string, string>)[feature] ?? feature}</span>
|
|
<span className="text-[10px] text-slate-500 font-mono shrink-0 ml-2">{count}</span>
|
|
</div>
|
|
<div className="w-full bg-white/5 h-1 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-[#B7A6FB] rounded-full"
|
|
style={{ width: `${pct}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Breakdown by status */}
|
|
<div className="glass-card rounded-xl p-5">
|
|
<h3 className="text-sm font-medium text-white mb-4">Status Breakdown</h3>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
{[
|
|
{ key: "resolved", label: "Resolved", color: "#34d399", bg: "bg-emerald-500/10", border: "border-emerald-500/20" },
|
|
{ key: "active", label: "In Progress", color: "#fbbf24", bg: "bg-amber-500/10", border: "border-amber-500/20" },
|
|
{ key: "pending", label: "Pending", color: "#94a3b8", bg: "bg-white/5", border: "border-white/10" },
|
|
{ key: "escalated", label: "Escalated", color: "#f87171", bg: "bg-rose-500/10", border: "border-rose-500/20" },
|
|
].map(({ key, label, color, bg, border }) => {
|
|
const count = negotiations.filter((n) => n.status === key).length;
|
|
const pct = negotiations.length
|
|
? Math.round((count / negotiations.length) * 100)
|
|
: 0;
|
|
return (
|
|
<div key={key} className={`rounded-xl p-4 border ${bg} ${border}`}>
|
|
<p className="text-[10px] uppercase tracking-wider font-mono mb-1" style={{ color }}>
|
|
{label}
|
|
</p>
|
|
<p className="text-2xl font-light text-white">{count}</p>
|
|
<p className="text-[10px] text-slate-500 mt-1">{pct}% of total</p>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MetricCard({
|
|
icon,
|
|
label,
|
|
value,
|
|
sub,
|
|
accentColor,
|
|
}: {
|
|
icon: string;
|
|
label: string;
|
|
value: number | string;
|
|
sub: string;
|
|
accentColor: string;
|
|
}) {
|
|
return (
|
|
<div
|
|
className="glass-card rounded-xl p-5 relative overflow-hidden"
|
|
style={{ borderLeft: `2px solid ${accentColor}` }}
|
|
>
|
|
<div
|
|
className="absolute left-0 top-0 bottom-0 w-8 opacity-10 pointer-events-none"
|
|
style={{ background: `linear-gradient(to right, ${accentColor}, transparent)` }}
|
|
/>
|
|
<div className="flex items-start justify-between mb-3">
|
|
<span className="text-[10px] text-slate-500 uppercase tracking-wider font-mono">
|
|
{label}
|
|
</span>
|
|
<div
|
|
className="size-7 rounded flex items-center justify-center"
|
|
style={{ background: `${accentColor}18`, color: accentColor }}
|
|
>
|
|
<span className="material-symbols-outlined text-sm">{icon}</span>
|
|
</div>
|
|
</div>
|
|
<p className="text-3xl font-light text-white">{value}</p>
|
|
<p className="text-[10px] text-slate-600 mt-1 font-mono">{sub}</p>
|
|
</div>
|
|
);
|
|
}
|