From 949d96a7c7f492dabbce45700146acf03ae6197c Mon Sep 17 00:00:00 2001 From: Satyam Rastogi Date: Fri, 15 May 2026 12:05:05 +0530 Subject: [PATCH] add leaderboard for canvassar --- src/components/AnimatedNumber.jsx | 2 +- src/data/mockStore.jsx | 106 +++++- src/pages/LeaderboardPage.jsx | 577 ++++++++++++++++++++---------- 3 files changed, 474 insertions(+), 211 deletions(-) diff --git a/src/components/AnimatedNumber.jsx b/src/components/AnimatedNumber.jsx index e585716..50754c0 100644 --- a/src/components/AnimatedNumber.jsx +++ b/src/components/AnimatedNumber.jsx @@ -106,7 +106,7 @@ const AnimatedNumber = ({ }; }, [value, duration, prefix, suffix, decimals, useLocaleString, delay]); - return 0; + return {value ?? 0}; }; export default AnimatedNumber; diff --git a/src/data/mockStore.jsx b/src/data/mockStore.jsx index 0bedd8a..6c79776 100644 --- a/src/data/mockStore.jsx +++ b/src/data/mockStore.jsx @@ -736,33 +736,100 @@ const MOCK_USERS = [ // Amounts reflect residential storm-damage roofing jobs ($12K–$42K per deal). const MOCK_SALES_HISTORY = [ // 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_2', agentId: 'e1', date: '2026-03-12', amount: 42000, status: 'closed_won' }, - { id: 'tx_3', agentId: 'e1', date: '2026-03-06', amount: 31800, status: 'closed_won' }, - { id: 'tx_4', agentId: 'e1', date: '2026-03-03', amount: 0, status: 'closed_lost' }, + { id: 'tx_1', agentId: 'e1', date: '2026-05-14', amount: 38500, status: 'closed_won' }, // this week + { id: 'tx_2', agentId: 'e1', date: '2026-05-12', amount: 42000, status: 'closed_won' }, // this week + { id: 'tx_3', agentId: 'e1', date: '2026-05-06', amount: 31800, status: 'closed_won' }, + { 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 - { id: 'tx_5', agentId: 'e2', date: '2026-03-17', 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_7', agentId: 'e2', date: '2026-03-11', amount: 21400, status: 'closed_won' }, - { id: 'tx_8', agentId: 'e2', date: '2026-03-08', amount: 19600, status: 'closed_won' }, - { id: 'tx_9', agentId: 'e2', date: '2026-03-05', amount: 17300, status: 'closed_won' }, - { id: 'tx_10', agentId: 'e2', date: '2026-03-02', amount: 0, status: 'closed_lost' }, - { id: 'tx_11', agentId: 'e2', date: '2026-03-01', amount: 0, status: 'closed_lost' }, + { id: 'tx_5', agentId: 'e2', date: '2026-05-13', amount: 18200, 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-05-08', amount: 21400, status: 'closed_won' }, + { id: 'tx_8', agentId: 'e2', date: '2026-05-06', amount: 19600, status: 'closed_won' }, + { id: 'tx_9', agentId: 'e2', date: '2026-05-05', amount: 17300, status: 'closed_won' }, + { id: 'tx_10', agentId: 'e2', date: '2026-05-02', 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 - { id: 'tx_12', agentId: 'e3', date: '2026-03-18', amount: 12400, status: 'closed_won' }, // this week - { id: 'tx_13', agentId: 'e3', date: '2026-03-09', amount: 0, status: 'closed_lost' }, - { id: 'tx_14', agentId: 'e3', date: '2026-03-04', amount: 0, status: 'closed_lost' }, + { id: 'tx_12', agentId: 'e3', date: '2026-05-13', amount: 12400, status: 'closed_won' }, // this week + { id: 'tx_13', agentId: 'e3', date: '2026-05-07', 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 - { id: 'tx_15', agentId: 'e4', date: '2026-03-15', amount: 24800, status: 'closed_won' }, // this week - { id: 'tx_16', agentId: 'e4', date: '2026-03-10', amount: 23200, status: 'closed_won' }, - { id: 'tx_17', agentId: 'e4', date: '2026-03-07', amount: 26500, status: 'closed_won' }, + { id: 'tx_15', agentId: 'e4', date: '2026-05-12', amount: 24800, status: 'closed_won' }, // this week + { id: 'tx_16', agentId: 'e4', date: '2026-05-08', amount: 23200, 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 - { id: 'tx_18', agentId: 'e5', date: '2026-03-20', amount: 29500, status: 'closed_won' }, // this week - { id: 'tx_19', agentId: 'e5', date: '2026-03-11', amount: 34800, status: 'closed_won' }, + { id: 'tx_18', agentId: 'e5', date: '2026-05-14', amount: 29500, status: 'closed_won' }, // this week + { 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 @@ -5150,6 +5217,7 @@ export const MockStoreProvider = ({ children }) => { users, meetings, salesHistory, // Exported for Leaderboard + canvasserHistory: MOCK_CANVASSER_HISTORY, // Exported for Canvasser Leaderboard owners, personnel, vendors, diff --git a/src/pages/LeaderboardPage.jsx b/src/pages/LeaderboardPage.jsx index 7473a31..3bf8dd9 100644 --- a/src/pages/LeaderboardPage.jsx +++ b/src/pages/LeaderboardPage.jsx @@ -1,115 +1,181 @@ import React, { useState, useMemo } from 'react'; 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 AnimatedNumber from '../components/AnimatedNumber'; import { usePermissions } from '../hooks/usePermissions'; -const LeaderboardPage = () => { - const { users, salesHistory } = useMockStore(); - const { can } = usePermissions(); - const [timeframe, setTimeframe] = useState('month'); // 'week', 'month', 'custom' - const [metric, setMetric] = useState('revenue'); // 'revenue', 'volume', 'winRate' +// ── Rank badge helper ──────────────────────────────────────────────────────── +const rankBadge = (i) => + i === 0 ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-300' : + i === 1 ? 'bg-zinc-100 text-zinc-700 dark:bg-white/10 dark:text-zinc-300' : + 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 TopPodium = ({ data, metricKey, formatVal, accentColor = 'border-yellow-400' }) => { + if (data.length < 3) return null; + const [first, second, third] = data; + const Step = ({ agent, rank, height, color, glow }) => ( +
+
+
+
+ + {agent.name.split(' ').map(n => n[0]).join('')} + +
+ {rank === 1 && } +
+
+

{agent.name}

+

+ {formatVal(agent[metricKey])} +

+
+
+
+
{rank}
+
+
+ ); + return ( +
+ + + +
+ ); +}; + +// ── Tab pill ───────────────────────────────────────────────────────────────── +const TabBtn = ({ active, onClick, icon: Icon, label, accent }) => ( + +); + +// ── Filter pill row ─────────────────────────────────────────────────────────── +const FilterPills = ({ options, value, onChange }) => ( +
+ {options.map(o => ( + + ))} +
+); + +// ════════════════════════════════════════════════════════════════════════════ +// 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]); - // Calculate metrics based on timeframe - const leaderboardData = useMemo(() => { + // ── 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(); - 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); + 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 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 + 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; }); - - // 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 - }; + 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 formatValue = (val, type) => { - if (type === 'revenue') return `$${val.toLocaleString('en-US')}`; - if (type === 'volume') return `${val} Deals`; - if (type === 'winRate') return `${val.toFixed(1)}%`; + 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; }; - const TopPodium = ({ data }) => { - if (data.length < 3) return null; - const [first, second, third] = data; + // ── 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 PodiumStep = ({ agent, rank, height, color, glow }) => ( -
-
-
-
- {/* Placeholder Avatar */} - - {agent.name.split(' ').map(n => n[0]).join('')} - -
- {rank === 1 && } -
-
-

{agent.name}

-

- {metric === 'revenue' && } - {metric === 'volume' && <> Deals} - {metric === 'winRate' && <>%} -

-
+ 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; - {/* The Step Block */} -
-
-
- {rank} -
-
-
- ); + return { ...agent, verified, unverified, converted, total, convRate }; + }).sort((a, b) => b[cvMetric] - a[cvMetric]); + }, [agents, canvasserHistory, cvTimeframe, cvMetric]); - return ( -
- - - -
- ); + const formatCv = (val, m) => { + if (m === 'convRate') return `${val.toFixed(1)}%`; + return `${val}`; }; + // ── Permission guard ───────────────────────────────────────────────────── if (!can('leaderboard', 'view')) { return (
@@ -124,119 +190,248 @@ const LeaderboardPage = () => { ); } + // ── Render ─────────────────────────────────────────────────────────────── return (
- {/* Header */} + + {/* ── Page header ── */}
-

Sales Leaderboard

-

Real-time performance rankings for the sales team.

+

+ {tab === 'sales' ? 'Sales Leaderboard' : 'Canvasser Leaderboard'} +

+

+ {tab === 'sales' + ? 'Real-time performance rankings for the sales team.' + : 'Verified door-knock lead rankings for canvassers.'} +

- {/* 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 => ( - - ))} -
+ {/* ── Tab switcher ── */} +
+ setTab('sales')} + icon={DollarSign} + label="Sales" + accent="bg-white dark:bg-zinc-800 border-zinc-200 dark:border-white/10 text-blue-600 dark:text-blue-400 shadow-sm" + /> + setTab('canvasser')} + icon={MapPin} + 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" + />
- {/* Podium Section */} -
- {/* Background Glow */} -
- -
- - {/* Detailed List */} - -
-

- - Full Rankings -

- -
- - - - - - - - - - - - {leaderboardData.map((agent, index) => ( - - - - - - - - ))} - -
RankAgentRevenueVolumeWin Rate
-
- {index + 1} -
-
-
-
- {agent.name.charAt(0)} -
-
-
{agent.name}
-
{agent.email}
-
-
-
- - - Deals - - % -
+ {/* ════════════════════════════════════════════════ + SALES TAB + ════════════════════════════════════════════════ */} + {tab === 'sales' && ( + <> + {/* Controls */} +
+ +
-
- + + {/* Podium */} +
+
+ formatSales(v, metric)} + accentColor="border-yellow-400" + /> +
+ + {/* Full table */} + +
+

+ Full Rankings +

+
+ + + + + + + + + + + + {salesData.map((agent, i) => ( + + + + + + + + ))} + +
RankAgentRevenueVolumeWin Rate
+
{i + 1}
+
+
+
+ {agent.name.charAt(0)} +
+
+
{agent.name}
+
{agent.email}
+
+
+
+ + + Deals + + % +
+
+
+
+ + )} + + {/* ════════════════════════════════════════════════ + CANVASSER TAB + ════════════════════════════════════════════════ */} + {tab === 'canvasser' && ( + <> + {/* Controls */} +
+ + +
+ + {/* Podium */} +
+
+ formatCv(v, cvMetric)} + accentColor="border-emerald-400" + /> +
+ + {/* Stat summary cards */} +
+ {[ + { 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 => ( + +
+ + {c.label} +
+

+ +

+
+ ))} +
+ + {/* Full table */} + +
+

+ Canvasser Rankings +

+
+ + + + + + + + + + + + + {canvasserData.map((agent, i) => ( + + + + + + + + + ))} + +
RankCanvasserVerifiedUnverifiedConvertedConv. Rate
+
{i + 1}
+
+
+
+ {agent.name.charAt(0)} +
+
+
{agent.name}
+
{agent.email}
+
+
+
+ + + + + + + % +
+
+
+
+ + )} + igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?
);