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:
@@ -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([]);
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/field/storm-zones"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['FIELD_AGENT', 'ADMIN', 'OWNER']}>
|
||||
<FieldStormZonePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/maps"
|
||||
element={
|
||||
|
||||
@@ -191,6 +191,7 @@ const Layout = () => {
|
||||
{ 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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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' } },
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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='© <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 (7–60 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;
|
||||
+403
-85
@@ -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,7 +240,7 @@ 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) => {
|
||||
if (storm.id === selectedId) return;
|
||||
@@ -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,7 +555,6 @@ 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} />}
|
||||
@@ -354,7 +571,6 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {
|
||||
</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>
|
||||
|
||||
{canManageZones ? (
|
||||
<button
|
||||
onClick={handleViewInDispatch}
|
||||
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>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -464,7 +681,7 @@ 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,
|
||||
name: (p.storm.areaName ?? 'Unknown').split('/')[0].trim().replace('Plano', '').trim() || p.storm.areaName,
|
||||
Leads: p.leadsCount,
|
||||
Closed: p.closedCount,
|
||||
stormId: p.storm.id,
|
||||
@@ -475,15 +692,13 @@ const AttributionView = ({ events, sessionLeads }) => {
|
||||
{ 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: '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,7 +847,10 @@ const FilterChip = ({ active, onClick, children }) => (
|
||||
|
||||
const StormIntelPage = () => {
|
||||
const { theme } = useTheme();
|
||||
const { leads: storeLeads = [] } = useMockStore();
|
||||
const { user } = useAuth();
|
||||
const { leads: storeLeads = [], stormZones, addStormZone } = useMockStore();
|
||||
|
||||
const canManageZones = ['ADMIN', 'OWNER'].includes(user?.role);
|
||||
|
||||
const [activeTab, setActiveTab] = useState('map');
|
||||
const [dateRange, setDateRange] = useState('6m');
|
||||
@@ -659,9 +862,15 @@ const StormIntelPage = () => {
|
||||
const [flyCount, setFlyCount] = useState(0);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
// Always load all events (attribution uses the full set regardless of date filter)
|
||||
// Zone drawing state
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [draftPolygon, setDraftPolygon] = useState([]);
|
||||
const [drawingLinkedStormId, setDrawingLinkedStormId] = useState(null);
|
||||
const [showZoneModal, setShowZoneModal] = useState(false);
|
||||
|
||||
const { events, loading, error, totalHomes } = useStormEvents({ dateRange, typeFilter, severityFilter, sortBy });
|
||||
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