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
+79
View File
@@ -0,0 +1,79 @@
/**
* Aggregates storm-sourced leads into per-storm revenue attribution stats.
*
* Merges MOCK_STORM_ATTRIBUTION_LEADS (historical) with any live storm leads
* created in the current session via the Storm Intel → Create Lead flow.
*
* Returns summary + per-storm breakdown sorted by revenue descending.
*/
import { useMemo } from 'react';
import { MOCK_STORM_ATTRIBUTION_LEADS } from '../data/mockStore';
// Estimated average pipeline value for an Appointed (not yet closed) lead
const AVG_PIPELINE_VALUE = 17500;
export function useStormAttribution(events = [], sessionLeads = []) {
return useMemo(() => {
// Merge: mock historical leads + any new storm leads from this session
const mockIds = new Set(MOCK_STORM_ATTRIBUTION_LEADS.map(l => l.id));
const newSessionLeads = sessionLeads.filter(l => l.stormSource && !mockIds.has(l.id));
const allLeads = [...MOCK_STORM_ATTRIBUTION_LEADS, ...newSessionLeads];
// Build a quick lookup of storm metadata from the events array
const stormMeta = Object.fromEntries(events.map(e => [e.id, e]));
// Aggregate per storm
const buckets = {};
allLeads.forEach(lead => {
const sid = lead.stormSource?.id;
if (!sid) return;
if (!buckets[sid]) {
buckets[sid] = { leads: [], closed: [], appointed: [], contacted: [] };
}
buckets[sid].leads.push(lead);
if (lead.status === 'Closed') buckets[sid].closed.push(lead);
if (lead.status === 'Appointed') buckets[sid].appointed.push(lead);
if (lead.status === 'Contacted') buckets[sid].contacted.push(lead);
});
const perStorm = Object.entries(buckets).map(([sid, b]) => {
const storm = stormMeta[sid] ?? b.leads[0]?.stormSource ?? { id: sid };
const revenue = b.closed.reduce((s, l) => s + (l.contractValue ?? 0), 0);
const pipeline = b.appointed.length * AVG_PIPELINE_VALUE;
const conversionRate = b.leads.length > 0
? Math.round((b.closed.length / b.leads.length) * 100)
: 0;
const revenuePerLead = b.leads.length > 0
? Math.round(revenue / b.leads.length)
: 0;
return {
storm,
leadsCount: b.leads.length,
closedCount: b.closed.length,
appointedCount: b.appointed.length,
contactedCount: b.contacted.length,
revenue,
pipeline,
conversionRate,
revenuePerLead,
};
}).sort((a, b) => b.revenue - a.revenue);
// Overall summary
const totalLeads = allLeads.length;
const closedLeads = allLeads.filter(l => l.status === 'Closed').length;
const appointedLeads = allLeads.filter(l => l.status === 'Appointed').length;
const totalRevenue = allLeads.reduce((s, l) => s + (l.contractValue ?? 0), 0);
const pipelineValue = appointedLeads * AVG_PIPELINE_VALUE;
const conversionRate = totalLeads > 0 ? Math.round((closedLeads / totalLeads) * 100) : 0;
const avgDealSize = closedLeads > 0 ? Math.round(totalRevenue / closedLeads) : 0;
return {
summary: { totalLeads, closedLeads, conversionRate, totalRevenue, pipelineValue, avgDealSize },
perStorm,
allLeads,
};
}, [events, sessionLeads]);
}