feat(storm-intel): Phase 3 — Revenue Attribution dashboard

Adds an Attribution tab to Storm Intel showing full ROI breakdown of storm-sourced canvassing.

- MOCK_STORM_ATTRIBUTION_LEADS: 35 realistic leads across 7 storms, with addresses, statuses
  (New / Contacted / Appointed / Closed) and contractValues for closed deals — $218,900 total revenue
- useStormAttribution hook: merges historical mock leads with any session-created storm leads,
  aggregates per-storm stats (leads, closed, conversion rate, revenue, pipeline estimate, avg deal size)
- Attribution tab in StormIntelPage:
  - 6 summary stat cards: Total Leads, Closed, Conversion Rate, Revenue, Pipeline, Avg Deal
  - Grouped bar chart (Recharts) — leads generated vs closed per storm, bars colored by severity
  - Per-storm revenue cards ranked by revenue: severity badge, stats row, animated revenue
    progress bar relative to top performer
  - "Top Performing Zone" insight card at bottom
- Tab switcher (Map / Attribution) added to header; filter bar only shows on Map tab
This commit is contained in:
Satyam-Rastogi
2026-05-18 17:16:34 +05:30
parent 4cface49da
commit df50f27ba6
3 changed files with 387 additions and 15 deletions
+254 -15
View File
@@ -4,13 +4,20 @@ import { motion, AnimatePresence } from 'framer-motion';
import { MapContainer, TileLayer, Polygon, Tooltip, useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import {
BarChart, Bar, XAxis, YAxis, Tooltip as RechartsTooltip,
ResponsiveContainer, CartesianGrid, Cell,
} from 'recharts';
import {
CloudLightning, Wind, Zap, Droplets, Filter, X, ChevronRight,
Home, AlertTriangle, TrendingUp, Users, Calendar, MapPin,
Flame, Snowflake, Clock, ArrowRight, Eye, UserPlus, Loader2,
DollarSign, Target, BarChart2, Map,
} from 'lucide-react';
import { useTheme } from '../context/ThemeContext';
import { useMockStore } from '../data/mockStore';
import { useStormEvents, conversionWindow } from '../hooks/useStormEvents';
import { useStormAttribution } from '../hooks/useStormAttribution';
// ─── Constants ───────────────────────────────────────────────────────────────
@@ -428,6 +435,199 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {
);
};
// ─── Attribution view ────────────────────────────────────────────────────────
const SEVERITY_DOT = { severe: '#DC2626', significant: '#EF4444', moderate: '#F97316', trace: '#EAB308' };
const CustomTooltip = ({ active, payload, label }) => {
if (!active || !payload?.length) return null;
return (
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 rounded-xl shadow-xl px-3 py-2.5 text-left">
<p className="text-[11px] font-bold text-zinc-500 dark:text-zinc-400 mb-1.5">{label}</p>
{payload.map(p => (
<div key={p.dataKey} className="flex items-center gap-2 mb-0.5">
<div className="w-2 h-2 rounded-sm shrink-0" style={{ backgroundColor: p.fill }} />
<span className="text-[11px] text-zinc-700 dark:text-zinc-200 font-medium">{p.name}: <strong>{p.value}</strong></span>
</div>
))}
</div>
);
};
const AttributionView = ({ events, sessionLeads }) => {
const { theme } = useTheme();
const { summary, perStorm } = useStormAttribution(events, sessionLeads);
const isDark = theme === 'dark';
const maxRevenue = Math.max(...perStorm.map(p => p.revenue), 1);
const chartData = perStorm
.filter(p => p.leadsCount > 0)
.map(p => ({
name: (p.storm.areaName ?? 'Unknown').split('/')[0].trim().replace('Plano', '').replace('Plano', '').trim() || p.storm.areaName,
Leads: p.leadsCount,
Closed: p.closedCount,
stormId: p.storm.id,
severity: p.storm.severity,
}));
const statCards = [
{ label: 'Storm Leads', value: summary.totalLeads, icon: Users, color: '#3B82F6' },
{ label: 'Closed', value: summary.closedLeads, icon: Target, color: '#10B981' },
{ label: 'Conv. Rate', value: `${summary.conversionRate}%`, icon: TrendingUp, color: '#F59E0B' },
{ label: 'Revenue', value: `$${(summary.totalRevenue/1000).toFixed(0)}k`, icon: DollarSign, color: '#8B5CF6' },
{ label: 'Pipeline', value: `$${(summary.pipelineValue/1000).toFixed(0)}k`, icon: BarChart2, color: '#F97316' },
{ label: 'Avg Deal', value: `$${(summary.avgDealSize/1000).toFixed(1)}k`, icon: CloudLightning, color: '#EC4899' },
];
return (
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-5 space-y-6">
{/* ── Summary cards ── */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
{statCards.map(({ label, value, icon: Icon, color }) => (
<div key={label} className="rounded-xl border border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-900/60 px-4 py-3">
<div className="flex items-center gap-1.5 mb-2">
<Icon size={12} style={{ color }} />
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400">{label}</p>
</div>
<p className="text-2xl font-black text-zinc-900 dark:text-white">{value}</p>
</div>
))}
</div>
{/* ── Leads by storm chart ── */}
<div className="rounded-xl border border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-900/60 p-4">
<p className="text-[12px] font-black uppercase tracking-wider text-zinc-400 mb-4">Leads Generated Per Storm</p>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={chartData} barGap={4} barCategoryGap="30%">
<CartesianGrid strokeDasharray="3 3" stroke={isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.06)'} vertical={false} />
<XAxis
dataKey="name"
tick={{ fontSize: 10, fill: isDark ? '#71717a' : '#a1a1aa', fontWeight: 600 }}
axisLine={false} tickLine={false}
/>
<YAxis
tick={{ fontSize: 10, fill: isDark ? '#71717a' : '#a1a1aa' }}
axisLine={false} tickLine={false} width={24}
/>
<RechartsTooltip content={<CustomTooltip />} cursor={{ fill: isDark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.03)' }} />
<Bar dataKey="Leads" name="Generated" fill="#F59E0B" radius={[4, 4, 0, 0]}>
{chartData.map((entry, i) => (
<Cell key={i} fill={SEVERITY_DOT[entry.severity] ?? '#F59E0B'} fillOpacity={0.5} />
))}
</Bar>
<Bar dataKey="Closed" name="Closed" fill="#10B981" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
<div className="flex items-center gap-4 mt-3">
<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded-sm bg-amber-500/50" /><span className="text-[10px] text-zinc-400 font-medium">Generated (by severity)</span></div>
<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded-sm bg-emerald-500" /><span className="text-[10px] text-zinc-400 font-medium">Closed</span></div>
</div>
</div>
{/* ── Per-storm breakdown ── */}
<div>
<p className="text-[12px] font-black uppercase tracking-wider text-zinc-400 mb-3">Storm Revenue Breakdown</p>
<div className="space-y-3">
{perStorm.map((item, idx) => {
const sty = SEVERITY_STYLES[item.storm.severity] ?? SEVERITY_STYLES.trace;
const revenuePercent = item.revenue > 0 ? Math.round((item.revenue / maxRevenue) * 100) : 0;
const stormDate = item.storm.date
? new Date(item.storm.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: '—';
return (
<div key={item.storm.id ?? idx} className="rounded-xl border border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-900/60 overflow-hidden">
{/* Severity bar */}
<div className="h-0.5 w-full" style={{ backgroundColor: sty.dot }} />
<div className="px-4 py-3">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-2.5 min-w-0">
{/* Rank */}
<div className="w-6 h-6 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center shrink-0">
<span className="text-[10px] font-black text-zinc-500">#{idx + 1}</span>
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-[14px] font-bold text-zinc-900 dark:text-white truncate">
{item.storm.areaName ?? 'Unknown Zone'}
</p>
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-md border shrink-0 ${sty.text} ${sty.bg} ${sty.border}`}>
{sty.label}
</span>
</div>
<p className="text-[11px] text-zinc-400 mt-0.5">{stormDate}{item.storm.maxHailSize ? ` · ${item.storm.maxHailSize}"` : ''}</p>
</div>
</div>
{/* Revenue */}
<div className="text-right shrink-0">
<p className="text-lg font-black text-zinc-900 dark:text-white">
{item.revenue > 0 ? `$${item.revenue.toLocaleString()}` : '—'}
</p>
<p className="text-[10px] text-zinc-400">revenue</p>
</div>
</div>
{/* Stats row */}
<div className="flex items-center gap-4 mb-3 flex-wrap">
{[
{ label: 'Leads', value: item.leadsCount, color: null },
{ label: 'Closed', value: item.closedCount, color: '#10B981' },
{ label: 'Appointed', value: item.appointedCount, color: '#F59E0B' },
{ label: 'Conv %', value: `${item.conversionRate}%`, color: null },
{ label: 'Pipeline', value: item.pipeline > 0 ? `$${(item.pipeline/1000).toFixed(0)}k` : '—', color: '#F97316' },
].map(({ label, value, color }) => (
<div key={label} className="text-center">
<p
className="text-[13px] font-black text-zinc-900 dark:text-white"
style={color ? { color } : undefined}
>
{value}
</p>
<p className="text-[9px] font-bold uppercase tracking-wider text-zinc-400">{label}</p>
</div>
))}
</div>
{/* Revenue progress bar */}
{item.revenue > 0 && (
<div className="h-1.5 w-full bg-zinc-100 dark:bg-zinc-800 rounded-full overflow-hidden">
<motion.div
className="h-full rounded-full"
style={{ backgroundColor: sty.dot }}
initial={{ width: 0 }}
animate={{ width: `${revenuePercent}%` }}
transition={{ duration: 0.7, ease: 'easeOut', delay: idx * 0.05 }}
/>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
{/* ── Best zone insight ── */}
{perStorm.length > 0 && perStorm[0].revenue > 0 && (
<div className="rounded-xl border border-emerald-200 dark:border-emerald-500/20 bg-emerald-50 dark:bg-emerald-500/5 px-4 py-3 flex items-start gap-3">
<div className="w-8 h-8 rounded-lg bg-emerald-500/15 flex items-center justify-center shrink-0">
<TrendingUp size={16} className="text-emerald-600 dark:text-emerald-400" />
</div>
<div>
<p className="text-[12px] font-black uppercase tracking-wider text-emerald-700 dark:text-emerald-400 mb-0.5">Top Performing Zone</p>
<p className="text-[13px] text-zinc-700 dark:text-zinc-300 font-semibold">
{perStorm[0].storm.areaName} generated ${perStorm[0].revenue.toLocaleString()} from {perStorm[0].closedCount} closed deal{perStorm[0].closedCount !== 1 ? 's' : ''} ({perStorm[0].conversionRate}% conversion).
{perStorm[0].pipeline > 0 && ` $${(perStorm[0].pipeline/1000).toFixed(0)}k more in pipeline.`}
</p>
</div>
</div>
)}
</div>
);
};
// ─── Filter bar ───────────────────────────────────────────────────────────────
const FilterChip = ({ active, onClick, children }) => (
@@ -447,7 +647,9 @@ const FilterChip = ({ active, onClick, children }) => (
const StormIntelPage = () => {
const { theme } = useTheme();
const { leads: storeLeads = [] } = useMockStore();
const [activeTab, setActiveTab] = useState('map');
const [dateRange, setDateRange] = useState('6m');
const [typeFilter, setTypeFilter] = useState('all');
const [severityFilter, setSeverityFilter] = useState('all');
@@ -457,7 +659,9 @@ const StormIntelPage = () => {
const [flyCount, setFlyCount] = useState(0);
const [showFilters, setShowFilters] = useState(false);
// Always load all events (attribution uses the full set regardless of date filter)
const { events, loading, error, totalHomes } = useStormEvents({ dateRange, typeFilter, severityFilter, sortBy });
const { events: allEvents } = useStormEvents({ dateRange: '1y' });
const handleSelect = useCallback((storm) => {
setSelectedStorm(storm);
@@ -525,23 +729,53 @@ const StormIntelPage = () => {
</div>
</div>
{/* Mobile filter toggle */}
<button
onClick={() => setShowFilters(f => !f)}
className={`lg:hidden p-2 rounded-lg border transition-colors ${
showFilters
? 'bg-amber-500 border-amber-600 text-white'
: 'border-zinc-200 dark:border-white/10 text-zinc-500 hover:bg-zinc-50 dark:hover:bg-white/5'
}`}
>
<Filter size={16} />
</button>
<div className="flex items-center gap-2">
{/* Tab switcher */}
<div className="flex items-center p-1 rounded-xl gap-0.5 bg-zinc-100 dark:bg-zinc-800">
<button
onClick={() => setActiveTab('map')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wider transition-all ${
activeTab === 'map'
? 'bg-white dark:bg-zinc-700 text-zinc-900 dark:text-white shadow-sm'
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'
}`}
>
<Map size={12} />
Map
</button>
<button
onClick={() => setActiveTab('attribution')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wider transition-all ${
activeTab === 'attribution'
? 'bg-white dark:bg-zinc-700 text-zinc-900 dark:text-white shadow-sm'
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'
}`}
>
<BarChart2 size={12} />
Attribution
</button>
</div>
{/* Mobile filter toggle — map tab only */}
{activeTab === 'map' && (
<button
onClick={() => setShowFilters(f => !f)}
className={`lg:hidden p-2 rounded-lg border transition-colors ${
showFilters
? 'bg-amber-500 border-amber-600 text-white'
: 'border-zinc-200 dark:border-white/10 text-zinc-500 hover:bg-zinc-50 dark:hover:bg-white/5'
}`}
>
<Filter size={16} />
</button>
)}
</div>
</div>
</div>
{/* ── Filter bar ── */}
{/* ── Filter bar — map tab only ── */}
<AnimatePresence>
{(showFilters || true) && (
{activeTab === 'map' && (showFilters || true) && (
<motion.div
initial={false}
className={`shrink-0 border-b border-zinc-200 dark:border-white/[0.06] bg-white dark:bg-zinc-900/60 ${showFilters || window.innerWidth >= 1024 ? '' : 'hidden lg:flex'}`}
@@ -578,8 +812,13 @@ const StormIntelPage = () => {
)}
</AnimatePresence>
{/* ── Body ── */}
<div className="flex-1 flex flex-col lg:flex-row min-h-0">
{/* ── Attribution tab ── */}
{activeTab === 'attribution' && (
<AttributionView events={allEvents} sessionLeads={storeLeads} />
)}
{/* ── Map tab body ── */}
<div className={`flex-1 flex flex-col lg:flex-row min-h-0 ${activeTab !== 'map' ? 'hidden' : ''}`}>
{/* Map */}
<div