Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Frontend/src/admin/pages/AdminAnalytics.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import StatCard from '../components/StatCard';
import { Card, CardContent } from "../../components/ui/card";
import useAuthStore from "../../store/authStore";
import { formatTimelineDate } from "../../utils/dateUtils";
import AgentLeaderboard from "../../components/AgentLeaderboard";

const COLORS = ['#6366f1', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#a855f7', '#ec4899'];

Expand Down Expand Up @@ -225,6 +226,12 @@ const AdminAnalytics = () => {
/>
</div>

<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
<div className="lg:col-span-12">
<AgentLeaderboard companyId={profile?.company_id} />
</div>
</div>

<div className="grid grid-cols-1 lg:grid-cols-12 gap-10">
{/* Main Content (8 cols) */}
<div className="lg:col-span-8 space-y-10">
Expand Down
7 changes: 7 additions & 0 deletions Frontend/src/admin/pages/AdminDashboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import useWebSocket from "../../hooks/useWebSocket";
import { supabase } from "../../lib/supabaseClient";
import StatCard from "../components/StatCard";
import TicketTable from "../components/TicketTable";
import AgentLeaderboard from "../../components/AgentLeaderboard";

// Inline SVG icon components
const TicketIcon = () => (
Expand Down Expand Up @@ -249,6 +250,12 @@ const AdminDashboard = () => {
</button>
</div>

<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
<div className="lg:col-span-12">
<AgentLeaderboard companyId={profile?.company_id} />
</div>
</div>

<div style={{ background: '#ffffff', borderRadius: '20px', border: '1px solid #fee2e2', boxShadow: '0 2px 16px rgba(0,0,0,0.05)', padding: '24px' }}>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6">
<div>
Expand Down
17 changes: 7 additions & 10 deletions Frontend/src/admin/pages/AdminScorecard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import React, { useState, useEffect, useCallback } from 'react';
import { Users, RefreshCw, TrendingUp, ChevronLeft, Activity, Trophy } from 'lucide-react';
import useAuthStore from '../../store/authStore';
import { API_CONFIG } from '../../config';
import AgentLeaderboard from '../components/AgentLeaderboard';
import AgentScorecard from '../components/AgentScorecard';
import AgentLeaderboard from '../../components/AgentLeaderboard';
import AgentScorecard from '../../components/AgentScorecard';

// ─── Helpers ──────────────────────────────────────────────────────────────────
const BACKEND = API_CONFIG.BACKEND_URL;
Expand Down Expand Up @@ -136,12 +136,9 @@ const AdminScorecard = () => {
{selectedAgent ? (
<div style={{ maxWidth: 640 }}>
<AgentScorecard
agentName={selectedAgent.agent_name}
score={selectedAgent.score}
metrics={selectedAgent.metrics || {}}
coachingTip={selectedAgent.coaching_tip}
sparklineData={selectedAgent.sparkline_data || []}
insufficientData={selectedAgent.insufficient_data}
agentId={selectedAgent.agent_id}
companyId={profile?.company_id}
agentName={selectedAgent.profiles?.full_name || selectedAgent.profiles?.email || 'Agent'}
/>
</div>
) : (
Expand All @@ -162,7 +159,7 @@ const AdminScorecard = () => {
{
id: 'stat-top-agent',
label: 'Top Agent',
value: agents[0]?.agent_name?.split(' ')[0] || '—',
value: agents[0]?.profiles?.full_name?.split(' ')[0] || agents[0]?.agent_name?.split(' ')[0] || '—',
icon: Trophy,
color: '#f59e0b',
bg: '#fefce8'
Expand All @@ -171,7 +168,7 @@ const AdminScorecard = () => {
id: 'stat-team-score',
label: 'Team Score',
value: agents.length
? `${(agents.reduce((s, a) => s + a.score, 0) / agents.length).toFixed(1)}/100`
? `${(agents.reduce((s, a) => s + (a.performance_score ?? a.score ?? 0), 0) / agents.length).toFixed(1)}/100`
: '—',
icon: TrendingUp,
color: '#16a34a',
Expand Down
126 changes: 126 additions & 0 deletions Frontend/src/components/AgentLeaderboard.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { useEffect, useState } from "react";
import { supabase } from "../lib/supabaseClient";

/**
* AgentLeaderboard — admin ranked view of all agents
* Issue #774
*/
export default function AgentLeaderboard({ companyId, onSelectAgent }) {
const [agents, setAgents] = useState([]);
const [loading, setLoading] = useState(true);
const BACKEND = import.meta.env.VITE_BACKEND_URL || "http://localhost:8000";

useEffect(() => {
if (!companyId) {
setLoading(false);
return;
}

let mounted = true;
(async () => {
setLoading(true);
try {
const { data: sessionData } = await supabase.auth.getSession();
const token = sessionData?.session?.access_token || "";
const response = await fetch(
`${BACKEND}/api/scorecard/company/${encodeURIComponent(companyId)}?days=30`,
{ headers: { Authorization: `Bearer ${token}` } }
);
const result = await response.json();
if (mounted && result.success) setAgents(result.agents || []);
} catch (error) {
console.error("[Leaderboard]", error);
} finally {
if (mounted) setLoading(false);
}
})();

return () => {
mounted = false;
};
}, [companyId]);

function rowColor(score) {
if (score >= 75) return "bg-green-50 border-green-100";
if (score >= 50) return "bg-amber-50 border-amber-100";
return "bg-red-50 border-red-100";
}

function scoreBadge(score) {
if (score >= 75) return "bg-green-100 text-green-700";
if (score >= 50) return "bg-amber-100 text-amber-700";
return "bg-red-100 text-red-700";
}

function rankEmoji(index) {
return index === 0 ? "🥇" : index === 1 ? "🥈" : index === 2 ? "🥉" : `#${index + 1}`;
}

if (loading) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-5 animate-pulse">
<div className="h-4 bg-gray-100 rounded w-40 mb-4" />
{[1, 2, 3].map((i) => <div key={i} className="h-10 bg-gray-100 rounded mb-2" />)}
</div>
);
}

if (agents.length === 0) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-5 text-center">
<p className="text-gray-400 text-sm">No agent scorecards yet.</p>
</div>
);
}

return (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="px-5 py-4 border-b border-gray-100">
<h3 className="text-sm font-semibold text-gray-800">🏆 Agent Performance Leaderboard</h3>
<p className="text-xs text-gray-400 mt-0.5">Last 30 days · {agents.length} agents</p>
</div>

<div className="divide-y divide-gray-100">
{agents.map((agent, index) => {
const score = agent.performance_score || 0;
const name = agent.profiles?.full_name || agent.profiles?.email || "Agent";

return (
<div
key={agent.agent_id}
onClick={onSelectAgent ? () => onSelectAgent(agent) : undefined}
className={`flex items-center gap-4 px-5 py-3 border-l-4 ${rowColor(score)} ${onSelectAgent ? "cursor-pointer" : ""}`}
>
<span className="text-lg w-8 text-center flex-shrink-0">{rankEmoji(index)}</span>

<div className="w-8 h-8 rounded-full bg-indigo-100 flex items-center justify-center flex-shrink-0 overflow-hidden">
{agent.profiles?.avatar_url ? (
<img src={agent.profiles.avatar_url} className="w-8 h-8 rounded-full object-cover" alt={name} />
) : (
<span className="text-xs font-bold text-indigo-600">{name[0]?.toUpperCase() || "A"}</span>
)}
</div>

<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-800 truncate">{name}</p>
<p className="text-xs text-gray-400">
{agent.resolution_rate}% resolved · {agent.ticket_volume} tickets · {agent.avg_resolution_hours}h avg
</p>
</div>

<span className={`px-2.5 py-1 rounded-full text-xs font-bold flex-shrink-0 ${scoreBadge(score)}`}>
{score}
</span>
</div>
);
})}
</div>

<div className="px-5 py-3 bg-gray-50 border-t border-gray-100 flex gap-4 text-xs text-gray-400">
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-green-500 inline-block" />≥75 Excellent</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-amber-400 inline-block" />50–74 Good</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-red-500 inline-block" />&lt;50 Needs Improvement</span>
</div>
</div>
);
}
146 changes: 146 additions & 0 deletions Frontend/src/components/AgentScorecard.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { useEffect, useState } from "react";
import { supabase } from "../lib/supabaseClient";

/**
* AgentScorecard — individual agent performance card
* Issue #774
*/
export default function AgentScorecard({ agentId, companyId, agentName }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const BACKEND = import.meta.env.VITE_BACKEND_URL || "http://localhost:8000";

useEffect(() => {
if (!agentId || !companyId) {
setLoading(false);
return;
}

let mounted = true;
(async () => {
setLoading(true);
setError(null);
try {
const { data: sessionData } = await supabase.auth.getSession();
const token = sessionData?.session?.access_token || "";
const response = await fetch(
`${BACKEND}/api/scorecard/agent/${encodeURIComponent(agentId)}?company_id=${encodeURIComponent(companyId)}&days=30`,
{ headers: { Authorization: `Bearer ${token}` } }
);
const result = await response.json();
if (mounted) setData(result);
} catch (fetchError) {
if (mounted) setError("Failed to load scorecard");
} finally {
if (mounted) setLoading(false);
}
})();

return () => {
mounted = false;
};
}, [agentId, companyId]);

if (loading) return <ScorecardSkeleton />;
if (error) return <div className="text-red-500 text-sm">{error}</div>;
if (!data?.has_data) {
return (
<div className="bg-gray-50 border border-gray-200 rounded-xl p-5 text-center">
<p className="text-gray-400 text-sm">📊 Insufficient data — resolve more tickets to see your scorecard.</p>
</div>
);
}

const score = data.performance_score || 0;
const scoreColor = score >= 75 ? "#16a34a" : score >= 50 ? "#d97706" : "#dc2626";
const circumference = 2 * Math.PI * 36;
const strokeDash = (score / 100) * circumference;

const metrics = [
{ label: "Resolution Rate", value: data.resolution_rate, unit: "%", color: "bg-green-500" },
{ label: "SLA Compliance", value: data.sla_compliance, unit: "%", color: "bg-blue-500" },
{ label: "Avg Speed", value: Math.max(0, 100 - (data.avg_resolution_hours || 0) * 4), unit: "%", color: "bg-purple-500" },
{ label: "Ticket Volume", value: Math.min((data.ticket_volume || 0) / 50 * 100, 100), unit: "%", color: "bg-amber-500" },
];

return (
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-5">
<h3 className="text-sm font-semibold text-gray-700">🏆 {agentName || data.agent_name || "My"} Performance Scorecard</h3>

<div className="flex items-center gap-6">
<div className="flex-shrink-0 relative w-24 h-24">
<svg width="96" height="96" viewBox="0 0 96 96">
<circle cx="48" cy="48" r="36" fill="none" stroke="#f3f4f6" strokeWidth="8" />
<circle
cx="48" cy="48" r="36" fill="none"
stroke={scoreColor} strokeWidth="8"
strokeDasharray={`${strokeDash} ${circumference}`}
strokeLinecap="round"
transform="rotate(-90 48 48)"
style={{ transition: "stroke-dasharray 1s ease" }}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-xl font-bold" style={{ color: scoreColor }}>{score}</span>
<span className="text-xs text-gray-400">/ 100</span>
</div>
</div>

<div className="flex-1 space-y-2">
{metrics.map(({ label, value, color }) => (
<div key={label}>
<div className="flex justify-between text-xs text-gray-500 mb-0.5">
<span>{label}</span>
<span>{Math.round(value)}%</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-1.5">
<div
className={`${color} h-1.5 rounded-full transition-all duration-700`}
style={{ width: `${Math.min(value, 100)}%` }}
/>
</div>
</div>
))}
</div>
</div>

<div className="grid grid-cols-3 gap-3 text-center">
{[
{ label: "Tickets", value: data.ticket_volume || 0 },
{ label: "Resolved", value: data.resolved_tickets || 0 },
{ label: "Avg Time", value: `${data.avg_resolution_hours || 0}h` },
].map(({ label, value }) => (
<div key={label} className="bg-gray-50 rounded-lg p-2">
<p className="text-base font-bold text-gray-800">{value}</p>
<p className="text-xs text-gray-400">{label}</p>
</div>
))}
</div>

{data.ai_coaching_tip && (
<div className="bg-indigo-50 border border-indigo-100 rounded-lg p-3">
<p className="text-xs font-semibold text-indigo-600 mb-1">🤖 AI Coaching Tip</p>
<p className="text-xs text-indigo-700 leading-relaxed italic">
"{data.ai_coaching_tip}"
</p>
</div>
)}
</div>
);
}

function ScorecardSkeleton() {
return (
<div className="bg-white rounded-xl border border-gray-200 p-6 animate-pulse space-y-4">
<div className="h-4 bg-gray-100 rounded w-40" />
<div className="flex items-center gap-6">
<div className="w-24 h-24 rounded-full bg-gray-100" />
<div className="flex-1 space-y-2">
{[1, 2, 3, 4].map((i) => <div key={i} className="h-3 bg-gray-100 rounded" />)}
</div>
</div>
<div className="h-12 bg-gray-100 rounded-lg" />
</div>
);
}
8 changes: 6 additions & 2 deletions Frontend/src/user/pages/Profile.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import useAuthStore from "../../store/authStore";
import useToastStore from "../../store/toastStore";
import { supabase } from "../../lib/supabaseClient";
import BugReportWidget from "../../components/shared/BugReportWidget";
import UserScorecard from "../components/UserScorecard";
import AgentScorecard from "../../components/AgentScorecard";

const Profile = () => {
const navigate = useNavigate();
Expand Down Expand Up @@ -463,7 +463,11 @@ const Profile = () => {
transition={{ delay: 0.25 }}
className="md:col-span-3"
>
<UserScorecard />
<AgentScorecard
agentId={user?.id}
companyId={profile?.company_id}
agentName={profile?.full_name || "You"}
/>
</motion.div>

{/* Settings Section */}
Expand Down
Loading
Loading