From 12bd2fe0dea5ee8a3e11deed3cf9f844e83a397b Mon Sep 17 00:00:00 2001 From: Satyam-Rastogi Date: Mon, 18 May 2026 18:06:17 +0530 Subject: [PATCH] =?UTF-8?q?feat(storm-intel):=20Phase=204+5=20=E2=80=94=20?= =?UTF-8?q?Tomorrow.io=20forecast=20overlay=20and=20canvasser=20zone=20ass?= =?UTF-8?q?ignment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- api/storm-forecast.js | 117 ++++ src/App.jsx | 9 + src/components/Layout.jsx | 1 + src/components/storm/ZoneAssignmentModal.jsx | 195 +++++++ src/data/mockStore.jsx | 61 +++ src/hooks/useStormForecast.js | 68 +++ src/pages/FieldStormZonePage.jsx | 315 +++++++++++ src/pages/StormIntelPage.jsx | 548 +++++++++++++++---- 8 files changed, 1199 insertions(+), 115 deletions(-) create mode 100644 api/storm-forecast.js create mode 100644 src/components/storm/ZoneAssignmentModal.jsx create mode 100644 src/hooks/useStormForecast.js create mode 100644 src/pages/FieldStormZonePage.jsx diff --git a/api/storm-forecast.js b/api/storm-forecast.js new file mode 100644 index 0000000..01f2f9b --- /dev/null +++ b/api/storm-forecast.js @@ -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}×teps=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: 0–100 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([]); + } +} diff --git a/src/App.jsx b/src/App.jsx index 74f8351..5a2f3a1 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -37,6 +37,7 @@ import EstimatesPage from './pages/EstimatesPage'; import KanbanPage from './pages/KanbanPage'; import LeadProjectPage from './pages/LeadProjectPage'; import StormIntelPage from './pages/StormIntelPage'; +import FieldStormZonePage from './pages/FieldStormZonePage'; // ... (existing imports) const ProtectedRoute = ({ children, allowedRoles }) => { @@ -279,6 +280,14 @@ function App() { } /> + + + + } + /> { { to: "/emp/fa/maps", icon: Map, label: "My Map" }, { to: "/emp/fa/leads", icon: ClipboardList, label: "Leads" }, { to: "/emp/fa/kanban", icon: LayoutGrid, label: "Pipeline" }, + { to: "/field/storm-zones", icon: CloudLightning, label: "Storm Zones" }, { to: "/emp/fa/pro-canvas", icon: LayoutDashboard, diff --git a/src/components/storm/ZoneAssignmentModal.jsx b/src/components/storm/ZoneAssignmentModal.jsx new file mode 100644 index 0000000..d922ce3 --- /dev/null +++ b/src/components/storm/ZoneAssignmentModal.jsx @@ -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 ( + + {open && ( + <> + + + {/* Color accent bar */} +
+ + {/* Header */} +
+
+
+ +
+
+

Save Canvasser Zone

+

{polygon.length} vertices drawn

+
+
+ +
+ + {/* Body */} +
+ + {/* Zone name */} +
+ + 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" + /> +
+ + {/* Agent assignment */} +
+ +
+ {DISPATCH_REPS.map(rep => ( + + ))} +
+
+ + {/* Zone color */} +
+ +
+ {ZONE_COLORS.map(c => ( +
+
+ + {/* Linked storm (optional) */} + {events.length > 0 && ( +
+ + +
+ )} +
+ + {/* Footer */} +
+ + +
+ + + )} + + ); +}; + +export default ZoneAssignmentModal; diff --git a/src/data/mockStore.jsx b/src/data/mockStore.jsx index f36549e..bcc9112 100644 --- a/src/data/mockStore.jsx +++ b/src/data/mockStore.jsx @@ -5080,6 +5080,7 @@ export const MockStoreProvider = ({ children }) => { const [templateAccessExcluded, setTemplateAccessExcluded] = useState([]); const [kanbanColumns, setKanbanColumns] = useState(KANBAN_COLUMNS_INITIAL); const [kanbanLeads, setKanbanLeads] = useState(KANBAN_LEADS_INITIAL); + const [stormZones, setStormZones] = useState(INITIAL_STORM_ZONES); // Commission Settings — Org defaults + per-user overrides const [orgCommissionDefaults, setOrgCommissionDefaults] = useState({ @@ -5396,6 +5397,20 @@ export const MockStoreProvider = ({ children }) => { removeUserCommissionOverride: (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) => { 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 ─ // Each lead has a stormSource { id, areaName, date, maxHailSize, severity } and // 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 = [ // ── 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' } }, diff --git a/src/hooks/useStormForecast.js b/src/hooks/useStormForecast.js new file mode 100644 index 0000000..e2069af --- /dev/null +++ b/src/hooks/useStormForecast.js @@ -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 }; +} diff --git a/src/pages/FieldStormZonePage.jsx b/src/pages/FieldStormZonePage.jsx new file mode 100644 index 0000000..4d2844e --- /dev/null +++ b/src/pages/FieldStormZonePage.jsx @@ -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 ( +
+ + + + {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 ( + + + +
+

{zone.name}

+

{zone.status === 'completed' ? 'Completed' : 'Active'}

+
+
+
+ + +

{zone.name}

+
+
+
+ ); + })} +
+
+ ); +}); +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 ( +
+ {/* Color bar */} +
+ +
+ {/* Zone header */} +
+
+
+ +
+
+

{zone.name}

+
+
+ {zone.status === 'completed' + ? <> Completed + : <> Active + } +
+
+
+
+
+ + {/* Assigned agent */} +
+
+ {zone.assignedAgentName?.split(' ').map(n => n[0]).join('') ?? '?'} +
+
+

{zone.assignedAgentName}

+

Assigned canvasser

+
+
+ + {/* Linked storm */} + {linkedStorm && ( +
+
+ + + {sevSty?.label} — {linkedStorm.maxHailSize}" Hail + +
+

{linkedStorm.areaName}

+

+ {new Date(linkedStorm.date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })} + {' · '}~{linkedStorm.estimatedHomes?.toLocaleString() ?? '?'} homes +

+ {conv && ( +
+ {conv.label} +
+ )} +
+ )} + + {/* Zone metadata */} +
+ {zone.polygon?.length ?? 0} vertices + · + Created {new Date(zone.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} +
+
+
+ ); +}; + +// ─── 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 ( +
+ + {/* ── Header ── */} +
+
+
+ +
+
+

My Storm Zones

+

+ {activeZones.length} active · {completedZones.length} completed +

+
+
+
+ + {stormZones.length === 0 ? ( + /* Empty state */ +
+
+ +
+

No zones assigned yet

+

+ Your manager will draw and assign canvasser zones after a storm event. Check back after the next alert. +

+
+ ) : ( +
+ + {/* Map overview */} +
+ +
+ + {/* High-risk alert */} + {activeZones.some(z => { + const storm = getLinkedStorm(z); + return storm && conversionWindow(storm.date).label === 'Hot Zone'; + }) && ( +
+ +
+

Peak Canvassing Window

+

+ One or more of your zones is in the hot conversion window (7–60 days post-storm). This is the optimal time to canvass. +

+
+
+ )} + + {/* Active zones */} + {activeZones.length > 0 && ( +
+
+
+

+ Active Zones ({activeZones.length}) +

+
+
+ {activeZones.map(zone => ( + + ))} +
+
+ )} + + {/* Completed zones */} + {completedZones.length > 0 && ( +
+
+ +

+ Completed ({completedZones.length}) +

+
+
+ {completedZones.map(zone => ( + + ))} +
+
+ )} +
+ )} +
+ ); +}; + +export default FieldStormZonePage; diff --git a/src/pages/StormIntelPage.jsx b/src/pages/StormIntelPage.jsx index 6b8af34..6494e66 100644 --- a/src/pages/StormIntelPage.jsx +++ b/src/pages/StormIntelPage.jsx @@ -1,7 +1,10 @@ import React, { useState, useCallback, useRef, useEffect, memo } from 'react'; import { useNavigate } from 'react-router-dom'; 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 'leaflet/dist/leaflet.css'; import { @@ -12,12 +15,17 @@ import { CloudLightning, Wind, Zap, Droplets, Filter, X, ChevronRight, Home, AlertTriangle, TrendingUp, Users, Calendar, MapPin, Flame, Snowflake, Clock, ArrowRight, Eye, UserPlus, Loader2, - DollarSign, Target, BarChart2, Map, + DollarSign, Target, BarChart2, Map, Pencil, Sun, Cloud, + CloudRain, CloudDrizzle, CheckCircle, XCircle, } from 'lucide-react'; +import { toast } from 'sonner'; import { useTheme } from '../context/ThemeContext'; import { useMockStore } from '../data/mockStore'; import { useStormEvents, conversionWindow } from '../hooks/useStormEvents'; import { useStormAttribution } from '../hooks/useStormAttribution'; +import { useStormForecast } from '../hooks/useStormForecast'; +import ZoneAssignmentModal from '../components/storm/ZoneAssignmentModal'; +import { useAuth } from '../context/AuthContext'; // ─── Constants ─────────────────────────────────────────────────────────────── @@ -38,11 +46,28 @@ const TYPE_ICONS = { 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 = ` .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::before { display: none !important; } - @keyframes stormPulse { 0%,100% { opacity: 0.7; } 50% { opacity: 1; } } + .draw-cursor .leaflet-container { cursor: crosshair !important; } `; // ─── Map sub-components ─────────────────────────────────────────────────────── @@ -71,7 +96,113 @@ const MapFlyTo = ({ storm, flyCount }) => { 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 ( + + + +
+

{zone.name}

+

+ {zone.assignedAgentName} · {zone.status === 'completed' ? 'Completed' : 'Active'} +

+
+
+
+ {/* Agent initials circle at centroid */} + + +

{zone.name}

+
+
+
+ ); + }); +}; + +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 ( + <> + + {/* Fill preview */} + {draftPolygon.length >= 3 && ( + + )} + {/* Vertex dots */} + {draftPolygon.map((pt, i) => ( + + ))} + + ); +}; + +const StormMap = memo(({ + events, selectedId, onSelect, theme, flyCount, selectedStorm, + stormZones, isDrawing, draftPolygon, onAddPoint, onFinishPolygon, +}) => { return (
@@ -91,11 +222,10 @@ const StormMap = memo(({ events, selectedId, onSelect, theme, flyCount, selected + {/* Existing storm polygons */} {events.map(storm => { const sty = SEVERITY_STYLES[storm.severity] ?? SEVERITY_STYLES.trace; 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 ( onSelect(storm), + click: () => !isDrawing && onSelect(storm), 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; e.target.setStyle({ fillOpacity: sty.fillOpacity, weight: sty.weight }); }, @@ -134,13 +264,25 @@ const StormMap = memo(({ events, selectedId, onSelect, theme, flyCount, selected {storm.areaName}

- {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

); })} + + {/* Canvasser zones layer */} + + + {/* Drawing layer (active only when isDrawing) */} + {isDrawing && ( + + )} {/* Legend */} @@ -165,11 +307,107 @@ const StormMap = memo(({ events, selectedId, onSelect, theme, flyCount, selected {events.length} storm{events.length !== 1 ? 's' : ''}
+ + {/* Drawing instructions */} + {isDrawing && ( +
+
+ + {draftPolygon.length < 3 + ? `Click to place vertices (${draftPolygon.length}/3 min)` + : `Double-click to close polygon (${draftPolygon.length} pts)` + } +
+
+ )}
); }); StormMap.displayName = 'StormMap'; +// ─── Forecast strip ─────────────────────────────────────────────────────────── + +const ForecastStrip = ({ forecast, loading, alertDay, hasStormAlert }) => { + if (loading) { + return ( +
+
+ {Array.from({ length: 7 }).map((_, i) => ( +
+ ))} +
+
+ ); + } + if (!forecast.length) return null; + + return ( +
+ {/* Storm alert banner */} + {hasStormAlert && alertDay && ( +
+ +
+ High-Risk Storm Day + + {new Date(alertDay.date).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })} + {' — '}{alertDay.weatherLabel} · {alertDay.windGust} mph gusts · {alertDay.precipProb}% rain + +
+ Score {alertDay.stormScore} +
+ )} + + {/* 7-day forecast row */} +
+ 7-Day + {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 ( +
+ + {dayLabel} + + + + {day.tempF}° + + {/* Storm score bar */} +
+
+
+ + {day.precipProb}% rain + +
+ ); + })} +
+
+ ); +}; + // ─── Storm card ─────────────────────────────────────────────────────────────── 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' }`} > - {/* Severity bar */}
- {/* Icon */}
- {/* Area + date */}

{storm.areaName} @@ -210,27 +445,20 @@ const StormCard = ({ storm, isSelected, onSelect }) => {

{dateLabel}

- {/* Badges row */}
- {/* Severity */} {sty.label} - - {/* Hail size */} {storm.maxHailSize && ( {storm.maxHailSize}" )} - - {/* Conversion window */} {conv.label}
- {/* Homes estimate */} {storm.estimatedHomes && (
@@ -248,7 +476,7 @@ const StormCard = ({ storm, isSelected, onSelect }) => { // ─── Detail drawer ──────────────────────────────────────────────────────────── -const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => { +const StormDetailDrawer = ({ storm, open, onClose, onFlyTo, onAssignZone, canManageZones }) => { const navigate = useNavigate(); const { theme } = useTheme(); @@ -274,13 +502,8 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => { }); }; - const handleViewInDispatch = () => { - navigate('/admin/dispatch'); - }; - return ( <> - {/* Backdrop on mobile */} {open && ( { )} - {/* Drawer panel */} {open && ( { 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" > - {/* Severity bar */}
- {/* Header */}
@@ -327,9 +547,7 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {
- {/* Scrollable body */}
- {/* Area name */}

{storm.areaName} @@ -337,24 +555,22 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {

{storm.county} County, TX

- {/* Conversion window */}
- {conv.label === 'Hot Zone' && } - {conv.label === 'Warm' && } - {conv.label === 'Cold' && } - {conv.label === 'Too Fresh' && } + {conv.label === 'Hot Zone' && } + {conv.label === 'Warm' && } + {conv.label === 'Cold' && } + {conv.label === 'Too Fresh' && }

{conv.label}

- {conv.label === 'Hot Zone' && 'Peak canvassing window — highest conversion likelihood'} - {conv.label === 'Warm' && 'Still worth canvassing — moderate conversion rate'} - {conv.label === 'Cold' && 'Low conversion — storm damage assessment mostly done'} + {conv.label === 'Hot Zone' && 'Peak canvassing window — highest conversion likelihood'} + {conv.label === 'Warm' && 'Still worth canvassing — moderate conversion rate'} + {conv.label === 'Cold' && 'Low conversion — storm damage assessment mostly done'} {conv.label === 'Too Fresh' && 'Too recent — wait for claims process to begin'}

- {/* Stats grid */}
{storm.maxHailSize && (
@@ -382,7 +598,6 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {
- {/* Description */}

Intel

@@ -390,7 +605,6 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {

- {/* Map action */}
- {/* Action buttons */}
- - + + {canManageZones ? ( + + ) : ( + + )}
)} @@ -464,26 +681,24 @@ const AttributionView = ({ events, sessionLeads }) => { 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, + name: (p.storm.areaName ?? 'Unknown').split('/')[0].trim().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' }, + { 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 (
- - {/* ── Summary cards ── */}
{statCards.map(({ label, value, icon: Icon, color }) => (
@@ -496,21 +711,13 @@ const AttributionView = ({ events, sessionLeads }) => { ))}
- {/* ── Leads by storm chart ── */}

Leads Generated Per Storm

- - + + } cursor={{ fill: isDark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.03)' }} /> {chartData.map((entry, i) => ( @@ -526,7 +733,6 @@ const AttributionView = ({ events, sessionLeads }) => {
- {/* ── Per-storm breakdown ── */}

Storm Revenue Breakdown

@@ -539,12 +745,10 @@ const AttributionView = ({ events, sessionLeads }) => { return (
- {/* Severity bar */}
- {/* Rank */}
#{idx + 1}
@@ -560,7 +764,6 @@ const AttributionView = ({ events, sessionLeads }) => {

{stormDate}{item.storm.maxHailSize ? ` · ${item.storm.maxHailSize}"` : ''}

- {/* Revenue */}

{item.revenue > 0 ? `$${item.revenue.toLocaleString()}` : '—'} @@ -569,14 +772,13 @@ const AttributionView = ({ events, sessionLeads }) => {

- {/* Stats row */}
{[ { 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' }, + { label: 'Pipeline', value: item.pipeline > 0 ? `$${(item.pipeline / 1000).toFixed(0)}k` : '—', color: '#F97316' }, ].map(({ label, value, color }) => (

{ ))}

- {/* Revenue progress bar */} {item.revenue > 0 && (
{
- {/* ── Best zone insight ── */} {perStorm.length > 0 && perStorm[0].revenue > 0 && (
@@ -619,7 +819,7 @@ const AttributionView = ({ events, sessionLeads }) => {

Top Performing Zone

{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.`}

@@ -628,7 +828,7 @@ const AttributionView = ({ events, sessionLeads }) => { ); }; -// ─── Filter bar ─────────────────────────────────────────────────────────────── +// ─── Filter chip ────────────────────────────────────────────────────────────── const FilterChip = ({ active, onClick, children }) => (
@@ -727,9 +966,40 @@ const StormIntelPage = () => {

Hot Zones

+ {stormZones.length > 0 && ( + <> +
+
+

{stormZones.filter(z => z.status === 'active').length}

+

Active Zones

+
+ + )}
+ {/* Draw Zone button — Admin/Owner only */} + {canManageZones && activeTab === 'map' && !isDrawing && ( + + )} + + {/* Cancel drawing */} + {isDrawing && ( + + )} + {/* Tab switcher */}
- {/* Mobile filter toggle — map tab only */} {activeTab === 'map' && ( + )} +
+
+ {stormZones.slice(0, 4).map(zone => ( +
+
+ {zone.name} + {zone.status === 'completed' && } +
+ ))} + {stormZones.length > 4 && ( +
+ +{stormZones.length - 4} +
+ )} +
+
+ )} +
{loading ? ( Array.from({ length: 4 }).map((_, i) => ( @@ -901,6 +1207,18 @@ const StormIntelPage = () => { open={drawerOpen} onClose={handleClose} onFlyTo={handleFlyTo} + onAssignZone={handleStartDrawing} + canManageZones={canManageZones} + /> + + {/* Zone assignment modal */} + { setShowZoneModal(false); setDraftPolygon([]); setDrawingLinkedStormId(null); }} + onSave={handleSaveZone} + polygon={draftPolygon} + linkedStormId={drawingLinkedStormId} + events={allEvents} />
);