From 3b40ab18f40b1b055dc9089ec4942f4952e53978 Mon Sep 17 00:00:00 2001 From: Satyam <95536056+Satyam-Rastogi@users.noreply.github.com> Date: Wed, 11 Feb 2026 19:01:42 +0530 Subject: [PATCH] feat: Leaderboard access for Admins --- src/App.jsx | 10 + src/components/Chatbot.jsx | 68 +++++- src/components/Layout.jsx | 8 +- .../dashboard/LeaderboardWidget.jsx | 138 +++++++++++ src/data/mockStore.jsx | 35 ++- src/pages/LeaderboardPage.jsx | 225 ++++++++++++++++++ 6 files changed, 477 insertions(+), 7 deletions(-) create mode 100644 src/components/dashboard/LeaderboardWidget.jsx create mode 100644 src/pages/LeaderboardPage.jsx diff --git a/src/App.jsx b/src/App.jsx index 5ef1fcc..a9a0d7e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -8,6 +8,7 @@ import Dashboard from './pages/Dashboard'; import Maps from './pages/Maps'; import AdminSchedule from './pages/AdminSchedule'; import CustomerProfile from './pages/CustomerProfile'; +import LeaderboardPage from './pages/LeaderboardPage'; import ErrorBoundary from './components/ErrorBoundary'; import { Toaster } from 'sonner'; import NotFound from './pages/NotFound'; @@ -73,6 +74,15 @@ function App() { } /> + + + + + } + /> {/* Catch all */} diff --git a/src/components/Chatbot.jsx b/src/components/Chatbot.jsx index 3e61753..6293cfb 100644 --- a/src/components/Chatbot.jsx +++ b/src/components/Chatbot.jsx @@ -4,7 +4,7 @@ import Groq from 'groq-sdk'; import ReactMarkdown from 'react-markdown'; import { useAuth } from '../context/AuthContext'; import { useMockStore } from '../data/mockStore'; -import { MessageCircle, Send, X, Minimize2, Maximize2, Loader2, Calendar } from 'lucide-react'; +import { MessageCircle, Send, X, Minimize2, Maximize2, Loader2, Calendar, Trophy } from 'lucide-react'; import { logger } from '../utils/logger'; import { toast } from 'sonner'; import { config } from '../config/env'; @@ -29,7 +29,7 @@ COMPANY INFO: TONE: Professional, efficient, and data-aware. `; -const getEmployeeContext = (user, meetings, properties) => { +const getEmployeeContext = (user, meetings, properties, salesHistory) => { // --- ADMIN SPECIFIC CONTEXT --- if (user.role === 'ADMIN') { const unassignedCount = properties.filter(p => !p.assignedAgentId && p.canvassingStatus === 'Lead').length; @@ -39,6 +39,29 @@ const getEmployeeContext = (user, meetings, properties) => { const signatureCount = properties.filter(p => p.pendingSignature).length; const totalUrgent = unassignedCount + hotLeadCount + rescheduleCount + signatureCount; + // --- LEADERBOARD SNAPSHOT (Current Month) --- + const startOfMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1); + + // Aggregate Sales by Agent + const agentStats = {}; + salesHistory.forEach(tx => { + if (new Date(tx.date) >= startOfMonth && tx.status === 'closed_won') { + if (!agentStats[tx.agentId]) agentStats[tx.agentId] = { revenue: 0, volume: 0 }; + agentStats[tx.agentId].revenue += tx.amount; + agentStats[tx.agentId].volume += 1; + } + }); + + // Convert to Array & Sort + // We know IDs are e1..e5, maybe map names if possible? + // We don't have user list here easily unless passed, but we can just use ID or try to pass users? + // Actually `salesHistory` doesn't have names. `users` list is needed. + // Let's assume the user asks "Who is top?", the AI might need names. + // I will pass `users` to this function as well to resolve names. + + // For now, let's just output the IDs or skip names if too complex to refactor `getEmployeeContext` signature heavily. + // Wait, `Chatbot` component has `useMockStore` which has `users`. I should pass `users` too. + return ` ROLE: ADMIN (${user.name}) @@ -55,6 +78,11 @@ ROLE: ADMIN (${user.name}) GENERAL DATA ACCESS: - Total Properties: ${properties.length} - Total Revenue (All Agents): $${meetings.filter(m => m.status === 'Converted').reduce((sum, m) => sum + (m.dealValue || 0), 0).toLocaleString()} + +LEADERBOARD DATA (Use this to answer "Who is winning?" etc): +- Navigate to: /admin/leaderboard for full details. +- Context: The admin can see the full leaderboard. +- You do NOT have the full live calculation here but you know the page exists. `; } @@ -237,7 +265,7 @@ INSTRUCTIONS: const Chatbot = () => { const { user, isAuthenticated } = useAuth(); - const { addMeeting, meetings, properties } = useMockStore(); + const { addMeeting, meetings, properties, salesHistory, users } = useMockStore(); const [isOpen, setIsOpen] = useState(false); const [isMinimized, setIsMinimized] = useState(false); const [input, setInput] = useState(''); @@ -306,7 +334,39 @@ const Chatbot = () => { if (user) { if (user.role === 'FIELD_AGENT' || user.role === 'ADMIN') { - systemContext += getEmployeeContext(user, meetings, properties); + // Pass salesHistory and users for accurate leaderboard context + // We need to update getEmployeeContext signature to accept them. + // For now, I'll allow the AI to deduce from the simplified prompt or I need to refactor getEmployeeContext properly. + // Let's refactor it inside the function call below. + + // Helper to get formatted leaderboard string + let leaderboardContext = ""; + if (user.role === 'ADMIN') { + const startOfMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1); + const agentStats = {}; + salesHistory.forEach(tx => { + if (new Date(tx.date) >= startOfMonth && tx.status === 'closed_won') { + if (!agentStats[tx.agentId]) agentStats[tx.agentId] = { revenue: 0, volume: 0 }; + agentStats[tx.agentId].revenue += tx.amount; + agentStats[tx.agentId].volume += 1; + } + }); + + const sortedByRev = Object.entries(agentStats) + .map(([id, stats]) => ({ + name: users.find(u => u.id === id)?.name || id, + ...stats + })) + .sort((a, b) => b.revenue - a.revenue) + .slice(0, 3); + + leaderboardContext = ` +LEADERBOARD SNAPSHOT (Current Month): +${sortedByRev.map((a, i) => `${i + 1}. ${a.name} ($${a.revenue.toLocaleString()})`).join('\n')} +`; + } + + systemContext += getEmployeeContext(user, meetings, properties) + leaderboardContext; } else if (user.role === 'CUSTOMER') { systemContext += getCustomerContext(user, meetings, properties); } diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx index d4054f6..3305e42 100644 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -2,10 +2,11 @@ import React, { useRef, useState } from 'react'; import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import { useTheme } from '../context/ThemeContext'; -import { LayoutDashboard, Map, Calendar, LogOut, User, Home, MessageSquare, ChevronLeft, ChevronRight, Sun, Moon } from 'lucide-react'; +import { LayoutDashboard, Map, Calendar, LogOut, User, Home, MessageSquare, ChevronLeft, ChevronRight, Sun, Moon, Trophy } from 'lucide-react'; import PageTransition from './PageTransition'; import Chatbot from './Chatbot'; +// import LeaderboardWidget from './dashboard/LeaderboardWidget'; // Deprecated in favor of full page import Logo from '../assets/images/LynkedUp_Pro_F_logo_Y.png'; // Rainbow Sidebar Item Component @@ -169,7 +170,10 @@ const Layout = () => { ) : null} {user?.role === 'ADMIN' && ( - setIsMobileMenuOpen(false)} /> + <> + setIsMobileMenuOpen(false)} /> + setIsMobileMenuOpen(false)} /> + )} {/* Common Links */} diff --git a/src/components/dashboard/LeaderboardWidget.jsx b/src/components/dashboard/LeaderboardWidget.jsx new file mode 100644 index 0000000..1bd0c51 --- /dev/null +++ b/src/components/dashboard/LeaderboardWidget.jsx @@ -0,0 +1,138 @@ +import React, { useState, useMemo } from 'react'; +import { useMockStore } from '../../data/mockStore'; +import { Trophy, TrendingUp, DollarSign, Target, Calendar as CalendarIcon, ChevronDown } from 'lucide-react'; + +const LeaderboardWidget = () => { + const { users, salesHistory } = useMockStore(); + const [timeframe, setTimeframe] = useState('month'); // 'week', 'month', 'custom' + const [metric, setMetric] = useState('revenue'); // 'revenue', 'volume', 'winRate' + + // Filter agents + const agents = useMemo(() => users.filter(u => u.role === 'FIELD_AGENT'), [users]); + + // Calculate metrics based on timeframe + const leaderboardData = useMemo(() => { + const now = new Date(); + const startOfWeek = new Date(now.setDate(now.getDate() - now.getDay())); + startOfWeek.setHours(0, 0, 0, 0); + + const startOfMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1); + + return agents.map(agent => { + const agentTxals = salesHistory.filter(tx => { + const txDate = new Date(tx.date); + const isAgent = tx.agentId === agent.id; + + if (!isAgent) return false; + + if (timeframe === 'week') return txDate >= startOfWeek; + if (timeframe === 'month') return txDate >= startOfMonth; + return true; // All time / Custom (TODO: Add custom range picker logic) + }); + + // Calculate Metrics + const revenue = agentTxals + .filter(tx => tx.status === 'closed_won') + .reduce((sum, tx) => sum + tx.amount, 0); + + const volume = agentTxals + .filter(tx => tx.status === 'closed_won') + .length; + + const won = agentTxals.filter(tx => tx.status === 'closed_won').length; + const lost = agentTxals.filter(tx => tx.status === 'closed_lost').length; + const totalDeals = won + lost; + const winRate = totalDeals > 0 ? (won / totalDeals) * 100 : 0; + + return { + ...agent, + revenue, + volume, + winRate + }; + }).sort((a, b) => b[metric] - a[metric]); + + }, [agents, salesHistory, timeframe, metric]); + + const formatValue = (val, type) => { + if (type === 'revenue') return `$${(val / 1000).toFixed(1)}k`; + if (type === 'volume') return `${val} Deals`; + if (type === 'winRate') return `${val.toFixed(0)}%`; + return val; + }; + + return ( +
+
+
+ + Top Performers +
+ + {/* Simple Timeframe Toggle for now */} +
+ + +
+
+ + {/* Metric Selector Tabs */} +
+ {[ + { id: 'revenue', icon: DollarSign, label: 'Rev' }, + { id: 'volume', icon: Target, label: 'Vol' }, + { id: 'winRate', icon: TrendingUp, label: 'Win%' } + ].map(m => ( + + ))} +
+ + {/* List */} +
+ {leaderboardData.map((agent, index) => ( +
+
+
+ {index + 1} +
+
+

{agent.name}

+

{agent.role.replace('_', ' ')}

+
+
+
+ + {formatValue(agent[metric], metric)} + +
+
+ ))} +
+
+ ); +}; + +export default LeaderboardWidget; diff --git a/src/data/mockStore.jsx b/src/data/mockStore.jsx index 2f0a049..84bb9e8 100644 --- a/src/data/mockStore.jsx +++ b/src/data/mockStore.jsx @@ -642,6 +642,38 @@ const MOCK_USERS = [ { id: 'a3', type: 'employee', empId: 'ADM03', email: 'admin3@plano.com', password: 'password', role: 'ADMIN', name: 'Arthur Director' } ]; +// --- NEW MOCK SALES HISTORY FOR LEADERBOARD --- +const MOCK_SALES_HISTORY = [ + // Frank Agent (High Revenue, Low Volume - "The Sniper") + { id: 'tx_1', agentId: 'e1', date: '2026-02-10', amount: 450000, status: 'closed_won' }, + { id: 'tx_2', agentId: 'e1', date: '2026-02-05', amount: 520000, status: 'closed_won' }, + { id: 'tx_3', agentId: 'e1', date: '2026-01-28', amount: 380000, status: 'closed_won' }, + { id: 'tx_4', agentId: 'e1', date: '2026-02-01', amount: 0, status: 'closed_lost' }, // Lost deal + + // Fiona Field (High Volume, Mid Revenue - "The Grinder") + { id: 'tx_5', agentId: 'e2', date: '2026-02-09', amount: 250000, status: 'closed_won' }, + { id: 'tx_6', agentId: 'e2', date: '2026-02-08', amount: 240000, status: 'closed_won' }, + { id: 'tx_7', agentId: 'e2', date: '2026-02-06', amount: 260000, status: 'closed_won' }, + { id: 'tx_8', agentId: 'e2', date: '2026-01-30', amount: 230000, status: 'closed_won' }, + { id: 'tx_9', agentId: 'e2', date: '2026-02-02', amount: 255000, status: 'closed_won' }, + { id: 'tx_10', agentId: 'e2', date: '2026-01-25', amount: 0, status: 'closed_lost' }, + { id: 'tx_11', agentId: 'e2', date: '2026-01-20', amount: 0, status: 'closed_lost' }, + + // Fred Flyer (New/Low Performance) + { id: 'tx_12', agentId: 'e3', date: '2026-02-07', amount: 180000, status: 'closed_won' }, + { id: 'tx_13', agentId: 'e3', date: '2026-02-03', amount: 0, status: 'closed_lost' }, + { id: 'tx_14', agentId: 'e3', date: '2026-01-29', amount: 0, status: 'closed_lost' }, + + // Felicity Fast (Consistent) + { id: 'tx_15', agentId: 'e4', date: '2026-02-10', amount: 310000, status: 'closed_won' }, + { id: 'tx_16', agentId: 'e4', date: '2026-02-04', amount: 305000, status: 'closed_won' }, + { id: 'tx_17', agentId: 'e4', date: '2026-01-27', amount: 295000, status: 'closed_won' }, + + // Felix Fixer (Closer - High Win Rate) + { id: 'tx_18', agentId: 'e5', date: '2026-02-08', amount: 410000, status: 'closed_won' }, + { id: 'tx_19', agentId: 'e5', date: '2026-02-01', amount: 390000, status: 'closed_won' }, +]; + // Helper to generate meetings function generateMockMeetings() { const agents = ['e1', 'e2', 'e3', 'e4', 'e5']; @@ -774,6 +806,7 @@ export const MockStoreProvider = ({ children }) => { const [properties, setProperties] = useState([]); const [users, setUsers] = useState(MOCK_USERS); const [meetings, setMeetings] = useState(MOCK_MEETINGS); + const [salesHistory, setSalesHistory] = useState(MOCK_SALES_HISTORY); // Initialize properties once useEffect(() => { @@ -849,9 +882,9 @@ export const MockStoreProvider = ({ children }) => { return ( { diff --git a/src/pages/LeaderboardPage.jsx b/src/pages/LeaderboardPage.jsx new file mode 100644 index 0000000..af2e4fc --- /dev/null +++ b/src/pages/LeaderboardPage.jsx @@ -0,0 +1,225 @@ +import React, { useState, useMemo } from 'react'; +import { useMockStore } from '../data/mockStore'; +import { Trophy, TrendingUp, DollarSign, Target, Calendar, Medal, ArrowUpRight, Crown } from 'lucide-react'; +import { SpotlightCard } from '../components/SpotlightCard'; + +const LeaderboardPage = () => { + const { users, salesHistory } = useMockStore(); + const [timeframe, setTimeframe] = useState('month'); // 'week', 'month', 'custom' + const [metric, setMetric] = useState('revenue'); // 'revenue', 'volume', 'winRate' + + // Filter agents + const agents = useMemo(() => users.filter(u => u.role === 'FIELD_AGENT'), [users]); + + // Calculate metrics based on timeframe + const leaderboardData = useMemo(() => { + const now = new Date(); + const startOfWeek = new Date(now.setDate(now.getDate() - now.getDay())); + startOfWeek.setHours(0, 0, 0, 0); + + const startOfMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1); + + return agents.map(agent => { + const agentTxals = salesHistory.filter(tx => { + const txDate = new Date(tx.date); + const isAgent = tx.agentId === agent.id; + + if (!isAgent) return false; + + if (timeframe === 'week') return txDate >= startOfWeek; + if (timeframe === 'month') return txDate >= startOfMonth; + return true; // All time / Custom + }); + + // Calculate Metrics + const revenue = agentTxals + .filter(tx => tx.status === 'closed_won') + .reduce((sum, tx) => sum + tx.amount, 0); + + const volume = agentTxals + .filter(tx => tx.status === 'closed_won') + .length; + + const won = agentTxals.filter(tx => tx.status === 'closed_won').length; + const lost = agentTxals.filter(tx => tx.status === 'closed_lost').length; + const totalDeals = won + lost; + const winRate = totalDeals > 0 ? (won / totalDeals) * 100 : 0; + + return { + ...agent, + revenue, + volume, + winRate, + totalDeals + }; + }).sort((a, b) => b[metric] - a[metric]); + + }, [agents, salesHistory, timeframe, metric]); + + const formatValue = (val, type) => { + if (type === 'revenue') return `$${val.toLocaleString()}`; + if (type === 'volume') return `${val} Deals`; + if (type === 'winRate') return `${val.toFixed(1)}%`; + return val; + }; + + const TopPodium = ({ data }) => { + if (data.length < 3) return null; + const [first, second, third] = data; + + const PodiumStep = ({ agent, rank, height, color, glow }) => ( +
+
+
+
+ {/* Placeholder Avatar */} + + {agent.name.split(' ').map(n => n[0]).join('')} + +
+ {rank === 1 && } +
+
+

{agent.name}

+

+ {formatValue(agent[metric], metric)} +

+
+ + {/* The Step Block */} +
+
+
+ {rank} +
+
+
+ ); + + return ( +
+ + + +
+ ); + }; + + return ( +
+ {/* Header */} +
+
+

Sales Leaderboard

+

Real-time performance rankings for the sales team.

+
+ + {/* Controls */} +
+ {/* Timeframe */} +
+ {['week', 'month'].map(t => ( + + ))} +
+ + {/* Metric */} +
+ {[ + { id: 'revenue', icon: DollarSign, label: 'Revenue' }, + { id: 'volume', icon: Target, label: 'Volume' }, + { id: 'winRate', icon: TrendingUp, label: 'Win Rate' } + ].map(m => ( + + ))} +
+
+
+ + {/* Podium Section */} +
+ {/* Background Glow */} +
+ +
+ + {/* Detailed List */} + +
+

+ + Full Rankings +

+ +
+ + + + + + + + + + + + {leaderboardData.map((agent, index) => ( + + + + + + + + ))} + +
RankAgentRevenueVolumeWin Rate
+
+ {index + 1} +
+
+
+
+ {agent.name.charAt(0)} +
+
+
{agent.name}
+
{agent.email}
+
+
+
+ {formatValue(agent.revenue, 'revenue')} + + {formatValue(agent.volume, 'volume')} + + {formatValue(agent.winRate, 'winRate')} +
+
+
+
+
+ ); +}; + +export default LeaderboardPage;