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:
+433
-115
@@ -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 (
|
||||
<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 (
|
||||
<div className="relative w-full h-full storm-intel-map">
|
||||
<style>{MAP_STYLES}</style>
|
||||
@@ -91,11 +222,10 @@ const StormMap = memo(({ events, selectedId, onSelect, theme, flyCount, selected
|
||||
|
||||
<MapFlyTo storm={selectedStorm} flyCount={flyCount} />
|
||||
|
||||
{/* 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 (
|
||||
<Polygon
|
||||
@@ -110,9 +240,9 @@ const StormMap = memo(({ events, selectedId, onSelect, theme, flyCount, selected
|
||||
dashArray: isSelected ? undefined : '6 4',
|
||||
}}
|
||||
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 }); },
|
||||
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}
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Polygon>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Canvasser zones layer */}
|
||||
<ZoneLayer zones={stormZones} />
|
||||
|
||||
{/* Drawing layer (active only when isDrawing) */}
|
||||
{isDrawing && (
|
||||
<ZoneDrawingLayer
|
||||
draftPolygon={draftPolygon}
|
||||
onAddPoint={onAddPoint}
|
||||
onFinish={onFinishPolygon}
|
||||
/>
|
||||
)}
|
||||
</MapContainer>
|
||||
|
||||
{/* 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>
|
||||
</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>
|
||||
);
|
||||
});
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
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 */}
|
||||
<div className="h-1 w-full" style={{ backgroundColor: sty.dot }} />
|
||||
<div className="p-3">
|
||||
<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' }}>
|
||||
<TypeIcon size={15} style={{ color: sty.dot }} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Area + date */}
|
||||
<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">
|
||||
{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>
|
||||
|
||||
{/* Badges row */}
|
||||
<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}`}>
|
||||
{sty.label}
|
||||
</span>
|
||||
|
||||
{/* Hail size */}
|
||||
{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">
|
||||
{storm.maxHailSize}"
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Conversion window */}
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-md border ${conv.color} ${conv.bg} ${conv.border}`}>
|
||||
{conv.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Homes estimate */}
|
||||
{storm.estimatedHomes && (
|
||||
<div className="flex items-center gap-1 mt-1.5">
|
||||
<Home size={10} className="text-zinc-400" />
|
||||
@@ -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 */}
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
@@ -292,7 +515,6 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Drawer panel */}
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
@@ -303,10 +525,8 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {
|
||||
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 */}
|
||||
<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 gap-2">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Scrollable body */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
|
||||
{/* Area name */}
|
||||
<div>
|
||||
<h2 className="text-lg font-black text-zinc-900 dark:text-white leading-tight">
|
||||
{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>
|
||||
</div>
|
||||
|
||||
{/* Conversion window */}
|
||||
<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 === 'Warm' && <TrendingUp 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 === 'Hot Zone' && <Flame 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 === 'Too Fresh' && <Clock size={14} className={conv.color} />}
|
||||
<div>
|
||||
<p className={`text-[12px] font-bold ${conv.color}`}>{conv.label}</p>
|
||||
<p className="text-[10px] text-zinc-500 dark:text-zinc-400">
|
||||
{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'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{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">
|
||||
@@ -382,7 +598,6 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<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">
|
||||
@@ -390,7 +605,6 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Map action */}
|
||||
<button
|
||||
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"
|
||||
@@ -403,7 +617,6 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="px-4 py-3 border-t border-zinc-100 dark:border-white/[0.06] space-y-2 shrink-0">
|
||||
<button
|
||||
onClick={handleCreateLead}
|
||||
@@ -413,20 +626,24 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {
|
||||
<UserPlus size={15} />
|
||||
Create Lead from Zone
|
||||
</button>
|
||||
<button
|
||||
onClick={handleViewInDispatch}
|
||||
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]"
|
||||
>
|
||||
<Zap size={15} />
|
||||
Open in LynkDispatch
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
disabled
|
||||
>
|
||||
<Users size={15} />
|
||||
Assign Canvassers — Phase 2
|
||||
</button>
|
||||
|
||||
{canManageZones ? (
|
||||
<button
|
||||
onClick={() => { onClose(); onAssignZone(storm.id); }}
|
||||
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]"
|
||||
>
|
||||
<Pencil size={15} />
|
||||
Draw Canvasser Zone
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => navigate('/admin/dispatch')}
|
||||
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]"
|
||||
>
|
||||
<Zap size={15} />
|
||||
Open in LynkDispatch
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -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 (
|
||||
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-5 space-y-6">
|
||||
|
||||
{/* ── Summary cards ── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{statCards.map(({ label, value, icon: Icon, color }) => (
|
||||
<div key={label} className="rounded-xl border border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-900/60 px-4 py-3">
|
||||
@@ -496,21 +711,13 @@ const AttributionView = ({ events, sessionLeads }) => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Leads by storm chart ── */}
|
||||
<div className="rounded-xl border border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-900/60 p-4">
|
||||
<p className="text-[12px] font-black uppercase tracking-wider text-zinc-400 mb-4">Leads Generated Per Storm</p>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={chartData} barGap={4} barCategoryGap="30%">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.06)'} vertical={false} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 10, fill: isDark ? '#71717a' : '#a1a1aa', fontWeight: 600 }}
|
||||
axisLine={false} tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: isDark ? '#71717a' : '#a1a1aa' }}
|
||||
axisLine={false} tickLine={false} width={24}
|
||||
/>
|
||||
<XAxis dataKey="name" tick={{ fontSize: 10, fill: isDark ? '#71717a' : '#a1a1aa', fontWeight: 600 }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 10, fill: isDark ? '#71717a' : '#a1a1aa' }} axisLine={false} tickLine={false} width={24} />
|
||||
<RechartsTooltip content={<CustomTooltip />} cursor={{ fill: isDark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.03)' }} />
|
||||
<Bar dataKey="Leads" name="Generated" fill="#F59E0B" radius={[4, 4, 0, 0]}>
|
||||
{chartData.map((entry, i) => (
|
||||
@@ -526,7 +733,6 @@ const AttributionView = ({ events, sessionLeads }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Per-storm breakdown ── */}
|
||||
<div>
|
||||
<p className="text-[12px] font-black uppercase tracking-wider text-zinc-400 mb-3">Storm Revenue Breakdown</p>
|
||||
<div className="space-y-3">
|
||||
@@ -539,12 +745,10 @@ const AttributionView = ({ events, sessionLeads }) => {
|
||||
|
||||
return (
|
||||
<div key={item.storm.id ?? idx} className="rounded-xl border border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-900/60 overflow-hidden">
|
||||
{/* Severity bar */}
|
||||
<div className="h-0.5 w-full" style={{ backgroundColor: sty.dot }} />
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
{/* Rank */}
|
||||
<div className="w-6 h-6 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center shrink-0">
|
||||
<span className="text-[10px] font-black text-zinc-500">#{idx + 1}</span>
|
||||
</div>
|
||||
@@ -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>
|
||||
</div>
|
||||
</div>
|
||||
{/* Revenue */}
|
||||
<div className="text-right shrink-0">
|
||||
<p className="text-lg font-black text-zinc-900 dark:text-white">
|
||||
{item.revenue > 0 ? `$${item.revenue.toLocaleString()}` : '—'}
|
||||
@@ -569,14 +772,13 @@ const AttributionView = ({ events, sessionLeads }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="flex items-center gap-4 mb-3 flex-wrap">
|
||||
{[
|
||||
{ label: 'Leads', value: item.leadsCount, color: null },
|
||||
{ label: 'Closed', value: item.closedCount, color: '#10B981' },
|
||||
{ label: 'Appointed', value: item.appointedCount, color: '#F59E0B' },
|
||||
{ label: 'Conv %', value: `${item.conversionRate}%`, color: null },
|
||||
{ label: 'Pipeline', value: item.pipeline > 0 ? `$${(item.pipeline/1000).toFixed(0)}k` : '—', color: '#F97316' },
|
||||
{ label: 'Pipeline', value: item.pipeline > 0 ? `$${(item.pipeline / 1000).toFixed(0)}k` : '—', color: '#F97316' },
|
||||
].map(({ label, value, color }) => (
|
||||
<div key={label} className="text-center">
|
||||
<p
|
||||
@@ -590,7 +792,6 @@ const AttributionView = ({ events, sessionLeads }) => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Revenue progress bar */}
|
||||
{item.revenue > 0 && (
|
||||
<div className="h-1.5 w-full bg-zinc-100 dark:bg-zinc-800 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
@@ -609,7 +810,6 @@ const AttributionView = ({ events, sessionLeads }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Best zone insight ── */}
|
||||
{perStorm.length > 0 && perStorm[0].revenue > 0 && (
|
||||
<div className="rounded-xl border border-emerald-200 dark:border-emerald-500/20 bg-emerald-50 dark:bg-emerald-500/5 px-4 py-3 flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-emerald-500/15 flex items-center justify-center shrink-0">
|
||||
@@ -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-[13px] text-zinc-700 dark:text-zinc-300 font-semibold">
|
||||
{perStorm[0].storm.areaName} generated ${perStorm[0].revenue.toLocaleString()} from {perStorm[0].closedCount} closed deal{perStorm[0].closedCount !== 1 ? 's' : ''} ({perStorm[0].conversionRate}% conversion).
|
||||
{perStorm[0].pipeline > 0 && ` $${(perStorm[0].pipeline/1000).toFixed(0)}k more in pipeline.`}
|
||||
{perStorm[0].pipeline > 0 && ` $${(perStorm[0].pipeline / 1000).toFixed(0)}k more in pipeline.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -628,7 +828,7 @@ const AttributionView = ({ events, sessionLeads }) => {
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Filter bar ───────────────────────────────────────────────────────────────
|
||||
// ─── Filter chip ──────────────────────────────────────────────────────────────
|
||||
|
||||
const FilterChip = ({ active, onClick, children }) => (
|
||||
<button
|
||||
@@ -647,21 +847,30 @@ const FilterChip = ({ active, onClick, children }) => (
|
||||
|
||||
const StormIntelPage = () => {
|
||||
const { theme } = useTheme();
|
||||
const { leads: storeLeads = [] } = useMockStore();
|
||||
const { user } = useAuth();
|
||||
const { leads: storeLeads = [], stormZones, addStormZone } = useMockStore();
|
||||
|
||||
const [activeTab, setActiveTab] = useState('map');
|
||||
const [dateRange, setDateRange] = useState('6m');
|
||||
const [typeFilter, setTypeFilter] = useState('all');
|
||||
const [severityFilter, setSeverityFilter] = useState('all');
|
||||
const [sortBy, setSortBy] = useState('date');
|
||||
const [selectedStorm, setSelectedStorm] = useState(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [flyCount, setFlyCount] = useState(0);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const canManageZones = ['ADMIN', 'OWNER'].includes(user?.role);
|
||||
|
||||
const [activeTab, setActiveTab] = useState('map');
|
||||
const [dateRange, setDateRange] = useState('6m');
|
||||
const [typeFilter, setTypeFilter] = useState('all');
|
||||
const [severityFilter, setSeverityFilter] = useState('all');
|
||||
const [sortBy, setSortBy] = useState('date');
|
||||
const [selectedStorm, setSelectedStorm] = useState(null);
|
||||
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: allEvents } = useStormEvents({ dateRange: '1y' });
|
||||
const { events: allEvents } = useStormEvents({ dateRange: '1y' });
|
||||
const { forecast, loading: forecastLoading, alertDay, hasStormAlert } = useStormForecast();
|
||||
|
||||
const handleSelect = useCallback((storm) => {
|
||||
setSelectedStorm(storm);
|
||||
@@ -677,6 +886,40 @@ const StormIntelPage = () => {
|
||||
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
|
||||
const [mapHeight, setMapHeight] = useState('320px');
|
||||
useEffect(() => {
|
||||
@@ -689,8 +932,6 @@ const StormIntelPage = () => {
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, []);
|
||||
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-zinc-50 dark:bg-zinc-950">
|
||||
|
||||
@@ -703,9 +944,7 @@ const StormIntelPage = () => {
|
||||
</div>
|
||||
<div>
|
||||
<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">
|
||||
Plano TX · Collin County
|
||||
</p>
|
||||
<p className="text-[12px] text-zinc-400 dark:text-zinc-500">Plano TX · Collin County</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -727,9 +966,40 @@ const StormIntelPage = () => {
|
||||
</p>
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400">Hot Zones</p>
|
||||
</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 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 */}
|
||||
<div className="flex items-center p-1 rounded-xl gap-0.5 bg-zinc-100 dark:bg-zinc-800">
|
||||
<button
|
||||
@@ -756,7 +1026,6 @@ const StormIntelPage = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile filter toggle — map tab only */}
|
||||
{activeTab === 'map' && (
|
||||
<button
|
||||
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'}`}
|
||||
>
|
||||
<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">
|
||||
<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]) => (
|
||||
<FilterChip key={val} active={dateRange === val} onClick={() => setDateRange(val)}>{label}</FilterChip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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]) => (
|
||||
<FilterChip key={val} active={severityFilter === val} onClick={() => setSeverityFilter(val)}>{label}</FilterChip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
@@ -812,6 +1074,16 @@ const StormIntelPage = () => {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* ── Forecast strip — map tab only ── */}
|
||||
{activeTab === 'map' && (
|
||||
<ForecastStrip
|
||||
forecast={forecast}
|
||||
loading={forecastLoading}
|
||||
alertDay={alertDay}
|
||||
hasStormAlert={hasStormAlert}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Attribution tab ── */}
|
||||
{activeTab === 'attribution' && (
|
||||
<AttributionView events={allEvents} sessionLeads={storeLeads} />
|
||||
@@ -849,13 +1121,17 @@ const StormIntelPage = () => {
|
||||
onSelect={handleSelect}
|
||||
theme={theme}
|
||||
flyCount={flyCount}
|
||||
stormZones={stormZones}
|
||||
isDrawing={isDrawing}
|
||||
draftPolygon={draftPolygon}
|
||||
onAddPoint={handleAddPoint}
|
||||
onFinishPolygon={handleFinishPolygon}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{/* 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">
|
||||
<p className="text-[12px] font-black uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
|
||||
{events.length} Event{events.length !== 1 ? 's' : ''}
|
||||
@@ -870,7 +1146,37 @@ const StormIntelPage = () => {
|
||||
)}
|
||||
</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">
|
||||
{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 */}
|
||||
<ZoneAssignmentModal
|
||||
open={showZoneModal}
|
||||
onClose={() => { setShowZoneModal(false); setDraftPolygon([]); setDrawingLinkedStormId(null); }}
|
||||
onSave={handleSaveZone}
|
||||
polygon={draftPolygon}
|
||||
linkedStormId={drawingLinkedStormId}
|
||||
events={allEvents}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user