262 lines
14 KiB
JavaScript
262 lines
14 KiB
JavaScript
import { useQuery } from '@tanstack/react-query';
|
|
import { useAuth } from '../context/AuthContext';
|
|
import { issues, reports } from '../services/api';
|
|
import { AreaChart, Area, BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts';
|
|
import { TicketCheck, CheckCircle2, GitPullRequest, Target, TrendingUp, TrendingDown, ArrowUpRight, Clock, AlertCircle, Building2 } from 'lucide-react';
|
|
import { cn } from '../lib/utils';
|
|
|
|
const CHART_COLORS = ['#6366f1', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
|
|
|
|
const StatSkeleton = () => (
|
|
<div className="stat-card">
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-2">
|
|
<div className="skeleton h-3 w-20" />
|
|
<div className="skeleton h-8 w-16" />
|
|
</div>
|
|
<div className="skeleton h-10 w-10 rounded-lg" />
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const ChartSkeleton = () => (
|
|
<div className="card">
|
|
<div className="card-header">
|
|
<div className="skeleton h-4 w-32" />
|
|
</div>
|
|
<div className="card-body">
|
|
<div className="skeleton h-56 w-full rounded-lg" />
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const CustomTooltip = ({ active, payload, label }) => {
|
|
if (!active || !payload) return null;
|
|
return (
|
|
<div className="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 shadow-xl text-xs">
|
|
<p className="text-gray-400 mb-1">{label}</p>
|
|
{payload.map((item, i) => (
|
|
<p key={i} className="text-white font-medium">
|
|
<span className="inline-block w-2 h-2 rounded-full mr-1.5" style={{ backgroundColor: item.color }} />
|
|
{item.name}: {item.value}
|
|
</p>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default function Dashboard() {
|
|
const { currentOrg } = useAuth();
|
|
|
|
const { data: stats, isLoading: statsLoading } = useQuery({
|
|
queryKey: ['issues-stats', currentOrg?.id],
|
|
queryFn: () => issues.stats(currentOrg.id),
|
|
enabled: !!currentOrg
|
|
});
|
|
|
|
const { data: report, isLoading: reportLoading } = useQuery({
|
|
queryKey: ['report-summary', currentOrg?.id],
|
|
queryFn: () => reports.summary(currentOrg.id, 14),
|
|
enabled: !!currentOrg
|
|
});
|
|
|
|
if (!currentOrg) {
|
|
return (
|
|
<div className="flex-1 flex items-center justify-center p-8">
|
|
<div className="text-center max-w-md">
|
|
<div className="w-16 h-16 rounded-2xl bg-indigo-600/10 flex items-center justify-center mx-auto mb-4">
|
|
<Building2 size={28} className="text-indigo-400" />
|
|
</div>
|
|
<h2 className="text-xl font-semibold text-white mb-2">Select an organization</h2>
|
|
<p className="text-gray-400 text-sm">Choose an organization from the sidebar to view your dashboard and manage issues.</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const s = stats?.data || {};
|
|
const r = report?.data || {};
|
|
const loading = statsLoading || reportLoading;
|
|
|
|
const statCards = [
|
|
{ label: 'Total Issues', value: s.total || 0, icon: TicketCheck, color: 'text-blue-400', bg: 'bg-blue-500/10', trend: '+12%', up: true },
|
|
{ label: 'Analyzed', value: s.analyzed || 0, icon: CheckCircle2, color: 'text-emerald-400', bg: 'bg-emerald-500/10', trend: '+8%', up: true },
|
|
{ label: 'PRs Created', value: s.pr_created || 0, icon: GitPullRequest, color: 'text-purple-400', bg: 'bg-purple-500/10', trend: '+15%', up: true },
|
|
{ label: 'Avg Confidence', value: s.avg_confidence ? `${(s.avg_confidence * 100).toFixed(0)}%` : 'N/A', icon: Target, color: 'text-amber-400', bg: 'bg-amber-500/10', trend: '+3%', up: true },
|
|
];
|
|
|
|
const statusData = [
|
|
{ name: 'Pending', value: s.pending || 0 },
|
|
{ name: 'Analyzing', value: s.analyzing || 0 },
|
|
{ name: 'Analyzed', value: s.analyzed || 0 },
|
|
{ name: 'PR Created', value: s.pr_created || 0 },
|
|
{ name: 'Error', value: s.error || 0 }
|
|
].filter(d => d.value > 0);
|
|
|
|
const sourceData = Object.entries(s.by_source || {}).map(([name, value]) => ({
|
|
name: name.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()),
|
|
value
|
|
}));
|
|
|
|
return (
|
|
<div className="p-6 animate-fade-in">
|
|
<div className="page-header">
|
|
<div>
|
|
<h1 className="page-title">Dashboard</h1>
|
|
<p className="page-subtitle">Overview of your issue analysis pipeline</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="badge badge-green">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
|
System operational
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
|
{loading ? (
|
|
Array(4).fill(0).map((_, i) => <StatSkeleton key={i} />)
|
|
) : (
|
|
statCards.map(stat => {
|
|
const Icon = stat.icon;
|
|
return (
|
|
<div key={stat.label} className="stat-card">
|
|
<div className="flex items-center justify-between relative z-10">
|
|
<div>
|
|
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide">{stat.label}</p>
|
|
<p className="text-2xl font-bold text-white mt-1">{stat.value}</p>
|
|
<div className={cn("flex items-center gap-1 mt-1.5 text-xs font-medium", stat.up ? "text-emerald-400" : "text-red-400")}>
|
|
{stat.up ? <TrendingUp size={12} /> : <TrendingDown size={12} />}
|
|
{stat.trend}
|
|
<span className="text-gray-500 font-normal ml-0.5">vs last week</span>
|
|
</div>
|
|
</div>
|
|
<div className={cn("w-11 h-11 rounded-xl flex items-center justify-center", stat.bg)}>
|
|
<Icon size={20} className={stat.color} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
{/* Charts row */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
|
|
{loading ? (
|
|
<>
|
|
<ChartSkeleton />
|
|
<ChartSkeleton />
|
|
</>
|
|
) : (
|
|
<>
|
|
{/* Trend chart */}
|
|
<div className="card">
|
|
<div className="card-header">
|
|
<h3 className="text-sm font-semibold">Issues Trend</h3>
|
|
<span className="badge badge-gray text-[10px]">Last 14 days</span>
|
|
</div>
|
|
<div className="card-body">
|
|
<div className="h-56">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<AreaChart data={r.daily_breakdown || []}>
|
|
<defs>
|
|
<linearGradient id="colorTotal" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.3} />
|
|
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
|
|
</linearGradient>
|
|
<linearGradient id="colorAnalyzed" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.3} />
|
|
<stop offset="95%" stopColor="#22c55e" stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#1e1e2a" />
|
|
<XAxis dataKey="date" tick={{ fill: '#5a5a70', fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
<YAxis tick={{ fill: '#5a5a70', fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
<Tooltip content={<CustomTooltip />} />
|
|
<Area type="monotone" dataKey="total" stroke="#6366f1" fill="url(#colorTotal)" strokeWidth={2} name="Total" />
|
|
<Area type="monotone" dataKey="analyzed" stroke="#22c55e" fill="url(#colorAnalyzed)" strokeWidth={2} name="Analyzed" />
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status distribution */}
|
|
<div className="card">
|
|
<div className="card-header">
|
|
<h3 className="text-sm font-semibold">Status Distribution</h3>
|
|
</div>
|
|
<div className="card-body">
|
|
<div className="h-56 flex items-center">
|
|
{statusData.length > 0 ? (
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<PieChart>
|
|
<Pie
|
|
data={statusData}
|
|
cx="50%"
|
|
cy="50%"
|
|
innerRadius={55}
|
|
outerRadius={80}
|
|
paddingAngle={4}
|
|
dataKey="value"
|
|
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
|
>
|
|
{statusData.map((entry, index) => (
|
|
<Cell key={entry.name} fill={CHART_COLORS[index % CHART_COLORS.length]} />
|
|
))}
|
|
</Pie>
|
|
<Tooltip content={<CustomTooltip />} />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<div className="w-full text-center">
|
|
<AlertCircle size={24} className="text-gray-600 mx-auto mb-2" />
|
|
<p className="text-gray-500 text-sm">No data yet</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* By source */}
|
|
{loading ? (
|
|
<ChartSkeleton />
|
|
) : (
|
|
<div className="card">
|
|
<div className="card-header">
|
|
<h3 className="text-sm font-semibold">Issues by Source</h3>
|
|
</div>
|
|
<div className="card-body">
|
|
<div className="h-56">
|
|
{sourceData.length > 0 ? (
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={sourceData} layout="vertical">
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#1e1e2a" horizontal={false} />
|
|
<XAxis type="number" tick={{ fill: '#5a5a70', fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
<YAxis type="category" dataKey="name" tick={{ fill: '#8888a0', fontSize: 12 }} width={100} tickLine={false} axisLine={false} />
|
|
<Tooltip content={<CustomTooltip />} />
|
|
<Bar dataKey="value" fill="#6366f1" radius={[0, 6, 6, 0]} barSize={24} name="Issues" />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<div className="h-full flex items-center justify-center">
|
|
<div className="text-center">
|
|
<AlertCircle size={24} className="text-gray-600 mx-auto mb-2" />
|
|
<p className="text-gray-500 text-sm">No data yet</p>
|
|
<p className="text-gray-600 text-xs mt-1">Connect an integration to start tracking</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|