feat(storm-intel): Phase 4+5 — Tomorrow.io forecast overlay and canvasser zone assignment

- Tomorrow.io 7-day forecast strip with weather icons, storm score bar, and per-day risk color coding
- High-risk alert banner in forecast strip when any day scores >=65
- api/storm-forecast.js: Vercel proxy that reads TOMORROW_API_KEY server-side; graceful empty response when key unset
- useStormForecast hook: USE_MOCK=false with realistic MOCK_FORECAST fallback
- ZoneDrawingLayer: click-to-place polygon vertices using react-leaflet useMapEvents; disables double-click zoom while active; shows live vertex dots + dashed preview polygon
- ZoneLayer: renders stormZones as colored semi-transparent polygons with agent initials at centroid
- ZoneAssignmentModal: name + canvasser picker + color selector + optional storm link; portaled modal
- StormIntelPage: Draw Zone button (Admin/Owner only); drawing state/cancel flow; stormZones displayed on map; zone summary strip in sidebar; StormDetailDrawer has working "Draw Canvasser Zone" action
- FieldStormZonePage: canvasser mobile view at /field/storm-zones showing assigned zones with map, linked storm details, conversion window badge, and peak window alert
- Layout: Storm Zones entry in FIELD_AGENT nav
- App: /field/storm-zones route (FIELD_AGENT + Admin + Owner)
This commit is contained in:
Satyam-Rastogi
2026-05-18 18:06:17 +05:30
parent df50f27ba6
commit 12bd2fe0de
8 changed files with 1199 additions and 115 deletions
+117
View File
@@ -0,0 +1,117 @@
/**
* Proxies Tomorrow.io daily weather forecast for the Plano TX area.
* Set TOMORROW_API_KEY in Vercel env vars (and .env locally for vercel dev).
*
* Response: array of 7-day daily forecast objects, normalised for the frontend.
* Cached 1 hour — free tier allows 500 calls/day.
*/
const PLANO_LAT = 33.055;
const PLANO_LNG = -96.752;
// Tomorrow.io weather code → label + hail risk flag
function interpretCode(code) {
const map = {
1000: { label: 'Clear', hailRisk: false },
1001: { label: 'Cloudy', hailRisk: false },
1100: { label: 'Mostly Clear', hailRisk: false },
1101: { label: 'Partly Cloudy', hailRisk: false },
1102: { label: 'Mostly Cloudy', hailRisk: false },
2000: { label: 'Fog', hailRisk: false },
4000: { label: 'Drizzle', hailRisk: false },
4001: { label: 'Rain', hailRisk: false },
4200: { label: 'Light Rain', hailRisk: false },
4201: { label: 'Heavy Rain', hailRisk: false },
5000: { label: 'Snow', hailRisk: false },
5001: { label: 'Flurries', hailRisk: false },
5100: { label: 'Light Snow', hailRisk: false },
5101: { label: 'Heavy Snow', hailRisk: false },
6000: { label: 'Freezing Drizzle', hailRisk: true },
6001: { label: 'Freezing Rain', hailRisk: true },
6200: { label: 'Lt Freezing Rain', hailRisk: true },
6201: { label: 'Hvy Freezing Rain',hailRisk: true },
7000: { label: 'Ice Pellets', hailRisk: true },
7101: { label: 'Heavy Hail', hailRisk: true },
7102: { label: 'Light Hail', hailRisk: true },
8000: { label: 'Thunderstorm', hailRisk: true },
};
return map[code] ?? { label: 'Mixed', hailRisk: false };
}
export default async function handler(req, res) {
res.setHeader('Cache-Control', 's-maxage=3600, stale-while-revalidate=7200');
res.setHeader('Access-Control-Allow-Origin', '*');
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
const key = process.env.TOMORROW_API_KEY;
if (!key) {
console.warn('[storm-forecast] TOMORROW_API_KEY not set');
return res.status(200).json([]);
}
try {
const fields = [
'precipitationProbability',
'weatherCode',
'windSpeedAvg',
'windGustMax',
'temperatureAvg',
'precipitationIntensityAvg',
'humidityAvg',
].join(',');
const url = `https://api.tomorrow.io/v4/weather/forecast?location=${PLANO_LAT},${PLANO_LNG}&apikey=${key}&timesteps=1d&fields=${fields}`;
const resp = await fetch(url, {
headers: { Accept: 'application/json' },
});
if (!resp.ok) {
const txt = await resp.text();
console.warn('[storm-forecast] Tomorrow.io error', resp.status, txt);
return res.status(200).json([]);
}
const data = await resp.json();
const daily = data?.timelines?.daily ?? [];
const forecast = daily.slice(0, 7).map(day => {
const v = day.values ?? {};
// Daily aggregated fields may come back as plain names or with Avg/Max suffix
const precipProb = v.precipitationProbability ?? v.precipitationProbabilityAvg ?? 0;
const weatherCode = v.weatherCode ?? v.weatherCodeMax ?? 1000;
const windGust = v.windGustMax ?? v.windGustAvg ?? 0;
const windSpeed = v.windSpeedAvg ?? 0;
const temp = v.temperatureAvg ?? 0;
const humidity = v.humidityAvg ?? 0;
const { label, hailRisk } = interpretCode(weatherCode);
// Storm severity: 0100 score for how dangerous this day looks
const stormScore = Math.min(100, Math.round(
precipProb * 0.5 +
(hailRisk ? 35 : 0) +
(windGust > 40 ? 15 : windGust > 25 ? 8 : 0)
));
return {
date: day.time,
precipProb: Math.round(precipProb),
weatherCode,
weatherLabel: label,
hailRisk,
windGust: Math.round(windGust),
windSpeed: Math.round(windSpeed),
tempF: Math.round(temp),
humidity: Math.round(humidity),
stormScore,
};
});
return res.status(200).json(forecast);
} catch (err) {
console.error('[storm-forecast] error:', err.message);
return res.status(200).json([]);
}
}
+9
View File
@@ -37,6 +37,7 @@ import EstimatesPage from './pages/EstimatesPage';
import KanbanPage from './pages/KanbanPage'; import KanbanPage from './pages/KanbanPage';
import LeadProjectPage from './pages/LeadProjectPage'; import LeadProjectPage from './pages/LeadProjectPage';
import StormIntelPage from './pages/StormIntelPage'; import StormIntelPage from './pages/StormIntelPage';
import FieldStormZonePage from './pages/FieldStormZonePage';
// ... (existing imports) // ... (existing imports)
const ProtectedRoute = ({ children, allowedRoles }) => { const ProtectedRoute = ({ children, allowedRoles }) => {
@@ -279,6 +280,14 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/field/storm-zones"
element={
<ProtectedRoute allowedRoles={['FIELD_AGENT', 'ADMIN', 'OWNER']}>
<FieldStormZonePage />
</ProtectedRoute>
}
/>
<Route <Route
path="/admin/maps" path="/admin/maps"
element={ element={
+1
View File
@@ -191,6 +191,7 @@ const Layout = () => {
{ to: "/emp/fa/maps", icon: Map, label: "My Map" }, { to: "/emp/fa/maps", icon: Map, label: "My Map" },
{ to: "/emp/fa/leads", icon: ClipboardList, label: "Leads" }, { to: "/emp/fa/leads", icon: ClipboardList, label: "Leads" },
{ to: "/emp/fa/kanban", icon: LayoutGrid, label: "Pipeline" }, { to: "/emp/fa/kanban", icon: LayoutGrid, label: "Pipeline" },
{ to: "/field/storm-zones", icon: CloudLightning, label: "Storm Zones" },
{ {
to: "/emp/fa/pro-canvas", to: "/emp/fa/pro-canvas",
icon: LayoutDashboard, icon: LayoutDashboard,
@@ -0,0 +1,195 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, MapPin, Check } from 'lucide-react';
import { DISPATCH_REPS } from '../../data/mockStore';
const ZONE_COLORS = [
'#F59E0B', '#8B5CF6', '#10B981', '#3B82F6',
'#EF4444', '#EC4899', '#06B6D4', '#F97316',
];
const ZoneAssignmentModal = ({ open, onClose, onSave, polygon = [], linkedStormId, events = [] }) => {
const [name, setName] = useState('');
const [agentId, setAgentId] = useState('');
const [color, setColor] = useState(ZONE_COLORS[0]);
const [stormId, setStormId] = useState('');
useEffect(() => {
if (open) {
setName('');
setAgentId('');
setColor(ZONE_COLORS[0]);
setStormId(linkedStormId ?? '');
}
}, [open, linkedStormId]);
const agent = DISPATCH_REPS.find(r => r.id === agentId);
const canSave = name.trim().length > 0 && agentId;
const handleSave = () => {
if (!canSave) return;
onSave({
name: name.trim(),
assignedAgentId: agentId,
assignedAgentName: agent?.name ?? '',
color,
stormId: stormId || null,
status: 'active',
});
};
return (
<AnimatePresence>
{open && (
<>
<motion.div
key="zam-backdrop"
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/40 backdrop-blur-[2px] z-[60]"
onClick={onClose}
/>
<motion.div
key="zam-modal"
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ type: 'spring', stiffness: 380, damping: 32 }}
className="fixed inset-x-4 top-[8%] sm:inset-auto sm:left-1/2 sm:-translate-x-1/2 sm:top-[12%] sm:w-[420px] z-[61] bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-200 dark:border-white/[0.08] shadow-2xl overflow-hidden"
>
{/* Color accent bar */}
<div className="h-1 w-full" style={{ backgroundColor: color }} />
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-100 dark:border-white/[0.06]">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg flex items-center justify-center" style={{ backgroundColor: color + '22' }}>
<MapPin size={16} style={{ color }} />
</div>
<div>
<h2 className="text-[14px] font-black text-zinc-900 dark:text-white">Save Canvasser Zone</h2>
<p className="text-[11px] text-zinc-400">{polygon.length} vertices drawn</p>
</div>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-zinc-100 dark:hover:bg-white/5 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 transition-colors"
>
<X size={16} />
</button>
</div>
{/* Body */}
<div className="px-5 py-4 space-y-4 max-h-[60vh] overflow-y-auto">
{/* Zone name */}
<div>
<label className="text-[11px] font-bold uppercase tracking-wider text-zinc-400 block mb-1.5">Zone Name</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="e.g. Spring Creek Zone A"
autoFocus
className="w-full px-3 py-2.5 rounded-xl border border-zinc-200 dark:border-white/[0.08] bg-zinc-50 dark:bg-zinc-800/60 text-[13px] text-zinc-900 dark:text-white placeholder-zinc-400 outline-none focus:ring-2 focus:ring-amber-500/40 focus:border-amber-400 transition-all"
/>
</div>
{/* Agent assignment */}
<div>
<label className="text-[11px] font-bold uppercase tracking-wider text-zinc-400 block mb-1.5">Assign Canvasser</label>
<div className="space-y-1.5">
{DISPATCH_REPS.map(rep => (
<button
key={rep.id}
onClick={() => setAgentId(rep.id)}
className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-xl border text-left transition-all ${
agentId === rep.id
? 'border-amber-400 bg-amber-50 dark:bg-amber-500/10'
: 'border-zinc-200 dark:border-white/[0.07] hover:border-zinc-300 dark:hover:border-white/15 bg-white dark:bg-zinc-800/40'
}`}
>
<div
className="w-7 h-7 rounded-full flex items-center justify-center text-white text-[10px] font-black shrink-0"
style={{ backgroundColor: color }}
>
{rep.initials}
</div>
<span className={`text-[13px] font-semibold flex-1 ${
agentId === rep.id
? 'text-amber-700 dark:text-amber-300'
: 'text-zinc-700 dark:text-zinc-200'
}`}>
{rep.name}
</span>
{agentId === rep.id && <Check size={14} className="text-amber-500 shrink-0" />}
</button>
))}
</div>
</div>
{/* Zone color */}
<div>
<label className="text-[11px] font-bold uppercase tracking-wider text-zinc-400 block mb-1.5">Zone Color</label>
<div className="flex items-center gap-2 flex-wrap">
{ZONE_COLORS.map(c => (
<button
key={c}
onClick={() => setColor(c)}
className="w-7 h-7 rounded-full transition-all"
style={{
backgroundColor: c,
outline: color === c ? `3px solid ${c}` : '2px solid transparent',
outlineOffset: 2,
}}
/>
))}
</div>
</div>
{/* Linked storm (optional) */}
{events.length > 0 && (
<div>
<label className="text-[11px] font-bold uppercase tracking-wider text-zinc-400 block mb-1.5">
Linked Storm <span className="font-normal normal-case tracking-normal text-zinc-300 dark:text-zinc-600">(optional)</span>
</label>
<select
value={stormId}
onChange={e => setStormId(e.target.value)}
className="w-full px-3 py-2.5 rounded-xl border border-zinc-200 dark:border-white/[0.08] bg-zinc-50 dark:bg-zinc-800/60 text-[13px] text-zinc-900 dark:text-white outline-none focus:ring-2 focus:ring-amber-500/40 focus:border-amber-400 transition-all"
>
<option value="">No storm linked</option>
{events.map(ev => (
<option key={ev.id} value={ev.id}>
{ev.areaName} {new Date(ev.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</option>
))}
</select>
</div>
)}
</div>
{/* Footer */}
<div className="px-5 py-3 border-t border-zinc-100 dark:border-white/[0.06] flex items-center gap-2">
<button
onClick={onClose}
className="flex-1 px-4 py-2.5 rounded-xl border border-zinc-200 dark:border-white/[0.07] text-[13px] font-bold text-zinc-600 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={!canSave}
className="flex-1 px-4 py-2.5 rounded-xl text-[13px] font-bold text-white transition-all hover:opacity-90 active:scale-[0.98] disabled:opacity-40 disabled:cursor-not-allowed"
style={{ background: `linear-gradient(135deg, ${color}, ${color}bb)` }}
>
Save Zone
</button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
};
export default ZoneAssignmentModal;
+61
View File
@@ -5080,6 +5080,7 @@ export const MockStoreProvider = ({ children }) => {
const [templateAccessExcluded, setTemplateAccessExcluded] = useState([]); const [templateAccessExcluded, setTemplateAccessExcluded] = useState([]);
const [kanbanColumns, setKanbanColumns] = useState(KANBAN_COLUMNS_INITIAL); const [kanbanColumns, setKanbanColumns] = useState(KANBAN_COLUMNS_INITIAL);
const [kanbanLeads, setKanbanLeads] = useState(KANBAN_LEADS_INITIAL); const [kanbanLeads, setKanbanLeads] = useState(KANBAN_LEADS_INITIAL);
const [stormZones, setStormZones] = useState(INITIAL_STORM_ZONES);
// Commission Settings Org defaults + per-user overrides // Commission Settings Org defaults + per-user overrides
const [orgCommissionDefaults, setOrgCommissionDefaults] = useState({ const [orgCommissionDefaults, setOrgCommissionDefaults] = useState({
@@ -5396,6 +5397,20 @@ export const MockStoreProvider = ({ children }) => {
removeUserCommissionOverride: (userId) => { removeUserCommissionOverride: (userId) => {
setUserCommissionOverrides(prev => prev.filter(o => o.userId !== userId)); setUserCommissionOverrides(prev => prev.filter(o => o.userId !== userId));
}, },
// Storm Zones
stormZones,
addStormZone: (zone) => {
const newZone = { ...zone, id: `sz-${Date.now()}`, createdAt: new Date().toISOString() };
setStormZones(prev => [...prev, newZone]);
return newZone;
},
updateStormZone: (id, data) => {
setStormZones(prev => prev.map(z => z.id === id ? { ...z, ...data } : z));
},
deleteStormZone: (id) => {
setStormZones(prev => prev.filter(z => z.id !== id));
},
updateProject: (projectId, data) => { updateProject: (projectId, data) => {
setProjects(prev => prev.map(p => p.id === projectId ? { ...p, ...data } : p)); setProjects(prev => prev.map(p => p.id === projectId ? { ...p, ...data } : p));
}, },
@@ -5705,6 +5720,52 @@ export const MOCK_STORM_EVENTS = [
// Storm-sourced leads canvasser door-knock results linked to storm events // Storm-sourced leads canvasser door-knock results linked to storm events
// Each lead has a stormSource { id, areaName, date, maxHailSize, severity } and // Each lead has a stormSource { id, areaName, date, maxHailSize, severity } and
// a contractValue (number) for Closed leads. createdAt is shortly after storm date. // a contractValue (number) for Closed leads. createdAt is shortly after storm date.
// Canvasser zones polygons drawn on Storm Intel map, assigned to agents
export const INITIAL_STORM_ZONES = [
{
id: 'sz-001',
name: 'Spring Creek Zone A',
stormId: 'SE-001',
polygon: [
[33.108, -96.720], [33.100, -96.702], [33.086, -96.702],
[33.080, -96.720], [33.086, -96.738], [33.100, -96.738],
],
assignedAgentId: 'REP-01',
assignedAgentName: 'Carlos Mendez',
color: '#F59E0B',
status: 'active',
createdAt: '2026-04-29T08:00:00Z',
},
{
id: 'sz-002',
name: 'Parker Rd Zone B',
stormId: 'SE-003',
polygon: [
[33.033, -96.712], [33.025, -96.697], [33.011, -96.697],
[33.003, -96.712], [33.011, -96.727], [33.025, -96.727],
],
assignedAgentId: 'REP-02',
assignedAgentName: 'Aisha Kumar',
color: '#8B5CF6',
status: 'active',
createdAt: '2026-02-04T09:00:00Z',
},
{
id: 'sz-003',
name: 'Haggard Park Zone C',
stormId: 'SE-006',
polygon: [
[33.047, -96.774], [33.039, -96.757], [33.025, -96.757],
[33.017, -96.774], [33.025, -96.791], [33.039, -96.791],
],
assignedAgentId: 'REP-04',
assignedAgentName: 'Nina Patel',
color: '#10B981',
status: 'completed',
createdAt: '2025-11-06T07:30:00Z',
},
];
export const MOCK_STORM_ATTRIBUTION_LEADS = [ export const MOCK_STORM_ATTRIBUTION_LEADS = [
// SE-001 · Apr 28 2026 · Severe · NE Plano // 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-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' } },
+68
View File
@@ -0,0 +1,68 @@
/**
* Tomorrow.io 7-day weather forecast for Plano TX.
*
* USE_MOCK = true → realistic mock forecast (no API call needed locally)
* USE_MOCK = false → calls /api/storm-forecast which proxies Tomorrow.io
*
* Flip to false once TOMORROW_API_KEY is set in Vercel env vars.
* For local dev with vercel dev, TOMORROW_API_KEY in .env is picked up automatically.
*/
import { useState, useEffect } from 'react';
const USE_MOCK = false;
// Realistic mock — includes a thunderstorm day and a hail-risk day
const MOCK_FORECAST = [
{ date: '2026-05-18T06:00:00Z', precipProb: 12, weatherCode: 1000, weatherLabel: 'Clear', hailRisk: false, windGust: 14, windSpeed: 9, tempF: 83, humidity: 42, stormScore: 6 },
{ date: '2026-05-19T06:00:00Z', precipProb: 28, weatherCode: 4200, weatherLabel: 'Light Rain', hailRisk: false, windGust: 22, windSpeed: 14, tempF: 79, humidity: 58, stormScore: 14 },
{ date: '2026-05-20T06:00:00Z', precipProb: 78, weatherCode: 8000, weatherLabel: 'Thunderstorm', hailRisk: true, windGust: 48, windSpeed: 28, tempF: 74, humidity: 72, stormScore: 88 },
{ date: '2026-05-21T06:00:00Z', precipProb: 52, weatherCode: 4001, weatherLabel: 'Rain', hailRisk: false, windGust: 30, windSpeed: 18, tempF: 71, humidity: 68, stormScore: 34 },
{ date: '2026-05-22T06:00:00Z', precipProb: 18, weatherCode: 1001, weatherLabel: 'Cloudy', hailRisk: false, windGust: 16, windSpeed: 10, tempF: 76, humidity: 52, stormScore: 9 },
{ date: '2026-05-23T06:00:00Z', precipProb: 8, weatherCode: 1000, weatherLabel: 'Clear', hailRisk: false, windGust: 12, windSpeed: 7, tempF: 81, humidity: 38, stormScore: 4 },
{ date: '2026-05-24T06:00:00Z', precipProb: 42, weatherCode: 7102, weatherLabel: 'Light Hail', hailRisk: true, windGust: 35, windSpeed: 22, tempF: 76, humidity: 61, stormScore: 71 },
];
export function useStormForecast() {
const [forecast, setForecast] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
const load = async () => {
try {
let data;
if (USE_MOCK) {
await new Promise(r => setTimeout(r, 350));
data = MOCK_FORECAST;
} else {
const res = await fetch('/api/storm-forecast');
if (!res.ok) throw new Error(`Forecast error: ${res.status}`);
data = await res.json();
// Fall back to mock if API returns nothing (key not set yet)
if (!Array.isArray(data) || data.length === 0) data = MOCK_FORECAST;
}
if (!cancelled) setForecast(data);
} catch (e) {
if (!cancelled) {
setError(e.message);
setForecast(MOCK_FORECAST); // graceful fallback
}
} finally {
if (!cancelled) setLoading(false);
}
};
load();
return () => { cancelled = true; };
}, []);
// Highest-risk day in the next 7 days
const alertDay = forecast.reduce((best, d) => (!best || d.stormScore > best.stormScore) ? d : best, null);
const hasStormAlert = alertDay?.stormScore >= 65;
return { forecast, loading, error, alertDay, hasStormAlert };
}
+315
View File
@@ -0,0 +1,315 @@
import React, { useState, useEffect, useRef, memo } from 'react';
import { MapContainer, TileLayer, Polygon, CircleMarker, Tooltip } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import {
CloudLightning, MapPin, CheckCircle, Clock,
Home, AlertTriangle, User, Loader2,
} from 'lucide-react';
import { useTheme } from '../context/ThemeContext';
import { useMockStore } from '../data/mockStore';
import { useStormEvents, conversionWindow } from '../hooks/useStormEvents';
const PLANO_CENTER = [33.055, -96.752];
const SEVERITY_STYLES = {
severe: { dot: '#DC2626', label: 'Severe' },
significant: { dot: '#EF4444', label: 'Significant' },
moderate: { dot: '#F97316', label: 'Moderate' },
trace: { dot: '#EAB308', label: 'Trace' },
};
const MAP_STYLES = `
.field-zone-map .leaflet-container { background: #e8e0d8; font-family: system-ui, sans-serif; }
.field-tooltip { background: white !important; border: 1px solid rgba(0,0,0,0.08) !important; border-radius: 10px !important; box-shadow: 0 4px 14px rgba(0,0,0,0.12) !important; padding: 8px 10px !important; pointer-events: none !important; }
.field-tooltip::before { display: none !important; }
`;
// ─── MapFlyToZones — fits map to show all zones ───────────────────────────────
const MapFlyToZones = ({ zones }) => {
const mapRef = useRef(null);
useEffect(() => {
// Access via imperative map handle
}, [zones]);
return null;
};
// ─── Zone map ─────────────────────────────────────────────────────────────────
const ZoneMap = memo(({ zones, theme }) => {
const allPoints = zones.flatMap(z => z.polygon ?? []);
const initialCenter = allPoints.length > 0
? [
allPoints.reduce((s, p) => s + p[0], 0) / allPoints.length,
allPoints.reduce((s, p) => s + p[1], 0) / allPoints.length,
]
: PLANO_CENTER;
return (
<div className="relative w-full h-full field-zone-map">
<style>{MAP_STYLES}</style>
<MapContainer
center={initialCenter}
zoom={12}
scrollWheelZoom
zoomControl={false}
style={{ width: '100%', height: '100%' }}
>
<TileLayer
key={theme}
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
className={theme === 'dark' ? 'map-tiles filter invert grayscale contrast-100' : ''}
/>
{zones.map(zone => {
if (!zone.polygon?.length) return null;
const centroid = zone.polygon.reduce(
(acc, pt) => [acc[0] + pt[0] / zone.polygon.length, acc[1] + pt[1] / zone.polygon.length],
[0, 0]
);
return (
<React.Fragment key={zone.id}>
<Polygon
positions={zone.polygon}
pathOptions={{
color: zone.color,
fillColor: zone.color,
fillOpacity: zone.status === 'completed' ? 0.06 : 0.18,
weight: 2.5,
opacity: zone.status === 'completed' ? 0.45 : 0.9,
dashArray: zone.status === 'completed' ? '5 4' : undefined,
}}
>
<Tooltip direction="top" opacity={1} className="field-tooltip">
<div style={{ fontFamily: 'system-ui,sans-serif' }}>
<p style={{ fontSize: 12, fontWeight: 700, color: '#18181b' }}>{zone.name}</p>
<p style={{ fontSize: 10, color: '#71717a' }}>{zone.status === 'completed' ? 'Completed' : 'Active'}</p>
</div>
</Tooltip>
</Polygon>
<CircleMarker
center={centroid}
radius={13}
pathOptions={{ color: zone.color, fillColor: zone.color, fillOpacity: 1, weight: 2 }}
>
<Tooltip direction="top" opacity={1} className="field-tooltip">
<p style={{ fontSize: 11, fontWeight: 700, color: '#18181b' }}>{zone.name}</p>
</Tooltip>
</CircleMarker>
</React.Fragment>
);
})}
</MapContainer>
</div>
);
});
ZoneMap.displayName = 'ZoneMap';
// ─── Zone card ────────────────────────────────────────────────────────────────
const ZoneCard = ({ zone, linkedStorm }) => {
const conv = linkedStorm ? conversionWindow(linkedStorm.date) : null;
const sevSty = linkedStorm ? (SEVERITY_STYLES[linkedStorm.severity] ?? SEVERITY_STYLES.trace) : null;
return (
<div className="rounded-2xl border border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-900/60 overflow-hidden shadow-sm">
{/* Color bar */}
<div className="h-1.5 w-full" style={{ backgroundColor: zone.color }} />
<div className="p-4">
{/* Zone header */}
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-start gap-3 min-w-0">
<div
className="w-10 h-10 rounded-xl flex items-center justify-center text-white text-[12px] font-black shrink-0"
style={{ backgroundColor: zone.color }}
>
<MapPin size={16} />
</div>
<div className="min-w-0">
<h3 className="text-[15px] font-black text-zinc-900 dark:text-white leading-snug">{zone.name}</h3>
<div className="flex items-center gap-1.5 mt-0.5 flex-wrap">
<div
className={`flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold ${
zone.status === 'completed'
? 'bg-emerald-50 dark:bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-500/20'
: 'bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300 border border-amber-200 dark:border-amber-500/20'
}`}
>
{zone.status === 'completed'
? <><CheckCircle size={9} /> Completed</>
: <><Clock size={9} /> Active</>
}
</div>
</div>
</div>
</div>
</div>
{/* Assigned agent */}
<div className="flex items-center gap-2 mb-3 px-3 py-2 rounded-xl bg-zinc-50 dark:bg-zinc-800/40 border border-zinc-100 dark:border-white/[0.06]">
<div
className="w-7 h-7 rounded-full flex items-center justify-center text-white text-[10px] font-black shrink-0"
style={{ backgroundColor: zone.color }}
>
{zone.assignedAgentName?.split(' ').map(n => n[0]).join('') ?? '?'}
</div>
<div>
<p className="text-[12px] font-bold text-zinc-900 dark:text-white">{zone.assignedAgentName}</p>
<p className="text-[10px] text-zinc-400">Assigned canvasser</p>
</div>
</div>
{/* Linked storm */}
{linkedStorm && (
<div className={`px-3 py-2.5 rounded-xl border mb-3 ${sevSty?.bg ?? ''} ${sevSty?.border ?? ''}`}>
<div className="flex items-center gap-2 mb-1">
<CloudLightning size={12} style={{ color: sevSty?.dot }} />
<span className="text-[10px] font-black uppercase tracking-wider" style={{ color: sevSty?.dot }}>
{sevSty?.label} {linkedStorm.maxHailSize}" Hail
</span>
</div>
<p className="text-[12px] font-bold text-zinc-900 dark:text-white">{linkedStorm.areaName}</p>
<p className="text-[10px] text-zinc-500 dark:text-zinc-400 mt-0.5">
{new Date(linkedStorm.date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
{' · '}~{linkedStorm.estimatedHomes?.toLocaleString() ?? '?'} homes
</p>
{conv && (
<div className={`mt-1.5 inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-bold border ${conv.color} ${conv.bg} ${conv.border}`}>
{conv.label}
</div>
)}
</div>
)}
{/* Zone metadata */}
<div className="flex items-center gap-3 text-[11px] text-zinc-400 mt-1">
<span>{zone.polygon?.length ?? 0} vertices</span>
<span>·</span>
<span>Created {new Date(zone.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</span>
</div>
</div>
</div>
);
};
// ─── Main page ────────────────────────────────────────────────────────────────
const FieldStormZonePage = () => {
const { theme } = useTheme();
const { stormZones } = useMockStore();
const { events } = useStormEvents({ dateRange: '1y' });
const activeZones = stormZones.filter(z => z.status === 'active');
const completedZones = stormZones.filter(z => z.status === 'completed');
const getLinkedStorm = (zone) => events.find(e => e.id === zone.stormId) ?? null;
// Responsive map height
const [mapHeight, setMapHeight] = useState('260px');
useEffect(() => {
const update = () => {
const w = window.innerWidth;
setMapHeight(w < 640 ? '220px' : w < 768 ? '280px' : '340px');
};
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
return (
<div className="flex flex-col h-full bg-zinc-50 dark:bg-zinc-950 overflow-y-auto">
{/* ── Header ── */}
<div className="shrink-0 px-4 sm:px-6 py-4 border-b border-zinc-200 dark:border-white/[0.06] bg-white dark:bg-zinc-900/80 backdrop-blur-md sticky top-0 z-10">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-amber-500/10 dark:bg-amber-500/15 flex items-center justify-center border border-amber-200 dark:border-amber-500/20">
<CloudLightning size={20} className="text-amber-500" />
</div>
<div>
<h1 className="text-xl font-black text-zinc-900 dark:text-white tracking-tight">My Storm Zones</h1>
<p className="text-[12px] text-zinc-400 dark:text-zinc-500">
{activeZones.length} active · {completedZones.length} completed
</p>
</div>
</div>
</div>
{stormZones.length === 0 ? (
/* Empty state */
<div className="flex-1 flex flex-col items-center justify-center px-6 py-16 text-center">
<div className="w-16 h-16 rounded-2xl bg-zinc-100 dark:bg-zinc-800/60 flex items-center justify-center mb-4">
<MapPin size={28} className="text-zinc-300 dark:text-zinc-600" />
</div>
<h2 className="text-[16px] font-black text-zinc-700 dark:text-zinc-300 mb-1">No zones assigned yet</h2>
<p className="text-[13px] text-zinc-400 max-w-xs">
Your manager will draw and assign canvasser zones after a storm event. Check back after the next alert.
</p>
</div>
) : (
<div className="flex-1 px-4 sm:px-6 py-4 space-y-5 pb-8">
{/* Map overview */}
<div className="rounded-2xl overflow-hidden border border-zinc-200 dark:border-white/[0.07] shadow-sm" style={{ height: mapHeight }}>
<ZoneMap zones={stormZones} theme={theme} />
</div>
{/* High-risk alert */}
{activeZones.some(z => {
const storm = getLinkedStorm(z);
return storm && conversionWindow(storm.date).label === 'Hot Zone';
}) && (
<div className="flex items-start gap-3 px-4 py-3 rounded-xl border border-amber-200 dark:border-amber-500/20 bg-amber-50 dark:bg-amber-500/8">
<AlertTriangle size={16} className="text-amber-500 shrink-0 mt-0.5" />
<div>
<p className="text-[13px] font-black text-amber-700 dark:text-amber-300">Peak Canvassing Window</p>
<p className="text-[12px] text-amber-600 dark:text-amber-400 mt-0.5">
One or more of your zones is in the hot conversion window (760 days post-storm). This is the optimal time to canvass.
</p>
</div>
</div>
)}
{/* Active zones */}
{activeZones.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-3">
<div className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" />
<p className="text-[12px] font-black uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
Active Zones ({activeZones.length})
</p>
</div>
<div className="space-y-3">
{activeZones.map(zone => (
<ZoneCard key={zone.id} zone={zone} linkedStorm={getLinkedStorm(zone)} />
))}
</div>
</div>
)}
{/* Completed zones */}
{completedZones.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-3">
<CheckCircle size={12} className="text-emerald-500" />
<p className="text-[12px] font-black uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
Completed ({completedZones.length})
</p>
</div>
<div className="space-y-3">
{completedZones.map(zone => (
<ZoneCard key={zone.id} zone={zone} linkedStorm={getLinkedStorm(zone)} />
))}
</div>
</div>
)}
</div>
)}
</div>
);
};
export default FieldStormZonePage;
+433 -115
View File
@@ -1,7 +1,10 @@
import React, { useState, useCallback, useRef, useEffect, memo } from 'react'; import React, { useState, useCallback, useRef, useEffect, memo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { MapContainer, TileLayer, Polygon, Tooltip, useMap } from 'react-leaflet'; import {
MapContainer, TileLayer, Polygon, Polyline, CircleMarker,
Tooltip, useMap, useMapEvents,
} from 'react-leaflet';
import L from 'leaflet'; import L from 'leaflet';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import { import {
@@ -12,12 +15,17 @@ 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, DollarSign, Target, BarChart2, Map, Pencil, Sun, Cloud,
CloudRain, CloudDrizzle, CheckCircle, XCircle,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner';
import { useTheme } from '../context/ThemeContext'; import { useTheme } from '../context/ThemeContext';
import { useMockStore } from '../data/mockStore'; import { useMockStore } from '../data/mockStore';
import { useStormEvents, conversionWindow } from '../hooks/useStormEvents'; import { useStormEvents, conversionWindow } from '../hooks/useStormEvents';
import { useStormAttribution } from '../hooks/useStormAttribution'; import { useStormAttribution } from '../hooks/useStormAttribution';
import { useStormForecast } from '../hooks/useStormForecast';
import ZoneAssignmentModal from '../components/storm/ZoneAssignmentModal';
import { useAuth } from '../context/AuthContext';
// ─── Constants ─────────────────────────────────────────────────────────────── // ─── Constants ───────────────────────────────────────────────────────────────
@@ -38,11 +46,28 @@ const TYPE_ICONS = {
flood: Droplets, flood: Droplets,
}; };
const FORECAST_ICONS = {
1000: Sun, 1100: Sun,
1001: Cloud, 1101: Cloud, 1102: Cloud,
4000: CloudDrizzle, 4200: CloudDrizzle,
4001: CloudRain, 4201: CloudRain,
8000: CloudLightning,
7000: Snowflake, 7101: Snowflake, 7102: Snowflake,
5000: Snowflake, 5100: Snowflake, 5101: Snowflake,
};
function scoreColor(score) {
if (score >= 65) return '#DC2626';
if (score >= 40) return '#F97316';
if (score >= 20) return '#EAB308';
return '#10B981';
}
const MAP_STYLES = ` const MAP_STYLES = `
.storm-intel-map .leaflet-container { background: #e8e0d8; font-family: system-ui, sans-serif; } .storm-intel-map .leaflet-container { background: #e8e0d8; font-family: system-ui, sans-serif; }
.storm-tooltip { background: white !important; border: 1px solid rgba(0,0,0,0.08) !important; border-radius: 10px !important; box-shadow: 0 4px 14px rgba(0,0,0,0.12) !important; padding: 8px 10px !important; pointer-events: none !important; } .storm-tooltip { background: white !important; border: 1px solid rgba(0,0,0,0.08) !important; border-radius: 10px !important; box-shadow: 0 4px 14px rgba(0,0,0,0.12) !important; padding: 8px 10px !important; pointer-events: none !important; }
.storm-tooltip::before { display: none !important; } .storm-tooltip::before { display: none !important; }
@keyframes stormPulse { 0%,100% { opacity: 0.7; } 50% { opacity: 1; } } .draw-cursor .leaflet-container { cursor: crosshair !important; }
`; `;
// ─── Map sub-components ─────────────────────────────────────────────────────── // ─── Map sub-components ───────────────────────────────────────────────────────
@@ -71,7 +96,113 @@ const MapFlyTo = ({ storm, flyCount }) => {
return null; return null;
}; };
const StormMap = memo(({ events, selectedId, onSelect, theme, flyCount, selectedStorm }) => { const ZoneLayer = ({ zones }) => {
if (!zones?.length) return null;
return zones.map(zone => {
if (!zone.polygon?.length) return null;
const centroid = zone.polygon.reduce(
(acc, pt) => [acc[0] + pt[0] / zone.polygon.length, acc[1] + pt[1] / zone.polygon.length],
[0, 0]
);
return (
<React.Fragment key={zone.id}>
<Polygon
positions={zone.polygon}
pathOptions={{
color: zone.color,
fillColor: zone.color,
fillOpacity: zone.status === 'completed' ? 0.06 : 0.14,
weight: 2,
opacity: zone.status === 'completed' ? 0.45 : 0.85,
dashArray: zone.status === 'completed' ? '5 4' : undefined,
}}
>
<Tooltip direction="top" opacity={1} className="storm-tooltip" sticky={false}>
<div style={{ fontFamily: 'system-ui,sans-serif', minWidth: 140 }}>
<p style={{ fontSize: 12, fontWeight: 700, color: '#18181b', marginBottom: 2 }}>{zone.name}</p>
<p style={{ fontSize: 10, color: '#71717a' }}>
{zone.assignedAgentName} · {zone.status === 'completed' ? 'Completed' : 'Active'}
</p>
</div>
</Tooltip>
</Polygon>
{/* Agent initials circle at centroid */}
<CircleMarker
center={centroid}
radius={11}
pathOptions={{ color: zone.color, fillColor: zone.color, fillOpacity: 1, weight: 1.5 }}
>
<Tooltip direction="top" opacity={1} className="storm-tooltip">
<p style={{ fontSize: 11, fontWeight: 700, color: '#18181b' }}>{zone.name}</p>
</Tooltip>
</CircleMarker>
</React.Fragment>
);
});
};
const ZoneDrawingLayer = ({ draftPolygon, onAddPoint, onFinish }) => {
const map = useMap();
useEffect(() => {
map.doubleClickZoom.disable();
map.getContainer().classList.add('draw-cursor');
return () => {
map.doubleClickZoom.enable();
map.getContainer().classList.remove('draw-cursor');
};
}, [map]);
useMapEvents({
click(e) {
onAddPoint([e.latlng.lat, e.latlng.lng]);
},
dblclick() {
if (draftPolygon.length >= 3) onFinish();
},
});
if (draftPolygon.length === 0) return null;
const closedPath = draftPolygon.length > 1
? [...draftPolygon, draftPolygon[0]]
: draftPolygon;
return (
<>
<Polyline
positions={closedPath}
pathOptions={{ color: '#F59E0B', weight: 2.5, dashArray: '6 4', opacity: 0.9 }}
/>
{/* Fill preview */}
{draftPolygon.length >= 3 && (
<Polygon
positions={draftPolygon}
pathOptions={{ color: '#F59E0B', fillColor: '#F59E0B', fillOpacity: 0.12, weight: 0 }}
/>
)}
{/* Vertex dots */}
{draftPolygon.map((pt, i) => (
<CircleMarker
key={i}
center={pt}
radius={i === 0 ? 6 : 4}
pathOptions={{
color: '#F59E0B',
fillColor: i === 0 ? '#fff' : '#F59E0B',
fillOpacity: 1,
weight: 2,
}}
/>
))}
</>
);
};
const StormMap = memo(({
events, selectedId, onSelect, theme, flyCount, selectedStorm,
stormZones, isDrawing, draftPolygon, onAddPoint, onFinishPolygon,
}) => {
return ( return (
<div className="relative w-full h-full storm-intel-map"> <div className="relative w-full h-full storm-intel-map">
<style>{MAP_STYLES}</style> <style>{MAP_STYLES}</style>
@@ -91,11 +222,10 @@ const StormMap = memo(({ events, selectedId, onSelect, theme, flyCount, selected
<MapFlyTo storm={selectedStorm} flyCount={flyCount} /> <MapFlyTo storm={selectedStorm} flyCount={flyCount} />
{/* Existing storm polygons */}
{events.map(storm => { {events.map(storm => {
const sty = SEVERITY_STYLES[storm.severity] ?? SEVERITY_STYLES.trace; const sty = SEVERITY_STYLES[storm.severity] ?? SEVERITY_STYLES.trace;
const isSelected = storm.id === selectedId; const isSelected = storm.id === selectedId;
const TypeIcon = TYPE_ICONS[storm.type] ?? CloudLightning;
const days = Math.floor((Date.now() - new Date(storm.date).getTime()) / 86400000);
return ( return (
<Polygon <Polygon
@@ -110,9 +240,9 @@ const StormMap = memo(({ events, selectedId, onSelect, theme, flyCount, selected
dashArray: isSelected ? undefined : '6 4', dashArray: isSelected ? undefined : '6 4',
}} }}
eventHandlers={{ eventHandlers={{
click: () => onSelect(storm), click: () => !isDrawing && onSelect(storm),
mouseover: (e) => { e.target.setStyle({ fillOpacity: Math.min(sty.fillOpacity + 0.1, 0.4), weight: sty.weight + 1 }); }, mouseover: (e) => { e.target.setStyle({ fillOpacity: Math.min(sty.fillOpacity + 0.1, 0.4), weight: sty.weight + 1 }); },
mouseout: (e) => { mouseout: (e) => {
if (storm.id === selectedId) return; if (storm.id === selectedId) return;
e.target.setStyle({ fillOpacity: sty.fillOpacity, weight: sty.weight }); e.target.setStyle({ fillOpacity: sty.fillOpacity, weight: sty.weight });
}, },
@@ -134,13 +264,25 @@ const StormMap = memo(({ events, selectedId, onSelect, theme, flyCount, selected
{storm.areaName} {storm.areaName}
</p> </p>
<p style={{ fontSize: 10, color: '#71717a' }}> <p style={{ fontSize: 10, color: '#71717a' }}>
{days === 0 ? 'Today' : `${days}d ago`} · ~{(storm.estimatedHomes ?? '?').toLocaleString()} homes {Math.floor((Date.now() - new Date(storm.date).getTime()) / 86400000)}d ago · ~{(storm.estimatedHomes ?? '?').toLocaleString()} homes
</p> </p>
</div> </div>
</Tooltip> </Tooltip>
</Polygon> </Polygon>
); );
})} })}
{/* Canvasser zones layer */}
<ZoneLayer zones={stormZones} />
{/* Drawing layer (active only when isDrawing) */}
{isDrawing && (
<ZoneDrawingLayer
draftPolygon={draftPolygon}
onAddPoint={onAddPoint}
onFinish={onFinishPolygon}
/>
)}
</MapContainer> </MapContainer>
{/* Legend */} {/* Legend */}
@@ -165,11 +307,107 @@ const StormMap = memo(({ events, selectedId, onSelect, theme, flyCount, selected
<span className="text-[11px] font-bold text-white">{events.length} storm{events.length !== 1 ? 's' : ''}</span> <span className="text-[11px] font-bold text-white">{events.length} storm{events.length !== 1 ? 's' : ''}</span>
</div> </div>
</div> </div>
{/* Drawing instructions */}
{isDrawing && (
<div className="absolute top-3 right-3 z-[1000]">
<div className="flex items-center gap-2 px-3 py-2 rounded-xl bg-amber-500 text-white shadow-lg text-[11px] font-bold">
<Pencil size={12} />
{draftPolygon.length < 3
? `Click to place vertices (${draftPolygon.length}/3 min)`
: `Double-click to close polygon (${draftPolygon.length} pts)`
}
</div>
</div>
)}
</div> </div>
); );
}); });
StormMap.displayName = 'StormMap'; StormMap.displayName = 'StormMap';
// ─── Forecast strip ───────────────────────────────────────────────────────────
const ForecastStrip = ({ forecast, loading, alertDay, hasStormAlert }) => {
if (loading) {
return (
<div className="shrink-0 border-b border-zinc-200 dark:border-white/[0.06] bg-white dark:bg-zinc-900/60 px-4 sm:px-6 py-2">
<div className="flex items-center gap-2">
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="h-14 w-[72px] shrink-0 rounded-lg bg-zinc-100 dark:bg-zinc-800/40 animate-pulse" />
))}
</div>
</div>
);
}
if (!forecast.length) return null;
return (
<div className="shrink-0 border-b border-zinc-200 dark:border-white/[0.06] bg-white dark:bg-zinc-900/60">
{/* Storm alert banner */}
{hasStormAlert && alertDay && (
<div className="mx-4 sm:mx-6 mt-2.5 mb-0 px-3 py-2 rounded-xl border border-red-200 dark:border-red-500/20 bg-red-50 dark:bg-red-500/8 flex items-center gap-2.5">
<AlertTriangle size={14} className="text-red-500 shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-[12px] font-black text-red-700 dark:text-red-400">High-Risk Storm Day</span>
<span className="text-[12px] text-red-600 dark:text-red-400 ml-1.5">
{new Date(alertDay.date).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}
{' — '}{alertDay.weatherLabel} · {alertDay.windGust} mph gusts · {alertDay.precipProb}% rain
</span>
</div>
<span className="text-[10px] font-black text-red-600 dark:text-red-400 shrink-0">Score {alertDay.stormScore}</span>
</div>
)}
{/* 7-day forecast row */}
<div className="flex items-stretch gap-1.5 px-4 sm:px-6 py-2.5 overflow-x-auto scrollbar-none">
<span className="text-[10px] font-black uppercase tracking-widest text-zinc-400 self-center mr-1 shrink-0 hidden sm:block">7-Day</span>
{forecast.map((day, i) => {
const FIcon = FORECAST_ICONS[day.weatherCode] ?? Cloud;
const isAlert = day.stormScore >= 65;
const isWarn = day.stormScore >= 40 && !isAlert;
const dayLabel = i === 0
? 'Today'
: new Date(day.date).toLocaleDateString('en-US', { weekday: 'short' });
return (
<div
key={day.date}
className={`flex-none w-[70px] rounded-xl border px-2 py-1.5 flex flex-col items-center gap-0.5 ${
isAlert
? 'border-red-200 dark:border-red-500/20 bg-red-50/60 dark:bg-red-500/8'
: isWarn
? 'border-orange-200 dark:border-orange-500/20 bg-orange-50/60 dark:bg-orange-500/8'
: 'border-zinc-100 dark:border-white/[0.06] bg-zinc-50/60 dark:bg-zinc-800/30'
}`}
>
<span className={`text-[10px] font-black uppercase tracking-wide ${i === 0 ? 'text-amber-600 dark:text-amber-400' : 'text-zinc-500 dark:text-zinc-400'}`}>
{dayLabel}
</span>
<FIcon
size={16}
style={{ color: isAlert ? '#DC2626' : isWarn ? '#F97316' : '#71717a' }}
/>
<span className={`text-[11px] font-black ${isAlert ? 'text-red-600 dark:text-red-400' : 'text-zinc-700 dark:text-zinc-200'}`}>
{day.tempF}°
</span>
{/* Storm score bar */}
<div className="w-full h-1 bg-zinc-200 dark:bg-zinc-700 rounded-full overflow-hidden mt-0.5">
<div
className="h-full rounded-full"
style={{ width: `${day.stormScore}%`, backgroundColor: scoreColor(day.stormScore) }}
/>
</div>
<span className={`text-[9px] font-bold ${isAlert ? 'text-red-500' : isWarn ? 'text-orange-500' : 'text-zinc-400'}`}>
{day.precipProb}% rain
</span>
</div>
);
})}
</div>
</div>
);
};
// ─── Storm card ─────────────────────────────────────────────────────────────── // ─── Storm card ───────────────────────────────────────────────────────────────
const StormCard = ({ storm, isSelected, onSelect }) => { const StormCard = ({ storm, isSelected, onSelect }) => {
@@ -188,17 +426,14 @@ const StormCard = ({ storm, isSelected, onSelect }) => {
: 'border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-900/60 hover:border-zinc-300 dark:hover:border-white/15 hover:shadow-sm' : 'border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-900/60 hover:border-zinc-300 dark:hover:border-white/15 hover:shadow-sm'
}`} }`}
> >
{/* Severity bar */}
<div className="h-1 w-full" style={{ backgroundColor: sty.dot }} /> <div className="h-1 w-full" style={{ backgroundColor: sty.dot }} />
<div className="p-3"> <div className="p-3">
<div className="flex items-start gap-2.5"> <div className="flex items-start gap-2.5">
{/* Icon */}
<div className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 mt-0.5" style={{ backgroundColor: sty.dot + '18' }}> <div className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 mt-0.5" style={{ backgroundColor: sty.dot + '18' }}>
<TypeIcon size={15} style={{ color: sty.dot }} /> <TypeIcon size={15} style={{ color: sty.dot }} />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{/* Area + date */}
<div className="flex items-start justify-between gap-1 mb-1"> <div className="flex items-start justify-between gap-1 mb-1">
<p className="text-[13px] font-bold text-zinc-900 dark:text-white leading-snug truncate"> <p className="text-[13px] font-bold text-zinc-900 dark:text-white leading-snug truncate">
{storm.areaName} {storm.areaName}
@@ -210,27 +445,20 @@ const StormCard = ({ storm, isSelected, onSelect }) => {
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 mb-2">{dateLabel}</p> <p className="text-[11px] text-zinc-500 dark:text-zinc-400 mb-2">{dateLabel}</p>
{/* Badges row */}
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
{/* Severity */}
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-md border ${sty.text} ${sty.bg} ${sty.border}`}> <span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-md border ${sty.text} ${sty.bg} ${sty.border}`}>
{sty.label} {sty.label}
</span> </span>
{/* Hail size */}
{storm.maxHailSize && ( {storm.maxHailSize && (
<span className="text-[10px] font-bold text-zinc-600 dark:text-zinc-300 bg-zinc-100 dark:bg-zinc-800 px-1.5 py-0.5 rounded-md border border-zinc-200 dark:border-white/10"> <span className="text-[10px] font-bold text-zinc-600 dark:text-zinc-300 bg-zinc-100 dark:bg-zinc-800 px-1.5 py-0.5 rounded-md border border-zinc-200 dark:border-white/10">
{storm.maxHailSize}" {storm.maxHailSize}"
</span> </span>
)} )}
{/* Conversion window */}
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-md border ${conv.color} ${conv.bg} ${conv.border}`}> <span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-md border ${conv.color} ${conv.bg} ${conv.border}`}>
{conv.label} {conv.label}
</span> </span>
</div> </div>
{/* Homes estimate */}
{storm.estimatedHomes && ( {storm.estimatedHomes && (
<div className="flex items-center gap-1 mt-1.5"> <div className="flex items-center gap-1 mt-1.5">
<Home size={10} className="text-zinc-400" /> <Home size={10} className="text-zinc-400" />
@@ -248,7 +476,7 @@ const StormCard = ({ storm, isSelected, onSelect }) => {
// ─── Detail drawer ──────────────────────────────────────────────────────────── // ─── Detail drawer ────────────────────────────────────────────────────────────
const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => { const StormDetailDrawer = ({ storm, open, onClose, onFlyTo, onAssignZone, canManageZones }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { theme } = useTheme(); const { theme } = useTheme();
@@ -274,13 +502,8 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {
}); });
}; };
const handleViewInDispatch = () => {
navigate('/admin/dispatch');
};
return ( return (
<> <>
{/* Backdrop on mobile */}
<AnimatePresence> <AnimatePresence>
{open && ( {open && (
<motion.div <motion.div
@@ -292,7 +515,6 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {
)} )}
</AnimatePresence> </AnimatePresence>
{/* Drawer panel */}
<AnimatePresence> <AnimatePresence>
{open && ( {open && (
<motion.div <motion.div
@@ -303,10 +525,8 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {
transition={{ type: 'spring', stiffness: 320, damping: 34 }} transition={{ type: 'spring', stiffness: 320, damping: 34 }}
className="fixed top-0 right-0 bottom-0 w-full max-w-sm z-50 flex flex-col bg-white dark:bg-zinc-900 border-l border-zinc-200 dark:border-white/[0.07] shadow-2xl" className="fixed top-0 right-0 bottom-0 w-full max-w-sm z-50 flex flex-col bg-white dark:bg-zinc-900 border-l border-zinc-200 dark:border-white/[0.07] shadow-2xl"
> >
{/* Severity bar */}
<div className="h-1 w-full shrink-0" style={{ backgroundColor: sty.dot }} /> <div className="h-1 w-full shrink-0" style={{ backgroundColor: sty.dot }} />
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-white/[0.06] shrink-0"> <div className="flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-white/[0.06] shrink-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg flex items-center justify-center" style={{ backgroundColor: sty.dot + '18' }}> <div className="w-8 h-8 rounded-lg flex items-center justify-center" style={{ backgroundColor: sty.dot + '18' }}>
@@ -327,9 +547,7 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {
</button> </button>
</div> </div>
{/* Scrollable body */}
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4"> <div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
{/* Area name */}
<div> <div>
<h2 className="text-lg font-black text-zinc-900 dark:text-white leading-tight"> <h2 className="text-lg font-black text-zinc-900 dark:text-white leading-tight">
{storm.areaName} {storm.areaName}
@@ -337,24 +555,22 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {
<p className="text-[12px] text-zinc-500 dark:text-zinc-400 mt-0.5">{storm.county} County, TX</p> <p className="text-[12px] text-zinc-500 dark:text-zinc-400 mt-0.5">{storm.county} County, TX</p>
</div> </div>
{/* Conversion window */}
<div className={`flex items-center gap-2 px-3 py-2 rounded-xl border ${conv.bg} ${conv.border}`}> <div className={`flex items-center gap-2 px-3 py-2 rounded-xl border ${conv.bg} ${conv.border}`}>
{conv.label === 'Hot Zone' && <Flame size={14} className={conv.color} />} {conv.label === 'Hot Zone' && <Flame size={14} className={conv.color} />}
{conv.label === 'Warm' && <TrendingUp size={14} className={conv.color} />} {conv.label === 'Warm' && <TrendingUp size={14} className={conv.color} />}
{conv.label === 'Cold' && <Snowflake size={14} className={conv.color} />} {conv.label === 'Cold' && <Snowflake size={14} className={conv.color} />}
{conv.label === 'Too Fresh' && <Clock size={14} className={conv.color} />} {conv.label === 'Too Fresh' && <Clock size={14} className={conv.color} />}
<div> <div>
<p className={`text-[12px] font-bold ${conv.color}`}>{conv.label}</p> <p className={`text-[12px] font-bold ${conv.color}`}>{conv.label}</p>
<p className="text-[10px] text-zinc-500 dark:text-zinc-400"> <p className="text-[10px] text-zinc-500 dark:text-zinc-400">
{conv.label === 'Hot Zone' && 'Peak canvassing window — highest conversion likelihood'} {conv.label === 'Hot Zone' && 'Peak canvassing window — highest conversion likelihood'}
{conv.label === 'Warm' && 'Still worth canvassing — moderate conversion rate'} {conv.label === 'Warm' && 'Still worth canvassing — moderate conversion rate'}
{conv.label === 'Cold' && 'Low conversion — storm damage assessment mostly done'} {conv.label === 'Cold' && 'Low conversion — storm damage assessment mostly done'}
{conv.label === 'Too Fresh' && 'Too recent — wait for claims process to begin'} {conv.label === 'Too Fresh' && 'Too recent — wait for claims process to begin'}
</p> </p>
</div> </div>
</div> </div>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{storm.maxHailSize && ( {storm.maxHailSize && (
<div className="rounded-xl bg-zinc-50 dark:bg-zinc-800/60 border border-zinc-100 dark:border-white/[0.06] p-3"> <div className="rounded-xl bg-zinc-50 dark:bg-zinc-800/60 border border-zinc-100 dark:border-white/[0.06] p-3">
@@ -382,7 +598,6 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {
</div> </div>
</div> </div>
{/* Description */}
<div> <div>
<p className="text-[11px] font-bold uppercase tracking-wider text-zinc-400 mb-1.5">Intel</p> <p className="text-[11px] font-bold uppercase tracking-wider text-zinc-400 mb-1.5">Intel</p>
<p className="text-[13px] text-zinc-600 dark:text-zinc-300 leading-relaxed"> <p className="text-[13px] text-zinc-600 dark:text-zinc-300 leading-relaxed">
@@ -390,7 +605,6 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {
</p> </p>
</div> </div>
{/* Map action */}
<button <button
onClick={onFlyTo} onClick={onFlyTo}
className="w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl border border-zinc-200 dark:border-white/[0.07] hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors group" className="w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl border border-zinc-200 dark:border-white/[0.07] hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors group"
@@ -403,7 +617,6 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {
</button> </button>
</div> </div>
{/* Action buttons */}
<div className="px-4 py-3 border-t border-zinc-100 dark:border-white/[0.06] space-y-2 shrink-0"> <div className="px-4 py-3 border-t border-zinc-100 dark:border-white/[0.06] space-y-2 shrink-0">
<button <button
onClick={handleCreateLead} onClick={handleCreateLead}
@@ -413,20 +626,24 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {
<UserPlus size={15} /> <UserPlus size={15} />
Create Lead from Zone Create Lead from Zone
</button> </button>
<button
onClick={handleViewInDispatch} {canManageZones ? (
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl font-bold text-[13px] text-zinc-700 dark:text-zinc-200 bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors active:scale-[0.98]" <button
> onClick={() => { onClose(); onAssignZone(storm.id); }}
<Zap size={15} /> className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl font-bold text-[13px] text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20 hover:bg-amber-100 dark:hover:bg-amber-500/20 transition-colors active:scale-[0.98]"
Open in LynkDispatch >
</button> <Pencil size={15} />
<button Draw Canvasser Zone
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl font-bold text-[13px] text-zinc-400 dark:text-zinc-500 border border-dashed border-zinc-200 dark:border-white/10 cursor-default" </button>
disabled ) : (
> <button
<Users size={15} /> onClick={() => navigate('/admin/dispatch')}
Assign Canvassers — Phase 2 className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl font-bold text-[13px] text-zinc-700 dark:text-zinc-200 bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors active:scale-[0.98]"
</button> >
<Zap size={15} />
Open in LynkDispatch
</button>
)}
</div> </div>
</motion.div> </motion.div>
)} )}
@@ -464,26 +681,24 @@ const AttributionView = ({ events, sessionLeads }) => {
const chartData = perStorm const chartData = perStorm
.filter(p => p.leadsCount > 0) .filter(p => p.leadsCount > 0)
.map(p => ({ .map(p => ({
name: (p.storm.areaName ?? 'Unknown').split('/')[0].trim().replace('Plano', '').replace('Plano', '').trim() || p.storm.areaName, name: (p.storm.areaName ?? 'Unknown').split('/')[0].trim().replace('Plano', '').trim() || p.storm.areaName,
Leads: p.leadsCount, Leads: p.leadsCount,
Closed: p.closedCount, Closed: p.closedCount,
stormId: p.storm.id, stormId: p.storm.id,
severity: p.storm.severity, severity: p.storm.severity,
})); }));
const statCards = [ const statCards = [
{ label: 'Storm Leads', value: summary.totalLeads, icon: Users, color: '#3B82F6' }, { label: 'Storm Leads', value: summary.totalLeads, icon: Users, color: '#3B82F6' },
{ label: 'Closed', value: summary.closedLeads, icon: Target, color: '#10B981' }, { label: 'Closed', value: summary.closedLeads, icon: Target, color: '#10B981' },
{ label: 'Conv. Rate', value: `${summary.conversionRate}%`, icon: TrendingUp, color: '#F59E0B' }, { label: 'Conv. Rate', value: `${summary.conversionRate}%`, icon: TrendingUp, color: '#F59E0B' },
{ label: 'Revenue', value: `$${(summary.totalRevenue/1000).toFixed(0)}k`, icon: DollarSign, color: '#8B5CF6' }, { 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: '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' }, { label: 'Avg Deal', value: `$${(summary.avgDealSize / 1000).toFixed(1)}k`, icon: CloudLightning, color: '#EC4899' },
]; ];
return ( return (
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-5 space-y-6"> <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"> <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
{statCards.map(({ label, value, icon: Icon, color }) => ( {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 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">
@@ -496,21 +711,13 @@ const AttributionView = ({ events, sessionLeads }) => {
))} ))}
</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"> <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> <p className="text-[12px] font-black uppercase tracking-wider text-zinc-400 mb-4">Leads Generated Per Storm</p>
<ResponsiveContainer width="100%" height={200}> <ResponsiveContainer width="100%" height={200}>
<BarChart data={chartData} barGap={4} barCategoryGap="30%"> <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} /> <CartesianGrid strokeDasharray="3 3" stroke={isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.06)'} vertical={false} />
<XAxis <XAxis dataKey="name" tick={{ fontSize: 10, fill: isDark ? '#71717a' : '#a1a1aa', fontWeight: 600 }} axisLine={false} tickLine={false} />
dataKey="name" <YAxis tick={{ fontSize: 10, fill: isDark ? '#71717a' : '#a1a1aa' }} axisLine={false} tickLine={false} width={24} />
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)' }} /> <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]}> <Bar dataKey="Leads" name="Generated" fill="#F59E0B" radius={[4, 4, 0, 0]}>
{chartData.map((entry, i) => ( {chartData.map((entry, i) => (
@@ -526,7 +733,6 @@ const AttributionView = ({ events, sessionLeads }) => {
</div> </div>
</div> </div>
{/* ── Per-storm breakdown ── */}
<div> <div>
<p className="text-[12px] font-black uppercase tracking-wider text-zinc-400 mb-3">Storm Revenue Breakdown</p> <p className="text-[12px] font-black uppercase tracking-wider text-zinc-400 mb-3">Storm Revenue Breakdown</p>
<div className="space-y-3"> <div className="space-y-3">
@@ -539,12 +745,10 @@ const AttributionView = ({ events, sessionLeads }) => {
return ( 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"> <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="h-0.5 w-full" style={{ backgroundColor: sty.dot }} />
<div className="px-4 py-3"> <div className="px-4 py-3">
<div className="flex items-start justify-between gap-3 mb-3"> <div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-2.5 min-w-0"> <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"> <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> <span className="text-[10px] font-black text-zinc-500">#{idx + 1}</span>
</div> </div>
@@ -560,7 +764,6 @@ const AttributionView = ({ events, sessionLeads }) => {
<p className="text-[11px] text-zinc-400 mt-0.5">{stormDate}{item.storm.maxHailSize ? ` · ${item.storm.maxHailSize}"` : ''}</p> <p className="text-[11px] text-zinc-400 mt-0.5">{stormDate}{item.storm.maxHailSize ? ` · ${item.storm.maxHailSize}"` : ''}</p>
</div> </div>
</div> </div>
{/* Revenue */}
<div className="text-right shrink-0"> <div className="text-right shrink-0">
<p className="text-lg font-black text-zinc-900 dark:text-white"> <p className="text-lg font-black text-zinc-900 dark:text-white">
{item.revenue > 0 ? `$${item.revenue.toLocaleString()}` : '—'} {item.revenue > 0 ? `$${item.revenue.toLocaleString()}` : '—'}
@@ -569,14 +772,13 @@ const AttributionView = ({ events, sessionLeads }) => {
</div> </div>
</div> </div>
{/* Stats row */}
<div className="flex items-center gap-4 mb-3 flex-wrap"> <div className="flex items-center gap-4 mb-3 flex-wrap">
{[ {[
{ label: 'Leads', value: item.leadsCount, color: null }, { label: 'Leads', value: item.leadsCount, color: null },
{ label: 'Closed', value: item.closedCount, color: '#10B981' }, { label: 'Closed', value: item.closedCount, color: '#10B981' },
{ label: 'Appointed', value: item.appointedCount, color: '#F59E0B' }, { label: 'Appointed', value: item.appointedCount, color: '#F59E0B' },
{ label: 'Conv %', value: `${item.conversionRate}%`, color: null }, { label: 'Conv %', value: `${item.conversionRate}%`, color: null },
{ label: 'Pipeline', value: item.pipeline > 0 ? `$${(item.pipeline/1000).toFixed(0)}k` : '—', color: '#F97316' }, { label: 'Pipeline', value: item.pipeline > 0 ? `$${(item.pipeline / 1000).toFixed(0)}k` : '—', color: '#F97316' },
].map(({ label, value, color }) => ( ].map(({ label, value, color }) => (
<div key={label} className="text-center"> <div key={label} className="text-center">
<p <p
@@ -590,7 +792,6 @@ const AttributionView = ({ events, sessionLeads }) => {
))} ))}
</div> </div>
{/* Revenue progress bar */}
{item.revenue > 0 && ( {item.revenue > 0 && (
<div className="h-1.5 w-full bg-zinc-100 dark:bg-zinc-800 rounded-full overflow-hidden"> <div className="h-1.5 w-full bg-zinc-100 dark:bg-zinc-800 rounded-full overflow-hidden">
<motion.div <motion.div
@@ -609,7 +810,6 @@ const AttributionView = ({ events, sessionLeads }) => {
</div> </div>
</div> </div>
{/* ── Best zone insight ── */}
{perStorm.length > 0 && perStorm[0].revenue > 0 && ( {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="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"> <div className="w-8 h-8 rounded-lg bg-emerald-500/15 flex items-center justify-center shrink-0">
@@ -619,7 +819,7 @@ const AttributionView = ({ events, sessionLeads }) => {
<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-[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"> <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].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.`} {perStorm[0].pipeline > 0 && ` $${(perStorm[0].pipeline / 1000).toFixed(0)}k more in pipeline.`}
</p> </p>
</div> </div>
</div> </div>
@@ -628,7 +828,7 @@ const AttributionView = ({ events, sessionLeads }) => {
); );
}; };
// ─── Filter bar ─────────────────────────────────────────────────────────────── // ─── Filter chip ──────────────────────────────────────────────────────────────
const FilterChip = ({ active, onClick, children }) => ( const FilterChip = ({ active, onClick, children }) => (
<button <button
@@ -647,21 +847,30 @@ const FilterChip = ({ active, onClick, children }) => (
const StormIntelPage = () => { const StormIntelPage = () => {
const { theme } = useTheme(); const { theme } = useTheme();
const { leads: storeLeads = [] } = useMockStore(); const { user } = useAuth();
const { leads: storeLeads = [], stormZones, addStormZone } = useMockStore();
const [activeTab, setActiveTab] = useState('map'); const canManageZones = ['ADMIN', 'OWNER'].includes(user?.role);
const [dateRange, setDateRange] = useState('6m');
const [typeFilter, setTypeFilter] = useState('all'); const [activeTab, setActiveTab] = useState('map');
const [severityFilter, setSeverityFilter] = useState('all'); const [dateRange, setDateRange] = useState('6m');
const [sortBy, setSortBy] = useState('date'); const [typeFilter, setTypeFilter] = useState('all');
const [selectedStorm, setSelectedStorm] = useState(null); const [severityFilter, setSeverityFilter] = useState('all');
const [drawerOpen, setDrawerOpen] = useState(false); const [sortBy, setSortBy] = useState('date');
const [flyCount, setFlyCount] = useState(0); const [selectedStorm, setSelectedStorm] = useState(null);
const [showFilters, setShowFilters] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [flyCount, setFlyCount] = useState(0);
const [showFilters, setShowFilters] = useState(false);
// Zone drawing state
const [isDrawing, setIsDrawing] = useState(false);
const [draftPolygon, setDraftPolygon] = useState([]);
const [drawingLinkedStormId, setDrawingLinkedStormId] = useState(null);
const [showZoneModal, setShowZoneModal] = 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 { events: allEvents } = useStormEvents({ dateRange: '1y' });
const { forecast, loading: forecastLoading, alertDay, hasStormAlert } = useStormForecast();
const handleSelect = useCallback((storm) => { const handleSelect = useCallback((storm) => {
setSelectedStorm(storm); setSelectedStorm(storm);
@@ -677,6 +886,40 @@ const StormIntelPage = () => {
setDrawerOpen(false); setDrawerOpen(false);
}, []); }, []);
const handleStartDrawing = useCallback((linkedStormId = null) => {
setIsDrawing(true);
setDraftPolygon([]);
setDrawingLinkedStormId(linkedStormId);
setDrawerOpen(false);
}, []);
const handleAddPoint = useCallback((pt) => {
setDraftPolygon(prev => [...prev, pt]);
}, []);
const handleFinishPolygon = useCallback(() => {
if (draftPolygon.length < 3) {
toast.error('Need at least 3 points to create a zone');
return;
}
setIsDrawing(false);
setShowZoneModal(true);
}, [draftPolygon]);
const handleCancelDrawing = useCallback(() => {
setIsDrawing(false);
setDraftPolygon([]);
setDrawingLinkedStormId(null);
}, []);
const handleSaveZone = useCallback((zoneData) => {
addStormZone({ ...zoneData, polygon: draftPolygon });
setShowZoneModal(false);
setDraftPolygon([]);
setDrawingLinkedStormId(null);
toast.success(`Zone "${zoneData.name}" saved and assigned to ${zoneData.assignedAgentName}`);
}, [addStormZone, draftPolygon]);
// Responsive map height // Responsive map height
const [mapHeight, setMapHeight] = useState('320px'); const [mapHeight, setMapHeight] = useState('320px');
useEffect(() => { useEffect(() => {
@@ -689,8 +932,6 @@ const StormIntelPage = () => {
return () => window.removeEventListener('resize', update); return () => window.removeEventListener('resize', update);
}, []); }, []);
const isDark = theme === 'dark';
return ( return (
<div className="flex flex-col h-full bg-zinc-50 dark:bg-zinc-950"> <div className="flex flex-col h-full bg-zinc-50 dark:bg-zinc-950">
@@ -703,9 +944,7 @@ const StormIntelPage = () => {
</div> </div>
<div> <div>
<h1 className="text-xl font-black text-zinc-900 dark:text-white tracking-tight">Storm Intel</h1> <h1 className="text-xl font-black text-zinc-900 dark:text-white tracking-tight">Storm Intel</h1>
<p className="text-[12px] text-zinc-400 dark:text-zinc-500"> <p className="text-[12px] text-zinc-400 dark:text-zinc-500">Plano TX · Collin County</p>
Plano TX · Collin County
</p>
</div> </div>
</div> </div>
@@ -727,9 +966,40 @@ const StormIntelPage = () => {
</p> </p>
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400">Hot Zones</p> <p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400">Hot Zones</p>
</div> </div>
{stormZones.length > 0 && (
<>
<div className="w-px h-8 bg-zinc-200 dark:bg-white/10" />
<div className="text-right">
<p className="text-xl font-black text-emerald-500">{stormZones.filter(z => z.status === 'active').length}</p>
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400">Active Zones</p>
</div>
</>
)}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Draw Zone button — Admin/Owner only */}
{canManageZones && activeTab === 'map' && !isDrawing && (
<button
onClick={() => handleStartDrawing(null)}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-amber-300 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300 text-[12px] font-bold hover:bg-amber-100 dark:hover:bg-amber-500/20 transition-colors"
>
<Pencil size={13} />
<span className="hidden sm:inline">Draw Zone</span>
</button>
)}
{/* Cancel drawing */}
{isDrawing && (
<button
onClick={handleCancelDrawing}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-red-200 dark:border-red-500/20 bg-red-50 dark:bg-red-500/10 text-red-600 dark:text-red-400 text-[12px] font-bold hover:bg-red-100 dark:hover:bg-red-500/20 transition-colors"
>
<XCircle size={13} />
Cancel
</button>
)}
{/* Tab switcher */} {/* Tab switcher */}
<div className="flex items-center p-1 rounded-xl gap-0.5 bg-zinc-100 dark:bg-zinc-800"> <div className="flex items-center p-1 rounded-xl gap-0.5 bg-zinc-100 dark:bg-zinc-800">
<button <button
@@ -756,7 +1026,6 @@ const StormIntelPage = () => {
</button> </button>
</div> </div>
{/* Mobile filter toggle — map tab only */}
{activeTab === 'map' && ( {activeTab === 'map' && (
<button <button
onClick={() => setShowFilters(f => !f)} onClick={() => setShowFilters(f => !f)}
@@ -781,27 +1050,20 @@ const StormIntelPage = () => {
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'}`}
> >
<div className="px-4 sm:px-6 py-3 flex flex-wrap gap-x-6 gap-y-2 items-center"> <div className="px-4 sm:px-6 py-3 flex flex-wrap gap-x-6 gap-y-2 items-center">
{/* Date range */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-[11px] font-bold text-zinc-400 uppercase tracking-wider mr-1">Range</span> <span className="text-[11px] font-bold text-zinc-400 uppercase tracking-wider mr-1">Range</span>
{[['1m', '1 Month'], ['3m', '3 Months'], ['6m', '6 Months'], ['1y', '1 Year']].map(([val, label]) => ( {[['1m', '1 Month'], ['3m', '3 Months'], ['6m', '6 Months'], ['1y', '1 Year']].map(([val, label]) => (
<FilterChip key={val} active={dateRange === val} onClick={() => setDateRange(val)}>{label}</FilterChip> <FilterChip key={val} active={dateRange === val} onClick={() => setDateRange(val)}>{label}</FilterChip>
))} ))}
</div> </div>
<div className="w-px h-5 bg-zinc-200 dark:bg-white/10 hidden sm:block" /> <div className="w-px h-5 bg-zinc-200 dark:bg-white/10 hidden sm:block" />
{/* Severity */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-[11px] font-bold text-zinc-400 uppercase tracking-wider mr-1">Severity</span> <span className="text-[11px] font-bold text-zinc-400 uppercase tracking-wider mr-1">Severity</span>
{[['all', 'All'], ['severe', 'Severe'], ['significant', 'Significant'], ['moderate', 'Moderate'], ['trace', 'Trace']].map(([val, label]) => ( {[['all', 'All'], ['severe', 'Severe'], ['significant', 'Significant'], ['moderate', 'Moderate'], ['trace', 'Trace']].map(([val, label]) => (
<FilterChip key={val} active={severityFilter === val} onClick={() => setSeverityFilter(val)}>{label}</FilterChip> <FilterChip key={val} active={severityFilter === val} onClick={() => setSeverityFilter(val)}>{label}</FilterChip>
))} ))}
</div> </div>
<div className="w-px h-5 bg-zinc-200 dark:bg-white/10 hidden sm:block" /> <div className="w-px h-5 bg-zinc-200 dark:bg-white/10 hidden sm:block" />
{/* Sort */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-[11px] font-bold text-zinc-400 uppercase tracking-wider mr-1">Sort</span> <span className="text-[11px] font-bold text-zinc-400 uppercase tracking-wider mr-1">Sort</span>
<FilterChip active={sortBy === 'date'} onClick={() => setSortBy('date')}>Newest</FilterChip> <FilterChip active={sortBy === 'date'} onClick={() => setSortBy('date')}>Newest</FilterChip>
@@ -812,6 +1074,16 @@ const StormIntelPage = () => {
)} )}
</AnimatePresence> </AnimatePresence>
{/* ── Forecast strip — map tab only ── */}
{activeTab === 'map' && (
<ForecastStrip
forecast={forecast}
loading={forecastLoading}
alertDay={alertDay}
hasStormAlert={hasStormAlert}
/>
)}
{/* ── Attribution tab ── */} {/* ── Attribution tab ── */}
{activeTab === 'attribution' && ( {activeTab === 'attribution' && (
<AttributionView events={allEvents} sessionLeads={storeLeads} /> <AttributionView events={allEvents} sessionLeads={storeLeads} />
@@ -849,13 +1121,17 @@ const StormIntelPage = () => {
onSelect={handleSelect} onSelect={handleSelect}
theme={theme} theme={theme}
flyCount={flyCount} flyCount={flyCount}
stormZones={stormZones}
isDrawing={isDrawing}
draftPolygon={draftPolygon}
onAddPoint={handleAddPoint}
onFinishPolygon={handleFinishPolygon}
/> />
)} )}
</div> </div>
{/* Storm list */} {/* Storm list */}
<div className="lg:w-[23rem] flex flex-col border-t lg:border-t-0 lg:border-l border-zinc-200 dark:border-white/[0.06] bg-white dark:bg-zinc-900/40 min-h-0"> <div className="lg:w-[23rem] flex flex-col border-t lg:border-t-0 lg:border-l border-zinc-200 dark:border-white/[0.06] bg-white dark:bg-zinc-900/40 min-h-0">
{/* List header */}
<div className="px-4 py-3 border-b border-zinc-100 dark:border-white/[0.06] shrink-0 flex items-center justify-between"> <div className="px-4 py-3 border-b border-zinc-100 dark:border-white/[0.06] shrink-0 flex items-center justify-between">
<p className="text-[12px] font-black uppercase tracking-wider text-zinc-500 dark:text-zinc-400"> <p className="text-[12px] font-black uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
{events.length} Event{events.length !== 1 ? 's' : ''} {events.length} Event{events.length !== 1 ? 's' : ''}
@@ -870,7 +1146,37 @@ const StormIntelPage = () => {
)} )}
</div> </div>
{/* Cards */} {/* Zone summary strip */}
{stormZones.length > 0 && (
<div className="px-4 py-2 border-b border-zinc-100 dark:border-white/[0.06] shrink-0 bg-zinc-50/50 dark:bg-white/[0.02]">
<div className="flex items-center justify-between mb-1.5">
<p className="text-[10px] font-black uppercase tracking-wider text-zinc-400">Canvasser Zones</p>
{canManageZones && (
<button
onClick={() => handleStartDrawing(null)}
className="text-[10px] font-bold text-amber-600 dark:text-amber-400 hover:underline flex items-center gap-0.5"
>
<Pencil size={10} /> New
</button>
)}
</div>
<div className="flex flex-wrap gap-1.5">
{stormZones.slice(0, 4).map(zone => (
<div key={zone.id} className="flex items-center gap-1.5 px-2 py-1 rounded-lg border border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-800/40">
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: zone.color }} />
<span className="text-[10px] font-semibold text-zinc-600 dark:text-zinc-300 max-w-[90px] truncate">{zone.name}</span>
{zone.status === 'completed' && <CheckCircle size={9} className="text-emerald-500 shrink-0" />}
</div>
))}
{stormZones.length > 4 && (
<div className="px-2 py-1 rounded-lg bg-zinc-100 dark:bg-zinc-800/40">
<span className="text-[10px] font-semibold text-zinc-400">+{stormZones.length - 4}</span>
</div>
)}
</div>
</div>
)}
<div className="flex-1 overflow-y-auto px-3 py-3 space-y-2"> <div className="flex-1 overflow-y-auto px-3 py-3 space-y-2">
{loading ? ( {loading ? (
Array.from({ length: 4 }).map((_, i) => ( Array.from({ length: 4 }).map((_, i) => (
@@ -901,6 +1207,18 @@ const StormIntelPage = () => {
open={drawerOpen} open={drawerOpen}
onClose={handleClose} onClose={handleClose}
onFlyTo={handleFlyTo} onFlyTo={handleFlyTo}
onAssignZone={handleStartDrawing}
canManageZones={canManageZones}
/>
{/* Zone assignment modal */}
<ZoneAssignmentModal
open={showZoneModal}
onClose={() => { setShowZoneModal(false); setDraftPolygon([]); setDrawingLinkedStormId(null); }}
onSave={handleSaveZone}
polygon={draftPolygon}
linkedStormId={drawingLinkedStormId}
events={allEvents}
/> />
</div> </div>
); );