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:
@@ -5701,3 +5701,57 @@ export const MOCK_STORM_EVENTS = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ── Storm-sourced leads — canvasser door-knock results linked to storm events ─
|
||||||
|
// Each lead has a stormSource { id, areaName, date, maxHailSize, severity } and
|
||||||
|
// a contractValue (number) for Closed leads. createdAt is shortly after storm date.
|
||||||
|
export const MOCK_STORM_ATTRIBUTION_LEADS = [
|
||||||
|
// ── SE-001 · Apr 28 2026 · Severe · NE Plano ──────────────────────────────
|
||||||
|
{ id: 'SAL-001', firstName: 'John', lastName: 'Martinez', address: '4821 Spring Creek Pkwy', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2026-04-29T10:14:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } },
|
||||||
|
{ id: 'SAL-002', firstName: 'Sarah', lastName: 'Kim', address: '4905 Custer Rd', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Appointed', contractValue: null, createdAt: '2026-04-29T14:32:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } },
|
||||||
|
{ id: 'SAL-003', firstName: 'Robert', lastName: 'Chen', address: '5012 Independence Pkwy', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Closed', contractValue: 18500, createdAt: '2026-04-30T09:05:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } },
|
||||||
|
{ id: 'SAL-004', firstName: 'Maria', lastName: 'Garcia', address: '4720 Alma Dr', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Closed', contractValue: 24200, createdAt: '2026-04-30T11:48:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } },
|
||||||
|
{ id: 'SAL-005', firstName: 'David', lastName: 'Thompson', address: '5130 Spring Creek Pkwy', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Appointed', contractValue: null, createdAt: '2026-05-01T08:22:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } },
|
||||||
|
{ id: 'SAL-006', firstName: 'Lisa', lastName: 'Anderson', address: '4845 Custer Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2026-05-01T13:10:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } },
|
||||||
|
{ id: 'SAL-007', firstName: 'James', lastName: 'Wilson', address: '5020 Alma Dr', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'New', contractValue: null, createdAt: '2026-05-02T10:00:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } },
|
||||||
|
{ id: 'SAL-008', firstName: 'Emily', lastName: 'Davis', address: '4980 Spring Creek Pkwy', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2026-05-03T09:45:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } },
|
||||||
|
|
||||||
|
// ── SE-002 · Mar 15 2026 · Moderate · Central Plano ──────────────────────
|
||||||
|
{ id: 'SAL-009', firstName: 'Michael', lastName: 'Brown', address: '1520 W 14th St', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2026-03-16T10:20:00Z', stormSource: { id: 'SE-002', areaName: 'Central Plano / W 15th St', date: '2026-03-15', maxHailSize: 1.2, severity: 'moderate' } },
|
||||||
|
{ id: 'SAL-010', firstName: 'Jennifer', lastName: 'Taylor', address: '1608 W 15th St', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Appointed', contractValue: null, createdAt: '2026-03-17T11:05:00Z', stormSource: { id: 'SE-002', areaName: 'Central Plano / W 15th St', date: '2026-03-15', maxHailSize: 1.2, severity: 'moderate' } },
|
||||||
|
{ id: 'SAL-011', firstName: 'Christopher',lastName: 'Moore', address: '1542 Ave K', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Closed', contractValue: 16800, createdAt: '2026-03-18T09:30:00Z', stormSource: { id: 'SE-002', areaName: 'Central Plano / W 15th St', date: '2026-03-15', maxHailSize: 1.2, severity: 'moderate' } },
|
||||||
|
{ id: 'SAL-012', firstName: 'Amanda', lastName: 'Jackson', address: '1615 W 15th St', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2026-03-19T14:00:00Z', stormSource: { id: 'SE-002', areaName: 'Central Plano / W 15th St', date: '2026-03-15', maxHailSize: 1.2, severity: 'moderate' } },
|
||||||
|
{ id: 'SAL-013', firstName: 'Daniel', lastName: 'White', address: '1480 W 14th St', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'New', contractValue: null, createdAt: '2026-03-20T10:15:00Z', stormSource: { id: 'SE-002', areaName: 'Central Plano / W 15th St', date: '2026-03-15', maxHailSize: 1.2, severity: 'moderate' } },
|
||||||
|
|
||||||
|
// ── SE-003 · Feb 3 2026 · Significant · SE Plano ─────────────────────────
|
||||||
|
{ id: 'SAL-014', firstName: 'Jessica', lastName: 'Harris', address: '3201 Parker Rd', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Appointed', contractValue: null, createdAt: '2026-02-04T09:00:00Z', stormSource: { id: 'SE-003', areaName: 'SE Plano / Parker Rd Corridor', date: '2026-02-03', maxHailSize: 1.75, severity: 'significant' } },
|
||||||
|
{ id: 'SAL-015', firstName: 'Ryan', lastName: 'Martinez', address: '3150 Parker Rd', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Closed', contractValue: 21400, createdAt: '2026-02-05T11:30:00Z', stormSource: { id: 'SE-003', areaName: 'SE Plano / Parker Rd Corridor', date: '2026-02-03', maxHailSize: 1.75, severity: 'significant' } },
|
||||||
|
{ id: 'SAL-016', firstName: 'Ashley', lastName: 'Thompson', address: '3280 Heritage Pkwy', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Closed', contractValue: 19900, createdAt: '2026-02-05T14:20:00Z', stormSource: { id: 'SE-003', areaName: 'SE Plano / Parker Rd Corridor', date: '2026-02-03', maxHailSize: 1.75, severity: 'significant' } },
|
||||||
|
{ id: 'SAL-017', firstName: 'Kevin', lastName: 'Johnson', address: '3190 Parker Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2026-02-06T10:00:00Z', stormSource: { id: 'SE-003', areaName: 'SE Plano / Parker Rd Corridor', date: '2026-02-03', maxHailSize: 1.75, severity: 'significant' } },
|
||||||
|
{ id: 'SAL-018', firstName: 'Stephanie', lastName: 'Davis', address: '3220 Heritage Pkwy', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Appointed', contractValue: null, createdAt: '2026-02-07T09:30:00Z', stormSource: { id: 'SE-003', areaName: 'SE Plano / Parker Rd Corridor', date: '2026-02-03', maxHailSize: 1.75, severity: 'significant' } },
|
||||||
|
{ id: 'SAL-019', firstName: 'Brian', lastName: 'Wilson', address: '3160 Parker Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2026-02-08T11:00:00Z', stormSource: { id: 'SE-003', areaName: 'SE Plano / Parker Rd Corridor', date: '2026-02-03', maxHailSize: 1.75, severity: 'significant' } },
|
||||||
|
|
||||||
|
// ── SE-004 · Jan 20 2026 · Moderate · W Plano ────────────────────────────
|
||||||
|
{ id: 'SAL-020', firstName: 'Nicole', lastName: 'Garcia', address: '7200 Legacy Dr', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2026-01-21T10:00:00Z', stormSource: { id: 'SE-004', areaName: 'W Plano / Legacy West', date: '2026-01-20', maxHailSize: 1.0, severity: 'moderate' } },
|
||||||
|
{ id: 'SAL-021', firstName: 'Andrew', lastName: 'Kim', address: '7150 Legacy Dr', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Appointed', contractValue: null, createdAt: '2026-01-22T13:45:00Z', stormSource: { id: 'SE-004', areaName: 'W Plano / Legacy West', date: '2026-01-20', maxHailSize: 1.0, severity: 'moderate' } },
|
||||||
|
{ id: 'SAL-022', firstName: 'Michelle', lastName: 'Lee', address: '7210 Legacy Dr', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'New', contractValue: null, createdAt: '2026-01-23T09:15:00Z', stormSource: { id: 'SE-004', areaName: 'W Plano / Legacy West', date: '2026-01-20', maxHailSize: 1.0, severity: 'moderate' } },
|
||||||
|
|
||||||
|
// ── SE-005 · Dec 10 2025 · Trace · Allen / N Plano ───────────────────────
|
||||||
|
{ id: 'SAL-023', firstName: 'Steven', lastName: 'Clark', address: '405 N Greenville Ave', city: 'Allen', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Closed', contractValue: 12400, createdAt: '2025-12-11T10:30:00Z', stormSource: { id: 'SE-005', areaName: 'Allen / N Plano Border', date: '2025-12-10', maxHailSize: 0.8, severity: 'trace' } },
|
||||||
|
{ id: 'SAL-024', firstName: 'Melissa', lastName: 'Robinson', address: '415 N Greenville Ave', city: 'Allen', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2025-12-12T11:00:00Z', stormSource: { id: 'SE-005', areaName: 'Allen / N Plano Border', date: '2025-12-10', maxHailSize: 0.8, severity: 'trace' } },
|
||||||
|
|
||||||
|
// ── SE-006 · Nov 5 2025 · Significant · S Plano ──────────────────────────
|
||||||
|
{ id: 'SAL-025', firstName: 'Timothy', lastName: 'Walker', address: '1801 K Ave', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Closed', contractValue: 31200, createdAt: '2025-11-06T09:00:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } },
|
||||||
|
{ id: 'SAL-026', firstName: 'Kimberly', lastName: 'Hall', address: '1750 J Ave', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Closed', contractValue: 18700, createdAt: '2025-11-07T10:15:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } },
|
||||||
|
{ id: 'SAL-027', firstName: 'Joshua', lastName: 'Allen', address: '1820 K Ave', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Closed', contractValue: 22900, createdAt: '2025-11-08T11:30:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } },
|
||||||
|
{ id: 'SAL-028', firstName: 'Amy', lastName: 'Young', address: '1780 J Ave', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Appointed', contractValue: null, createdAt: '2025-11-09T09:45:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } },
|
||||||
|
{ id: 'SAL-029', firstName: 'Brandon', lastName: 'King', address: '1810 K Ave', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2025-11-10T10:00:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } },
|
||||||
|
{ id: 'SAL-030', firstName: 'Heather', lastName: 'Wright', address: '1760 J Ave', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2025-11-11T13:20:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } },
|
||||||
|
{ id: 'SAL-031', firstName: 'Tyler', lastName: 'Scott', address: '1795 K Ave', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2025-11-12T09:00:00Z', stormSource: { id: 'SE-006', areaName: 'S Plano / Haggard Park Area', date: '2025-11-05', maxHailSize: 1.6, severity: 'significant' } },
|
||||||
|
|
||||||
|
// ── SE-007 · Sep 18 2025 · Moderate · Frisco / N Plano ───────────────────
|
||||||
|
{ id: 'SAL-032', firstName: 'Amber', lastName: 'Green', address: '12001 Coit Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Closed', contractValue: 17600, createdAt: '2025-09-19T10:00:00Z', stormSource: { id: 'SE-007', areaName: 'Frisco / N Plano', date: '2025-09-18', maxHailSize: 1.1, severity: 'moderate' } },
|
||||||
|
{ id: 'SAL-033', firstName: 'Nathan', lastName: 'Adams', address: '12050 Coit Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Closed', contractValue: 14300, createdAt: '2025-09-20T11:15:00Z', stormSource: { id: 'SE-007', areaName: 'Frisco / N Plano', date: '2025-09-18', maxHailSize: 1.1, severity: 'moderate' } },
|
||||||
|
{ id: 'SAL-034', firstName: 'Brittany', lastName: 'Baker', address: '12020 Coit Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2025-09-21T09:30:00Z', stormSource: { id: 'SE-007', areaName: 'Frisco / N Plano', date: '2025-09-18', maxHailSize: 1.1, severity: 'moderate' } },
|
||||||
|
{ id: 'SAL-035', firstName: 'Dylan', lastName: 'Nelson', address: '12080 Coit Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Door Knock', status: 'New', contractValue: null, createdAt: '2025-09-22T10:45:00Z', stormSource: { id: 'SE-007', areaName: 'Frisco / N Plano', date: '2025-09-18', maxHailSize: 1.1, severity: 'moderate' } },
|
||||||
|
];
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -4,13 +4,20 @@ import { motion, AnimatePresence } from 'framer-motion';
|
|||||||
import { MapContainer, TileLayer, Polygon, Tooltip, useMap } from 'react-leaflet';
|
import { MapContainer, TileLayer, Polygon, Tooltip, useMap } from 'react-leaflet';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import {
|
||||||
|
BarChart, Bar, XAxis, YAxis, Tooltip as RechartsTooltip,
|
||||||
|
ResponsiveContainer, CartesianGrid, Cell,
|
||||||
|
} from 'recharts';
|
||||||
import {
|
import {
|
||||||
CloudLightning, Wind, Zap, Droplets, Filter, X, ChevronRight,
|
CloudLightning, Wind, Zap, Droplets, Filter, X, ChevronRight,
|
||||||
Home, AlertTriangle, TrendingUp, Users, Calendar, MapPin,
|
Home, AlertTriangle, TrendingUp, Users, Calendar, MapPin,
|
||||||
Flame, Snowflake, Clock, ArrowRight, Eye, UserPlus, Loader2,
|
Flame, Snowflake, Clock, ArrowRight, Eye, UserPlus, Loader2,
|
||||||
|
DollarSign, Target, BarChart2, Map,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useTheme } from '../context/ThemeContext';
|
import { useTheme } from '../context/ThemeContext';
|
||||||
|
import { useMockStore } from '../data/mockStore';
|
||||||
import { useStormEvents, conversionWindow } from '../hooks/useStormEvents';
|
import { useStormEvents, conversionWindow } from '../hooks/useStormEvents';
|
||||||
|
import { useStormAttribution } from '../hooks/useStormAttribution';
|
||||||
|
|
||||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
// ─── 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 ───────────────────────────────────────────────────────────────
|
// ─── Filter bar ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const FilterChip = ({ active, onClick, children }) => (
|
const FilterChip = ({ active, onClick, children }) => (
|
||||||
@@ -447,7 +647,9 @@ const FilterChip = ({ active, onClick, children }) => (
|
|||||||
|
|
||||||
const StormIntelPage = () => {
|
const StormIntelPage = () => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
const { leads: storeLeads = [] } = useMockStore();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState('map');
|
||||||
const [dateRange, setDateRange] = useState('6m');
|
const [dateRange, setDateRange] = useState('6m');
|
||||||
const [typeFilter, setTypeFilter] = useState('all');
|
const [typeFilter, setTypeFilter] = useState('all');
|
||||||
const [severityFilter, setSeverityFilter] = useState('all');
|
const [severityFilter, setSeverityFilter] = useState('all');
|
||||||
@@ -457,7 +659,9 @@ const StormIntelPage = () => {
|
|||||||
const [flyCount, setFlyCount] = useState(0);
|
const [flyCount, setFlyCount] = useState(0);
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
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, loading, error, totalHomes } = useStormEvents({ dateRange, typeFilter, severityFilter, sortBy });
|
||||||
|
const { events: allEvents } = useStormEvents({ dateRange: '1y' });
|
||||||
|
|
||||||
const handleSelect = useCallback((storm) => {
|
const handleSelect = useCallback((storm) => {
|
||||||
setSelectedStorm(storm);
|
setSelectedStorm(storm);
|
||||||
@@ -525,7 +729,35 @@ const StormIntelPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile filter toggle */}
|
<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
|
<button
|
||||||
onClick={() => setShowFilters(f => !f)}
|
onClick={() => setShowFilters(f => !f)}
|
||||||
className={`lg:hidden p-2 rounded-lg border transition-colors ${
|
className={`lg:hidden p-2 rounded-lg border transition-colors ${
|
||||||
@@ -536,12 +768,14 @@ const StormIntelPage = () => {
|
|||||||
>
|
>
|
||||||
<Filter size={16} />
|
<Filter size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Filter bar ── */}
|
{/* ── Filter bar — map tab only ── */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{(showFilters || true) && (
|
{activeTab === 'map' && (showFilters || true) && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={false}
|
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'}`}
|
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>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* ── Body ── */}
|
{/* ── Attribution tab ── */}
|
||||||
<div className="flex-1 flex flex-col lg:flex-row min-h-0">
|
{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 */}
|
{/* Map */}
|
||||||
<div
|
<div
|
||||||
|
|||||||
Reference in New Issue
Block a user