feat: Leaderboard access for Admins
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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' && (
|
||||
<SidebarItem to="/admin/schedule" icon={Calendar} label="Team Schedule" isCollapsed={isCollapsed} onClick={() => setIsMobileMenuOpen(false)} />
|
||||
<>
|
||||
<SidebarItem to="/admin/schedule" icon={Calendar} label="Team Schedule" isCollapsed={isCollapsed} onClick={() => setIsMobileMenuOpen(false)} />
|
||||
<SidebarItem to="/admin/leaderboard" icon={Trophy} label="Leaderboard" isCollapsed={isCollapsed} onClick={() => setIsMobileMenuOpen(false)} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Common Links */}
|
||||
|
||||
@@ -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 (
|
||||
<div className="mt-6 mb-2">
|
||||
<div className="flex items-center justify-between px-4 mb-3">
|
||||
<div className="flex items-center space-x-2 text-zinc-900 dark:text-white">
|
||||
<Trophy size={16} className="text-yellow-500" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider">Top Performers</span>
|
||||
</div>
|
||||
|
||||
{/* Simple Timeframe Toggle for now */}
|
||||
<div className="flex bg-zinc-100 dark:bg-white/5 rounded-lg p-0.5">
|
||||
<button
|
||||
onClick={() => setTimeframe('week')}
|
||||
className={`px-2 py-0.5 text-[10px] font-bold rounded-md transition-all ${timeframe === 'week' ? 'bg-white dark:bg-zinc-800 shadow-sm text-zinc-900 dark:text-white' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-white'}`}
|
||||
>
|
||||
W
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTimeframe('month')}
|
||||
className={`px-2 py-0.5 text-[10px] font-bold rounded-md transition-all ${timeframe === 'month' ? 'bg-white dark:bg-zinc-800 shadow-sm text-zinc-900 dark:text-white' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-white'}`}
|
||||
>
|
||||
M
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metric Selector Tabs */}
|
||||
<div className="flex px-4 mb-4 space-x-1">
|
||||
{[
|
||||
{ id: 'revenue', icon: DollarSign, label: 'Rev' },
|
||||
{ id: 'volume', icon: Target, label: 'Vol' },
|
||||
{ id: 'winRate', icon: TrendingUp, label: 'Win%' }
|
||||
].map(m => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => setMetric(m.id)}
|
||||
className={`flex-1 flex flex-col items-center justify-center py-2 rounded-lg border text-[10px] font-bold transition-all ${metric === m.id
|
||||
? 'bg-blue-50 dark:bg-blue-500/10 border-blue-200 dark:border-blue-500/20 text-blue-600 dark:text-blue-300'
|
||||
: 'bg-transparent border-transparent text-zinc-500 hover:bg-zinc-100 dark:hover:bg-white/5'}`}
|
||||
>
|
||||
<m.icon size={14} className="mb-0.5" />
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="space-y-1 px-2">
|
||||
{leaderboardData.map((agent, index) => (
|
||||
<div key={agent.id} className="flex items-center justify-between p-2 rounded-lg bg-white/40 dark:bg-white/5 border border-zinc-200/50 dark:border-white/5 hover:bg-white/60 dark:hover:bg-white/10 transition-all group">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-5 h-5 flex items-center justify-center rounded-full text-[10px] font-bold ${index === 0 ? 'bg-yellow-100 text-yellow-700 border border-yellow-200' :
|
||||
index === 1 ? 'bg-zinc-100 text-zinc-700 border border-zinc-200' :
|
||||
index === 2 ? 'bg-orange-100 text-orange-700 border border-orange-200' :
|
||||
'bg-transparent text-zinc-400'
|
||||
}`}>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-zinc-900 dark:text-white leading-none mb-0.5">{agent.name}</p>
|
||||
<p className="text-[9px] text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">{agent.role.replace('_', ' ')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-xs font-bold font-mono text-zinc-900 dark:text-white">
|
||||
{formatValue(agent[metric], metric)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LeaderboardWidget;
|
||||
Reference in New Issue
Block a user