add leaderboard for canvassar
This commit is contained in:
@@ -106,7 +106,7 @@ const AnimatedNumber = ({
|
|||||||
};
|
};
|
||||||
}, [value, duration, prefix, suffix, decimals, useLocaleString, delay]);
|
}, [value, duration, prefix, suffix, decimals, useLocaleString, delay]);
|
||||||
|
|
||||||
return <span ref={numberRef} className={className}>0</span>;
|
return <span ref={numberRef} className={className}>{value ?? 0}</span>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AnimatedNumber;
|
export default AnimatedNumber;
|
||||||
|
|||||||
+87
-19
@@ -736,33 +736,100 @@ const MOCK_USERS = [
|
|||||||
// Amounts reflect residential storm-damage roofing jobs ($12K–$42K per deal).
|
// Amounts reflect residential storm-damage roofing jobs ($12K–$42K per deal).
|
||||||
const MOCK_SALES_HISTORY = [
|
const MOCK_SALES_HISTORY = [
|
||||||
// Frank Agent (High Revenue, Low Volume - "The Sniper") — fewer deals, bigger jobs
|
// Frank Agent (High Revenue, Low Volume - "The Sniper") — fewer deals, bigger jobs
|
||||||
{ id: 'tx_1', agentId: 'e1', date: '2026-03-19', amount: 38500, status: 'closed_won' }, // this week
|
{ id: 'tx_1', agentId: 'e1', date: '2026-05-14', amount: 38500, status: 'closed_won' }, // this week
|
||||||
{ id: 'tx_2', agentId: 'e1', date: '2026-03-12', amount: 42000, status: 'closed_won' },
|
{ id: 'tx_2', agentId: 'e1', date: '2026-05-12', amount: 42000, status: 'closed_won' }, // this week
|
||||||
{ id: 'tx_3', agentId: 'e1', date: '2026-03-06', amount: 31800, status: 'closed_won' },
|
{ id: 'tx_3', agentId: 'e1', date: '2026-05-06', amount: 31800, status: 'closed_won' },
|
||||||
{ id: 'tx_4', agentId: 'e1', date: '2026-03-03', amount: 0, status: 'closed_lost' },
|
{ id: 'tx_4', agentId: 'e1', date: '2026-05-03', amount: 0, status: 'closed_lost' },
|
||||||
|
|
||||||
// Fiona Field (High Volume, Mid Revenue - "The Grinder") — more deals, consistent value
|
// Fiona Field (High Volume, Mid Revenue - "The Grinder") — more deals, consistent value
|
||||||
{ id: 'tx_5', agentId: 'e2', date: '2026-03-17', amount: 18200, status: 'closed_won' }, // this week
|
{ id: 'tx_5', agentId: 'e2', date: '2026-05-13', amount: 18200, status: 'closed_won' }, // this week
|
||||||
{ id: 'tx_6', agentId: 'e2', date: '2026-03-16', amount: 16800, status: 'closed_won' }, // this week
|
{ id: 'tx_6', agentId: 'e2', date: '2026-05-11', amount: 16800, status: 'closed_won' }, // this week
|
||||||
{ id: 'tx_7', agentId: 'e2', date: '2026-03-11', amount: 21400, status: 'closed_won' },
|
{ id: 'tx_7', agentId: 'e2', date: '2026-05-08', amount: 21400, status: 'closed_won' },
|
||||||
{ id: 'tx_8', agentId: 'e2', date: '2026-03-08', amount: 19600, status: 'closed_won' },
|
{ id: 'tx_8', agentId: 'e2', date: '2026-05-06', amount: 19600, status: 'closed_won' },
|
||||||
{ id: 'tx_9', agentId: 'e2', date: '2026-03-05', amount: 17300, status: 'closed_won' },
|
{ id: 'tx_9', agentId: 'e2', date: '2026-05-05', amount: 17300, status: 'closed_won' },
|
||||||
{ id: 'tx_10', agentId: 'e2', date: '2026-03-02', amount: 0, status: 'closed_lost' },
|
{ id: 'tx_10', agentId: 'e2', date: '2026-05-02', amount: 0, status: 'closed_lost' },
|
||||||
{ id: 'tx_11', agentId: 'e2', date: '2026-03-01', amount: 0, status: 'closed_lost' },
|
{ id: 'tx_11', agentId: 'e2', date: '2026-05-01', amount: 0, status: 'closed_lost' },
|
||||||
|
|
||||||
// Fred Flyer (New/Low Performance) — one close this month, struggling
|
// Fred Flyer (New/Low Performance) — one close this month, struggling
|
||||||
{ id: 'tx_12', agentId: 'e3', date: '2026-03-18', amount: 12400, status: 'closed_won' }, // this week
|
{ id: 'tx_12', agentId: 'e3', date: '2026-05-13', amount: 12400, status: 'closed_won' }, // this week
|
||||||
{ id: 'tx_13', agentId: 'e3', date: '2026-03-09', amount: 0, status: 'closed_lost' },
|
{ id: 'tx_13', agentId: 'e3', date: '2026-05-07', amount: 0, status: 'closed_lost' },
|
||||||
{ id: 'tx_14', agentId: 'e3', date: '2026-03-04', amount: 0, status: 'closed_lost' },
|
{ id: 'tx_14', agentId: 'e3', date: '2026-05-04', amount: 0, status: 'closed_lost' },
|
||||||
|
|
||||||
// Felicity Fast (Consistent) — reliable mid-tier producer
|
// Felicity Fast (Consistent) — reliable mid-tier producer
|
||||||
{ id: 'tx_15', agentId: 'e4', date: '2026-03-15', amount: 24800, status: 'closed_won' }, // this week
|
{ id: 'tx_15', agentId: 'e4', date: '2026-05-12', amount: 24800, status: 'closed_won' }, // this week
|
||||||
{ id: 'tx_16', agentId: 'e4', date: '2026-03-10', amount: 23200, status: 'closed_won' },
|
{ id: 'tx_16', agentId: 'e4', date: '2026-05-08', amount: 23200, status: 'closed_won' },
|
||||||
{ id: 'tx_17', agentId: 'e4', date: '2026-03-07', amount: 26500, status: 'closed_won' },
|
{ id: 'tx_17', agentId: 'e4', date: '2026-05-05', amount: 26500, status: 'closed_won' },
|
||||||
|
|
||||||
// Felix Fixer (Closer - High Win Rate) — doesn't lose, picks his battles
|
// Felix Fixer (Closer - High Win Rate) — doesn't lose, picks his battles
|
||||||
{ id: 'tx_18', agentId: 'e5', date: '2026-03-20', amount: 29500, status: 'closed_won' }, // this week
|
{ id: 'tx_18', agentId: 'e5', date: '2026-05-14', amount: 29500, status: 'closed_won' }, // this week
|
||||||
{ id: 'tx_19', agentId: 'e5', date: '2026-03-11', amount: 34800, status: 'closed_won' },
|
{ id: 'tx_19', agentId: 'e5', date: '2026-05-08', amount: 34800, status: 'closed_won' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- MOCK CANVASSER DOOR-KNOCK HISTORY ---
|
||||||
|
// Each record = one door-knock session / lead attempt by a canvasser.
|
||||||
|
// status: 'verified' | 'unverified' | 'converted'
|
||||||
|
// verified = lead confirmed by team lead / admin
|
||||||
|
// unverified = logged by canvasser, pending review
|
||||||
|
// converted = verified lead that became a paying client
|
||||||
|
// Dates in May 2026 — days 11-14 fall in current week, all in current month.
|
||||||
|
const MOCK_CANVASSER_HISTORY = [
|
||||||
|
// Frank Agent (e1) — solid verifier, good conversion
|
||||||
|
{ id: 'ck_001', agentId: 'e1', date: '2026-05-14', status: 'verified', address: '2612 Dunwick Dr' },
|
||||||
|
{ id: 'ck_002', agentId: 'e1', date: '2026-05-14', status: 'converted', address: '2608 Dunwick Dr' },
|
||||||
|
{ id: 'ck_003', agentId: 'e1', date: '2026-05-13', status: 'verified', address: '2604 Dunwick Dr' },
|
||||||
|
{ id: 'ck_004', agentId: 'e1', date: '2026-05-13', status: 'unverified', address: '2600 Dunwick Dr' },
|
||||||
|
{ id: 'ck_005', agentId: 'e1', date: '2026-05-12', status: 'converted', address: '2516 Dunwick Dr' },
|
||||||
|
{ id: 'ck_006', agentId: 'e1', date: '2026-05-08', status: 'verified', address: '2637 Rothland Ln' },
|
||||||
|
{ id: 'ck_007', agentId: 'e1', date: '2026-05-08', status: 'unverified', address: '2633 Rothland Ln' },
|
||||||
|
{ id: 'ck_008', agentId: 'e1', date: '2026-05-06', status: 'verified', address: '2629 Rothland Ln' },
|
||||||
|
{ id: 'ck_009', agentId: 'e1', date: '2026-05-04', status: 'verified', address: '6613 Phoenix Pl' },
|
||||||
|
{ id: 'ck_010', agentId: 'e1', date: '2026-05-02', status: 'converted', address: '6609 Phoenix Pl' },
|
||||||
|
|
||||||
|
// Fiona Field (e2) — high volume, moderate verification rate
|
||||||
|
{ id: 'ck_011', agentId: 'e2', date: '2026-05-14', status: 'verified', address: '6605 Phoenix Pl' },
|
||||||
|
{ id: 'ck_012', agentId: 'e2', date: '2026-05-14', status: 'unverified', address: '6601 Phoenix Pl' },
|
||||||
|
{ id: 'ck_013', agentId: 'e2', date: '2026-05-13', status: 'converted', address: '6617 Phoenix Pl' },
|
||||||
|
{ id: 'ck_014', agentId: 'e2', date: '2026-05-13', status: 'verified', address: '3913 Arizona Pl' },
|
||||||
|
{ id: 'ck_015', agentId: 'e2', date: '2026-05-11', status: 'unverified', address: '3917 Arizona Pl' },
|
||||||
|
{ id: 'ck_016', agentId: 'e2', date: '2026-05-08', status: 'verified', address: '3921 Arizona Pl' },
|
||||||
|
{ id: 'ck_017', agentId: 'e2', date: '2026-05-08', status: 'unverified', address: '3920 Arizona Pl' },
|
||||||
|
{ id: 'ck_018', agentId: 'e2', date: '2026-05-06', status: 'converted', address: '3916 Arizona Pl' },
|
||||||
|
{ id: 'ck_019', agentId: 'e2', date: '2026-05-05', status: 'verified', address: '3912 Arizona Pl' },
|
||||||
|
{ id: 'ck_020', agentId: 'e2', date: '2026-05-04', status: 'unverified', address: '3908 Arizona Pl' },
|
||||||
|
{ id: 'ck_021', agentId: 'e2', date: '2026-05-03', status: 'verified', address: '3904 Arizona Pl' },
|
||||||
|
{ id: 'ck_022', agentId: 'e2', date: '2026-05-01', status: 'unverified', address: '3900 Arizona Pl' },
|
||||||
|
|
||||||
|
// Fred Flyer (e3) — low volume, mostly unverified (new canvasser)
|
||||||
|
{ id: 'ck_023', agentId: 'e3', date: '2026-05-14', status: 'unverified', address: '3917 Sailmaker Ln' },
|
||||||
|
{ id: 'ck_024', agentId: 'e3', date: '2026-05-12', status: 'verified', address: '3913 Sailmaker Ln' },
|
||||||
|
{ id: 'ck_025', agentId: 'e3', date: '2026-05-07', status: 'unverified', address: '3909 Sailmaker Ln' },
|
||||||
|
{ id: 'ck_026', agentId: 'e3', date: '2026-05-05', status: 'unverified', address: '3905 Sailmaker Ln' },
|
||||||
|
{ id: 'ck_027', agentId: 'e3', date: '2026-05-03', status: 'converted', address: '3901 Sailmaker Ln' },
|
||||||
|
|
||||||
|
// Felicity Fast (e4) — top performer, best verified count + conversion
|
||||||
|
{ id: 'ck_028', agentId: 'e4', date: '2026-05-14', status: 'converted', address: '2612 Dunwick Dr' },
|
||||||
|
{ id: 'ck_029', agentId: 'e4', date: '2026-05-14', status: 'verified', address: '2608 Dunwick Dr' },
|
||||||
|
{ id: 'ck_030', agentId: 'e4', date: '2026-05-13', status: 'converted', address: '2604 Dunwick Dr' },
|
||||||
|
{ id: 'ck_031', agentId: 'e4', date: '2026-05-12', status: 'verified', address: '2600 Dunwick Dr' },
|
||||||
|
{ id: 'ck_032', agentId: 'e4', date: '2026-05-11', status: 'verified', address: '6613 Phoenix Pl' },
|
||||||
|
{ id: 'ck_033', agentId: 'e4', date: '2026-05-08', status: 'converted', address: '6609 Phoenix Pl' },
|
||||||
|
{ id: 'ck_034', agentId: 'e4', date: '2026-05-07', status: 'verified', address: '6605 Phoenix Pl' },
|
||||||
|
{ id: 'ck_035', agentId: 'e4', date: '2026-05-06', status: 'unverified', address: '6601 Phoenix Pl' },
|
||||||
|
{ id: 'ck_036', agentId: 'e4', date: '2026-05-05', status: 'converted', address: '6617 Phoenix Pl' },
|
||||||
|
{ id: 'ck_037', agentId: 'e4', date: '2026-05-04', status: 'verified', address: '3913 Arizona Pl' },
|
||||||
|
{ id: 'ck_038', agentId: 'e4', date: '2026-05-03', status: 'verified', address: '3917 Arizona Pl' },
|
||||||
|
{ id: 'ck_039', agentId: 'e4', date: '2026-05-02', status: 'unverified', address: '3921 Arizona Pl' },
|
||||||
|
{ id: 'ck_040', agentId: 'e4', date: '2026-05-01', status: 'converted', address: '3920 Arizona Pl' },
|
||||||
|
{ id: 'ck_041', agentId: 'e4', date: '2026-05-01', status: 'verified', address: '3916 Arizona Pl' },
|
||||||
|
|
||||||
|
// Felix Fixer (e5) — selective, fewer knocks, decent conversion rate
|
||||||
|
{ id: 'ck_042', agentId: 'e5', date: '2026-05-14', status: 'converted', address: '3912 Arizona Pl' },
|
||||||
|
{ id: 'ck_043', agentId: 'e5', date: '2026-05-13', status: 'verified', address: '3908 Arizona Pl' },
|
||||||
|
{ id: 'ck_044', agentId: 'e5', date: '2026-05-11', status: 'unverified', address: '3904 Arizona Pl' },
|
||||||
|
{ id: 'ck_045', agentId: 'e5', date: '2026-05-07', status: 'converted', address: '3900 Arizona Pl' },
|
||||||
|
{ id: 'ck_046', agentId: 'e5', date: '2026-05-05', status: 'verified', address: '3917 Sailmaker Ln' },
|
||||||
|
{ id: 'ck_047', agentId: 'e5', date: '2026-05-03', status: 'unverified', address: '3913 Sailmaker Ln' },
|
||||||
|
{ id: 'ck_048', agentId: 'e5', date: '2026-05-01', status: 'verified', address: '3909 Sailmaker Ln' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Helper to generate meetings
|
// Helper to generate meetings
|
||||||
@@ -5150,6 +5217,7 @@ export const MockStoreProvider = ({ children }) => {
|
|||||||
users,
|
users,
|
||||||
meetings,
|
meetings,
|
||||||
salesHistory, // Exported for Leaderboard
|
salesHistory, // Exported for Leaderboard
|
||||||
|
canvasserHistory: MOCK_CANVASSER_HISTORY, // Exported for Canvasser Leaderboard
|
||||||
owners,
|
owners,
|
||||||
personnel,
|
personnel,
|
||||||
vendors,
|
vendors,
|
||||||
|
|||||||
+339
-144
@@ -1,115 +1,181 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { useMockStore } from '../data/mockStore';
|
import { useMockStore } from '../data/mockStore';
|
||||||
import { Trophy, TrendingUp, DollarSign, Target, Calendar, Medal, ArrowUpRight, Crown, ShieldOff } from 'lucide-react';
|
import {
|
||||||
|
Trophy, TrendingUp, DollarSign, Target, Crown, ShieldOff,
|
||||||
|
Users, CheckCircle2, Clock, BarChart2, MapPin
|
||||||
|
} from 'lucide-react';
|
||||||
import { SpotlightCard } from '../components/SpotlightCard';
|
import { SpotlightCard } from '../components/SpotlightCard';
|
||||||
import AnimatedNumber from '../components/AnimatedNumber';
|
import AnimatedNumber from '../components/AnimatedNumber';
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
|
|
||||||
const LeaderboardPage = () => {
|
// ── Rank badge helper ────────────────────────────────────────────────────────
|
||||||
const { users, salesHistory } = useMockStore();
|
const rankBadge = (i) =>
|
||||||
const { can } = usePermissions();
|
i === 0 ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-300' :
|
||||||
const [timeframe, setTimeframe] = useState('month'); // 'week', 'month', 'custom'
|
i === 1 ? 'bg-zinc-100 text-zinc-700 dark:bg-white/10 dark:text-zinc-300' :
|
||||||
const [metric, setMetric] = useState('revenue'); // 'revenue', 'volume', 'winRate'
|
i === 2 ? 'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300' :
|
||||||
|
'text-zinc-500';
|
||||||
|
|
||||||
// Filter agents
|
// ── Podium (shared) ──────────────────────────────────────────────────────────
|
||||||
const agents = useMemo(() => users.filter(u => u.role === 'FIELD_AGENT'), [users]);
|
const TopPodium = ({ data, metricKey, formatVal, accentColor = 'border-yellow-400' }) => {
|
||||||
|
|
||||||
// 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('en-US')}`;
|
|
||||||
if (type === 'volume') return `${val} Deals`;
|
|
||||||
if (type === 'winRate') return `${val.toFixed(1)}%`;
|
|
||||||
return val;
|
|
||||||
};
|
|
||||||
|
|
||||||
const TopPodium = ({ data }) => {
|
|
||||||
if (data.length < 3) return null;
|
if (data.length < 3) return null;
|
||||||
const [first, second, third] = data;
|
const [first, second, third] = data;
|
||||||
|
const Step = ({ agent, rank, height, color, glow }) => (
|
||||||
const PodiumStep = ({ agent, rank, height, color, glow }) => (
|
|
||||||
<div className="flex flex-col items-center z-10 mx-2 md:mx-4">
|
<div className="flex flex-col items-center z-10 mx-2 md:mx-4">
|
||||||
<div className="mb-4 text-center">
|
<div className="mb-4 text-center">
|
||||||
<div className="relative inline-block">
|
<div className="relative inline-block">
|
||||||
<div className={`w-16 h-16 md:w-20 md:h-20 rounded-full border-4 ${color} flex items-center justify-center bg-zinc-900/5 dark:bg-zinc-800 shadow-xl overflow-hidden mb-2 relative z-10`}>
|
<div className={`w-16 h-16 md:w-20 md:h-20 rounded-full border-4 ${color} flex items-center justify-center bg-zinc-900/5 dark:bg-zinc-800 shadow-xl overflow-hidden mb-2 relative z-10`}>
|
||||||
{/* Placeholder Avatar */}
|
|
||||||
<span className="text-xl font-bold text-zinc-700 dark:text-zinc-300">
|
<span className="text-xl font-bold text-zinc-700 dark:text-zinc-300">
|
||||||
{agent.name.split(' ').map(n => n[0]).join('')}
|
{agent.name.split(' ').map(n => n[0]).join('')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{rank === 1 && <Crown size={24} className="absolute -top-4 -right-2 text-yellow-400 fill-yellow-400 animate-bounce" />}
|
{rank === 1 && <Crown size={24} className="absolute -top-4 -right-2 text-yellow-400 fill-yellow-400 animate-bounce" />}
|
||||||
<div className={`absolute inset-0 rounded-full blur-xl opacity-40 ${glow}`}></div>
|
<div className={`absolute inset-0 rounded-full blur-xl opacity-40 ${glow}`} />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-bold text-sm md:text-base text-zinc-900 dark:text-white truncate max-w-[100px]">{agent.name}</h3>
|
<h3 className="font-bold text-sm md:text-base text-zinc-900 dark:text-white truncate max-w-[100px]">{agent.name}</h3>
|
||||||
<p className={`font-mono font-bold text-sm md:text-lg ${color.replace('border-', 'text-')}`}>
|
<p className={`font-mono font-bold text-sm md:text-lg ${color.replace('border-', 'text-')}`}>
|
||||||
{metric === 'revenue' && <AnimatedNumber value={agent[metric]} prefix="$" useLocaleString duration={1.5} delay={rank * 0.2} />}
|
{formatVal(agent[metricKey])}
|
||||||
{metric === 'volume' && <><AnimatedNumber value={agent[metric]} duration={1.5} delay={rank * 0.2} /> Deals</>}
|
|
||||||
{metric === 'winRate' && <><AnimatedNumber value={agent[metric]} decimals={1} duration={1.5} delay={rank * 0.2} />%</>}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={`w-24 md:w-32 ${height} rounded-t-lg bg-gradient-to-b from-white/80 to-white/20 dark:from-white/10 dark:to-white/5 border-t border-x ${color} backdrop-blur-md relative overflow-hidden hover:brightness-110 transition-all duration-500`}>
|
||||||
{/* The Step Block */}
|
<div className="absolute inset-x-0 top-0 h-[1px] bg-white/50" />
|
||||||
<div className={`w-24 md:w-32 ${height} rounded-t-lg bg-gradient-to-b from-white/80 to-white/20 dark:from-white/10 dark:to-white/5 border-t border-x ${color.replace('border-', 'border-')} backdrop-blur-md relative overflow-hidden group transition-all duration-500 hover:brightness-110`}>
|
<div className="w-full h-full flex items-end justify-center pb-4 text-4xl font-black text-black/5 dark:text-white/5 select-none">{rank}</div>
|
||||||
<div className="absolute inset-x-0 top-0 h-[1px] bg-white/50"></div>
|
|
||||||
<div className="w-full h-full flex items-end justify-center pb-4 text-4xl font-black text-black/5 dark:text-white/5 select-none">
|
|
||||||
{rank}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-end justify-center mb-12 pt-8">
|
<div className="flex items-end justify-center mb-12 pt-8">
|
||||||
<PodiumStep agent={second} rank={2} height="h-32" color="border-zinc-300" glow="bg-zinc-400" />
|
<Step agent={second} rank={2} height="h-32" color="border-zinc-300" glow="bg-zinc-400" />
|
||||||
<PodiumStep agent={first} rank={1} height="h-44" color="border-yellow-400" glow="bg-yellow-500" />
|
<Step agent={first} rank={1} height="h-44" color={accentColor} glow="bg-yellow-500" />
|
||||||
<PodiumStep agent={third} rank={3} height="h-24" color="border-orange-400" glow="bg-orange-500" />
|
<Step agent={third} rank={3} height="h-24" color="border-orange-400" glow="bg-orange-500" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Tab pill ─────────────────────────────────────────────────────────────────
|
||||||
|
const TabBtn = ({ active, onClick, icon: Icon, label, accent }) => (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-bold transition-all border ${
|
||||||
|
active
|
||||||
|
? accent
|
||||||
|
: 'border-transparent text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={15} /> {label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Filter pill row ───────────────────────────────────────────────────────────
|
||||||
|
const FilterPills = ({ options, value, onChange }) => (
|
||||||
|
<div className="bg-zinc-100 dark:bg-zinc-900/50 p-1 rounded-xl flex border border-zinc-200 dark:border-white/5">
|
||||||
|
{options.map(o => (
|
||||||
|
<button
|
||||||
|
key={o.id}
|
||||||
|
onClick={() => onChange(o.id)}
|
||||||
|
className={`flex items-center gap-1.5 px-4 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider transition-all ${
|
||||||
|
value === o.id
|
||||||
|
? 'bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white shadow-sm'
|
||||||
|
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{o.icon && <o.icon size={11} />} {o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// MAIN PAGE
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
const LeaderboardPage = () => {
|
||||||
|
const { users, salesHistory, canvasserHistory } = useMockStore();
|
||||||
|
const { can } = usePermissions();
|
||||||
|
|
||||||
|
// Tab: 'sales' | 'canvasser'
|
||||||
|
const [tab, setTab] = useState('sales');
|
||||||
|
|
||||||
|
// ── Sales filters ────────────────────────────────────────────────────────
|
||||||
|
const [timeframe, setTimeframe] = useState('all');
|
||||||
|
const [metric, setMetric] = useState('revenue');
|
||||||
|
|
||||||
|
// ── Canvasser filters ────────────────────────────────────────────────────
|
||||||
|
const [cvTimeframe, setCvTimeframe] = useState('all');
|
||||||
|
const [cvMetric, setCvMetric] = useState('verified');
|
||||||
|
|
||||||
|
// ── Agents ───────────────────────────────────────────────────────────────
|
||||||
|
const agents = useMemo(() => users.filter(u => u.role === 'FIELD_AGENT'), [users]);
|
||||||
|
|
||||||
|
// ── Date windows ─────────────────────────────────────────────────────────
|
||||||
|
// Returns null for 'all' (no date filter), or a Date boundary for week/month.
|
||||||
|
const getWindow = (tf) => {
|
||||||
|
if (tf === 'all') return null;
|
||||||
|
const now = new Date();
|
||||||
|
if (tf === 'week') {
|
||||||
|
const d = new Date(now);
|
||||||
|
d.setDate(d.getDate() - d.getDay());
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
return new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Sales leaderboard data ───────────────────────────────────────────────
|
||||||
|
const salesData = useMemo(() => {
|
||||||
|
const since = getWindow(timeframe);
|
||||||
|
return agents.map(agent => {
|
||||||
|
const txs = salesHistory.filter(tx => {
|
||||||
|
if (tx.agentId !== agent.id) return false;
|
||||||
|
if (since === null) return true; // 'all time' — no date filter
|
||||||
|
return new Date(tx.date) >= since;
|
||||||
|
});
|
||||||
|
const revenue = txs.filter(t => t.status === 'closed_won').reduce((s, t) => s + t.amount, 0);
|
||||||
|
const volume = txs.filter(t => t.status === 'closed_won').length;
|
||||||
|
const won = txs.filter(t => t.status === 'closed_won').length;
|
||||||
|
const lost = txs.filter(t => t.status === 'closed_lost').length;
|
||||||
|
const winRate = (won + lost) > 0 ? (won / (won + lost)) * 100 : 0;
|
||||||
|
return { ...agent, revenue, volume, winRate, totalDeals: won + lost };
|
||||||
|
}).sort((a, b) => b[metric] - a[metric]);
|
||||||
|
}, [agents, salesHistory, timeframe, metric]);
|
||||||
|
|
||||||
|
const formatSales = (val, m) => {
|
||||||
|
if (m === 'revenue') return `$${val.toLocaleString('en-US')}`;
|
||||||
|
if (m === 'volume') return `${val} Deals`;
|
||||||
|
if (m === 'winRate') return `${val.toFixed(1)}%`;
|
||||||
|
return val;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Canvasser leaderboard data ───────────────────────────────────────────
|
||||||
|
// Single source of truth: canvasserHistory records, filtered by date window.
|
||||||
|
// verified = records with status 'verified' OR 'converted' (converted is also verified)
|
||||||
|
// unverified = records with status 'unverified' (pending review)
|
||||||
|
// converted = records with status 'converted' (became paying client)
|
||||||
|
// total = all door-knock records in the window
|
||||||
|
// convRate = converted / verified — share of verified leads that closed
|
||||||
|
const canvasserData = useMemo(() => {
|
||||||
|
const since = getWindow(cvTimeframe);
|
||||||
|
return agents.map(agent => {
|
||||||
|
const records = (canvasserHistory || []).filter(r => {
|
||||||
|
if (r.agentId !== agent.id) return false;
|
||||||
|
if (!since) return true;
|
||||||
|
return new Date(r.date) >= since;
|
||||||
|
});
|
||||||
|
|
||||||
|
const verified = records.filter(r => r.status === 'verified' || r.status === 'converted').length;
|
||||||
|
const unverified = records.filter(r => r.status === 'unverified').length;
|
||||||
|
const converted = records.filter(r => r.status === 'converted').length;
|
||||||
|
const total = records.length;
|
||||||
|
const convRate = verified > 0 ? (converted / verified) * 100 : 0;
|
||||||
|
|
||||||
|
return { ...agent, verified, unverified, converted, total, convRate };
|
||||||
|
}).sort((a, b) => b[cvMetric] - a[cvMetric]);
|
||||||
|
}, [agents, canvasserHistory, cvTimeframe, cvMetric]);
|
||||||
|
|
||||||
|
const formatCv = (val, m) => {
|
||||||
|
if (m === 'convRate') return `${val.toFixed(1)}%`;
|
||||||
|
return `${val}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Permission guard ─────────────────────────────────────────────────────
|
||||||
if (!can('leaderboard', 'view')) {
|
if (!can('leaderboard', 'view')) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full flex items-center justify-center p-8">
|
<div className="min-h-full flex items-center justify-center p-8">
|
||||||
@@ -124,95 +190,106 @@ const LeaderboardPage = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Render ───────────────────────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full p-8 max-w-7xl mx-auto space-y-8 pb-20">
|
<div className="min-h-full p-8 max-w-7xl mx-auto space-y-8 pb-20">
|
||||||
{/* Header */}
|
|
||||||
|
{/* ── Page header ── */}
|
||||||
<header className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
<header className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-zinc-900 to-zinc-600 dark:from-white dark:to-white/60 mb-2 tracking-tight">Sales Leaderboard</h1>
|
<h1 className="text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-zinc-900 to-zinc-600 dark:from-white dark:to-white/60 mb-2 tracking-tight">
|
||||||
<p className="text-zinc-500 dark:text-zinc-400 font-light">Real-time performance rankings for the sales team.</p>
|
{tab === 'sales' ? 'Sales Leaderboard' : 'Canvasser Leaderboard'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-zinc-500 dark:text-zinc-400 font-light">
|
||||||
|
{tab === 'sales'
|
||||||
|
? 'Real-time performance rankings for the sales team.'
|
||||||
|
: 'Verified door-knock lead rankings for canvassers.'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* ── Tab switcher ── */}
|
||||||
<div className="flex flex-col sm:flex-row gap-3">
|
<div className="flex items-center gap-2 bg-zinc-100 dark:bg-zinc-900/60 border border-zinc-200 dark:border-white/5 rounded-2xl p-1.5">
|
||||||
{/* Timeframe */}
|
<TabBtn
|
||||||
<div className="bg-zinc-100 dark:bg-zinc-900/50 p-1 rounded-xl flex border border-zinc-200 dark:border-white/5">
|
active={tab === 'sales'}
|
||||||
{['week', 'month'].map(t => (
|
onClick={() => setTab('sales')}
|
||||||
<button
|
icon={DollarSign}
|
||||||
key={t}
|
label="Sales"
|
||||||
onClick={() => setTimeframe(t)}
|
accent="bg-white dark:bg-zinc-800 border-zinc-200 dark:border-white/10 text-blue-600 dark:text-blue-400 shadow-sm"
|
||||||
className={`px-4 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider transition-all ${timeframe === t
|
/>
|
||||||
? 'bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white shadow-sm'
|
<TabBtn
|
||||||
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white'}`}
|
active={tab === 'canvasser'}
|
||||||
>
|
onClick={() => setTab('canvasser')}
|
||||||
This {t}
|
icon={MapPin}
|
||||||
</button>
|
label="Canvasser"
|
||||||
))}
|
accent="bg-white dark:bg-zinc-800 border-zinc-200 dark:border-white/10 text-emerald-600 dark:text-emerald-400 shadow-sm"
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
{/* Metric */}
|
|
||||||
<div className="bg-zinc-100 dark:bg-zinc-900/50 p-1 rounded-xl flex border border-zinc-200 dark:border-white/5">
|
|
||||||
{[
|
|
||||||
{ id: 'revenue', icon: DollarSign, label: 'Revenue' },
|
|
||||||
{ id: 'volume', icon: Target, label: 'Volume' },
|
|
||||||
{ id: 'winRate', icon: TrendingUp, label: 'Win Rate' }
|
|
||||||
].map(m => (
|
|
||||||
<button
|
|
||||||
key={m.id}
|
|
||||||
onClick={() => setMetric(m.id)}
|
|
||||||
className={`flex items-center space-x-2 px-4 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider transition-all ${metric === m.id
|
|
||||||
? 'bg-white dark:bg-zinc-800 text-blue-600 dark:text-blue-300 shadow-sm'
|
|
||||||
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white'}`}
|
|
||||||
>
|
|
||||||
<m.icon size={12} />
|
|
||||||
<span>{m.label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Podium Section */}
|
{/* ════════════════════════════════════════════════
|
||||||
<div className="relative">
|
SALES TAB
|
||||||
{/* Background Glow */}
|
════════════════════════════════════════════════ */}
|
||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[300px] bg-blue-500/10 dark:bg-blue-900/20 blur-[100px] rounded-full pointer-events-none" />
|
{tab === 'sales' && (
|
||||||
<TopPodium data={leaderboardData} />
|
<>
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 flex-wrap">
|
||||||
|
<FilterPills
|
||||||
|
value={timeframe}
|
||||||
|
onChange={setTimeframe}
|
||||||
|
options={[
|
||||||
|
{ id: 'all', label: 'All Time' },
|
||||||
|
{ id: 'month', label: 'This Month' },
|
||||||
|
{ id: 'week', label: 'This Week' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<FilterPills
|
||||||
|
value={metric}
|
||||||
|
onChange={setMetric}
|
||||||
|
options={[
|
||||||
|
{ id: 'revenue', icon: DollarSign, label: 'Revenue' },
|
||||||
|
{ id: 'volume', icon: Target, label: 'Volume' },
|
||||||
|
{ id: 'winRate', icon: TrendingUp, label: 'Win Rate' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detailed List */}
|
{/* Podium */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[300px] bg-blue-500/10 dark:bg-blue-900/20 blur-[100px] rounded-full pointer-events-none" />
|
||||||
|
<TopPodium
|
||||||
|
data={salesData}
|
||||||
|
metricKey={metric}
|
||||||
|
formatVal={v => formatSales(v, metric)}
|
||||||
|
accentColor="border-yellow-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Full table */}
|
||||||
<SpotlightCard className="overflow-hidden">
|
<SpotlightCard className="overflow-hidden">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<h3 className="text-lg font-bold text-zinc-900 dark:text-white mb-4 flex items-center">
|
<h3 className="text-lg font-bold text-zinc-900 dark:text-white mb-4 flex items-center">
|
||||||
<Trophy size={18} className="mr-2 text-zinc-400" />
|
<Trophy size={18} className="mr-2 text-zinc-400" /> Full Rankings
|
||||||
Full Rankings
|
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left border-collapse">
|
<table className="w-full text-left border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-xs font-bold text-zinc-400 uppercase tracking-wider border-b border-zinc-200 dark:border-white/5">
|
<tr className="text-xs font-bold text-zinc-400 uppercase tracking-wider border-b border-zinc-200 dark:border-white/5">
|
||||||
<th className="pb-3 pl-4">Rank</th>
|
<th className="pb-3 pl-4">Rank</th>
|
||||||
<th className="pb-3">Agent</th>
|
<th className="pb-3">Agent</th>
|
||||||
<th className="pb-3 text-right">Revenue</th>
|
<th className={`pb-3 text-right ${metric === 'revenue' ? 'text-blue-500' : ''}`}>Revenue</th>
|
||||||
<th className="pb-3 text-right">Volume</th>
|
<th className={`pb-3 text-right ${metric === 'volume' ? 'text-blue-500' : ''}`}>Volume</th>
|
||||||
<th className="pb-3 text-right">Win Rate</th>
|
<th className={`pb-3 text-right ${metric === 'winRate' ? 'text-blue-500' : ''}`}>Win Rate</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
|
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
|
||||||
{leaderboardData.map((agent, index) => (
|
{salesData.map((agent, i) => (
|
||||||
<tr key={agent.id} className="group hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors">
|
<tr key={agent.id} className="group hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors">
|
||||||
<td className="py-4 pl-4">
|
<td className="py-4 pl-4">
|
||||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm ${index === 0 ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-300' :
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm ${rankBadge(i)}`}>{i + 1}</div>
|
||||||
index === 1 ? 'bg-zinc-100 text-zinc-700 dark:bg-white/10 dark:text-zinc-300' :
|
|
||||||
index === 2 ? 'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300' :
|
|
||||||
'text-zinc-500'
|
|
||||||
}`}>
|
|
||||||
{index + 1}
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4">
|
<td className="py-4">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 rounded-full bg-zinc-200 dark:bg-zinc-700 flex items-center justify-center text-xs font-bold text-zinc-500 dark:text-zinc-400 mr-3">
|
<div className="w-8 h-8 rounded-full bg-zinc-200 dark:bg-zinc-700 flex items-center justify-center text-xs font-bold text-zinc-500 dark:text-zinc-400">
|
||||||
{agent.name.charAt(0)}
|
{agent.name.charAt(0)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -222,13 +299,13 @@ const LeaderboardPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className={`py-4 text-right font-mono font-medium ${metric === 'revenue' ? 'text-blue-600 dark:text-blue-400 font-bold' : 'text-zinc-500 dark:text-zinc-400'}`}>
|
<td className={`py-4 text-right font-mono font-medium ${metric === 'revenue' ? 'text-blue-600 dark:text-blue-400 font-bold' : 'text-zinc-500 dark:text-zinc-400'}`}>
|
||||||
<AnimatedNumber value={agent.revenue} prefix="$" useLocaleString duration={1.2} delay={index * 0.05} />
|
<AnimatedNumber value={agent.revenue} prefix="$" useLocaleString duration={1.2} delay={i * 0.05} />
|
||||||
</td>
|
</td>
|
||||||
<td className={`py-4 text-right font-mono font-medium ${metric === 'volume' ? 'text-blue-600 dark:text-blue-400 font-bold' : 'text-zinc-500 dark:text-zinc-400'}`}>
|
<td className={`py-4 text-right font-mono font-medium ${metric === 'volume' ? 'text-blue-600 dark:text-blue-400 font-bold' : 'text-zinc-500 dark:text-zinc-400'}`}>
|
||||||
<AnimatedNumber value={agent.volume} duration={1.2} delay={index * 0.05} /> Deals
|
<AnimatedNumber value={agent.volume} duration={1.2} delay={i * 0.05} /> Deals
|
||||||
</td>
|
</td>
|
||||||
<td className={`py-4 text-right font-mono font-medium ${metric === 'winRate' ? 'text-blue-600 dark:text-blue-400 font-bold' : 'text-zinc-500 dark:text-zinc-400'}`}>
|
<td className={`py-4 text-right font-mono font-medium ${metric === 'winRate' ? 'text-blue-600 dark:text-blue-400 font-bold' : 'text-zinc-500 dark:text-zinc-400'}`}>
|
||||||
<AnimatedNumber value={agent.winRate} decimals={1} duration={1.2} delay={index * 0.05} />%
|
<AnimatedNumber value={agent.winRate} decimals={1} duration={1.2} delay={i * 0.05} />%
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -237,6 +314,124 @@ const LeaderboardPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SpotlightCard>
|
</SpotlightCard>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ════════════════════════════════════════════════
|
||||||
|
CANVASSER TAB
|
||||||
|
════════════════════════════════════════════════ */}
|
||||||
|
{tab === 'canvasser' && (
|
||||||
|
<>
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 flex-wrap">
|
||||||
|
<FilterPills
|
||||||
|
value={cvTimeframe}
|
||||||
|
onChange={setCvTimeframe}
|
||||||
|
options={[
|
||||||
|
{ id: 'all', label: 'All Time' },
|
||||||
|
{ id: 'month', label: 'This Month' },
|
||||||
|
{ id: 'week', label: 'This Week' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<FilterPills
|
||||||
|
value={cvMetric}
|
||||||
|
onChange={setCvMetric}
|
||||||
|
options={[
|
||||||
|
{ id: 'verified', icon: CheckCircle2, label: 'Verified' },
|
||||||
|
{ id: 'total', icon: Users, label: 'Door Knocks' },
|
||||||
|
{ id: 'convRate', icon: BarChart2, label: 'Conv. Rate' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Podium */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[300px] bg-emerald-500/10 dark:bg-emerald-900/20 blur-[100px] rounded-full pointer-events-none" />
|
||||||
|
<TopPodium
|
||||||
|
data={canvasserData}
|
||||||
|
metricKey={cvMetric}
|
||||||
|
formatVal={v => formatCv(v, cvMetric)}
|
||||||
|
accentColor="border-emerald-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stat summary cards */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
{[
|
||||||
|
{ label: 'Total Verified', value: canvasserData.reduce((s, a) => s + a.verified, 0), color: 'text-emerald-500', icon: CheckCircle2 },
|
||||||
|
{ label: 'Unverified (Pending)',value: canvasserData.reduce((s, a) => s + a.unverified, 0), color: 'text-amber-500', icon: Clock },
|
||||||
|
{ label: 'Converted to Client', value: canvasserData.reduce((s, a) => s + a.converted, 0), color: 'text-blue-500', icon: TrendingUp },
|
||||||
|
{ label: 'Total Door Knocks', value: canvasserData.reduce((s, a) => s + a.total, 0), color: 'text-violet-500', icon: MapPin },
|
||||||
|
].map(c => (
|
||||||
|
<SpotlightCard key={c.label} className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<c.icon size={14} className={c.color} />
|
||||||
|
<span className="text-[11px] font-mono font-bold text-zinc-500 uppercase tracking-widest">{c.label}</span>
|
||||||
|
</div>
|
||||||
|
<p className={`text-2xl font-black font-mono ${c.color}`}>
|
||||||
|
<AnimatedNumber value={c.value} duration={1.2} />
|
||||||
|
</p>
|
||||||
|
</SpotlightCard>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Full table */}
|
||||||
|
<SpotlightCard className="overflow-hidden">
|
||||||
|
<div className="p-6">
|
||||||
|
<h3 className="text-lg font-bold text-zinc-900 dark:text-white mb-4 flex items-center gap-2">
|
||||||
|
<MapPin size={18} className="text-emerald-400" /> Canvasser Rankings
|
||||||
|
</h3>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-xs font-bold text-zinc-400 uppercase tracking-wider border-b border-zinc-200 dark:border-white/5">
|
||||||
|
<th className="pb-3 pl-4">Rank</th>
|
||||||
|
<th className="pb-3">Canvasser</th>
|
||||||
|
<th className={`pb-3 text-right ${cvMetric === 'verified' ? 'text-emerald-500' : ''}`}>Verified</th>
|
||||||
|
<th className="pb-3 text-right text-amber-400">Unverified</th>
|
||||||
|
<th className="pb-3 text-right">Converted</th>
|
||||||
|
<th className={`pb-3 text-right ${cvMetric === 'convRate' ? 'text-emerald-500' : ''}`}>Conv. Rate</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
|
||||||
|
{canvasserData.map((agent, i) => (
|
||||||
|
<tr key={agent.id} className="group hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors">
|
||||||
|
<td className="py-4 pl-4">
|
||||||
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm ${rankBadge(i)}`}>{i + 1}</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-emerald-500/15 flex items-center justify-center text-xs font-bold text-emerald-600 dark:text-emerald-400">
|
||||||
|
{agent.name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-zinc-900 dark:text-white">{agent.name}</div>
|
||||||
|
<div className="text-xs text-zinc-500">{agent.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className={`py-4 text-right font-mono font-medium ${cvMetric === 'verified' ? 'text-emerald-600 dark:text-emerald-400 font-bold' : 'text-zinc-500 dark:text-zinc-400'}`}>
|
||||||
|
<AnimatedNumber value={agent.verified} duration={1.2} delay={i * 0.05} />
|
||||||
|
</td>
|
||||||
|
<td className="py-4 text-right font-mono font-medium text-amber-500 dark:text-amber-400">
|
||||||
|
<AnimatedNumber value={agent.unverified} duration={1.2} delay={i * 0.05} />
|
||||||
|
</td>
|
||||||
|
<td className="py-4 text-right font-mono font-medium text-blue-500 dark:text-blue-400">
|
||||||
|
<AnimatedNumber value={agent.converted} duration={1.2} delay={i * 0.05} />
|
||||||
|
</td>
|
||||||
|
<td className={`py-4 text-right font-mono font-medium ${cvMetric === 'convRate' ? 'text-emerald-600 dark:text-emerald-400 font-bold' : 'text-zinc-500 dark:text-zinc-400'}`}>
|
||||||
|
<AnimatedNumber value={agent.convRate} decimals={1} duration={1.2} delay={i * 0.05} />%
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
|
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user