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 KanbanPage from './pages/KanbanPage';
|
||||||
import LeadProjectPage from './pages/LeadProjectPage';
|
import LeadProjectPage from './pages/LeadProjectPage';
|
||||||
import StormIntelPage from './pages/StormIntelPage';
|
import StormIntelPage from './pages/StormIntelPage';
|
||||||
|
import FieldStormZonePage from './pages/FieldStormZonePage';
|
||||||
|
|
||||||
// ... (existing imports)
|
// ... (existing imports)
|
||||||
const ProtectedRoute = ({ children, allowedRoles }) => {
|
const ProtectedRoute = ({ children, allowedRoles }) => {
|
||||||
@@ -279,6 +280,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/field/storm-zones"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute allowedRoles={['FIELD_AGENT', 'ADMIN', 'OWNER']}>
|
||||||
|
<FieldStormZonePage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin/maps"
|
path="/admin/maps"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ const Layout = () => {
|
|||||||
{ to: "/emp/fa/maps", icon: Map, label: "My Map" },
|
{ to: "/emp/fa/maps", icon: Map, label: "My Map" },
|
||||||
{ to: "/emp/fa/leads", icon: ClipboardList, label: "Leads" },
|
{ to: "/emp/fa/leads", icon: ClipboardList, label: "Leads" },
|
||||||
{ to: "/emp/fa/kanban", icon: LayoutGrid, label: "Pipeline" },
|
{ to: "/emp/fa/kanban", icon: LayoutGrid, label: "Pipeline" },
|
||||||
|
{ to: "/field/storm-zones", icon: CloudLightning, label: "Storm Zones" },
|
||||||
{
|
{
|
||||||
to: "/emp/fa/pro-canvas",
|
to: "/emp/fa/pro-canvas",
|
||||||
icon: LayoutDashboard,
|
icon: LayoutDashboard,
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { X, MapPin, Check } from 'lucide-react';
|
||||||
|
import { DISPATCH_REPS } from '../../data/mockStore';
|
||||||
|
|
||||||
|
const ZONE_COLORS = [
|
||||||
|
'#F59E0B', '#8B5CF6', '#10B981', '#3B82F6',
|
||||||
|
'#EF4444', '#EC4899', '#06B6D4', '#F97316',
|
||||||
|
];
|
||||||
|
|
||||||
|
const ZoneAssignmentModal = ({ open, onClose, onSave, polygon = [], linkedStormId, events = [] }) => {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [agentId, setAgentId] = useState('');
|
||||||
|
const [color, setColor] = useState(ZONE_COLORS[0]);
|
||||||
|
const [stormId, setStormId] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setName('');
|
||||||
|
setAgentId('');
|
||||||
|
setColor(ZONE_COLORS[0]);
|
||||||
|
setStormId(linkedStormId ?? '');
|
||||||
|
}
|
||||||
|
}, [open, linkedStormId]);
|
||||||
|
|
||||||
|
const agent = DISPATCH_REPS.find(r => r.id === agentId);
|
||||||
|
const canSave = name.trim().length > 0 && agentId;
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!canSave) return;
|
||||||
|
onSave({
|
||||||
|
name: name.trim(),
|
||||||
|
assignedAgentId: agentId,
|
||||||
|
assignedAgentName: agent?.name ?? '',
|
||||||
|
color,
|
||||||
|
stormId: stormId || null,
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
key="zam-backdrop"
|
||||||
|
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 bg-black/40 backdrop-blur-[2px] z-[60]"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
key="zam-modal"
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 380, damping: 32 }}
|
||||||
|
className="fixed inset-x-4 top-[8%] sm:inset-auto sm:left-1/2 sm:-translate-x-1/2 sm:top-[12%] sm:w-[420px] z-[61] bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-200 dark:border-white/[0.08] shadow-2xl overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Color accent bar */}
|
||||||
|
<div className="h-1 w-full" style={{ backgroundColor: color }} />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-100 dark:border-white/[0.06]">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-8 h-8 rounded-lg flex items-center justify-center" style={{ backgroundColor: color + '22' }}>
|
||||||
|
<MapPin size={16} style={{ color }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[14px] font-black text-zinc-900 dark:text-white">Save Canvasser Zone</h2>
|
||||||
|
<p className="text-[11px] text-zinc-400">{polygon.length} vertices drawn</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-zinc-100 dark:hover:bg-white/5 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="px-5 py-4 space-y-4 max-h-[60vh] overflow-y-auto">
|
||||||
|
|
||||||
|
{/* Zone name */}
|
||||||
|
<div>
|
||||||
|
<label className="text-[11px] font-bold uppercase tracking-wider text-zinc-400 block mb-1.5">Zone Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
placeholder="e.g. Spring Creek Zone A"
|
||||||
|
autoFocus
|
||||||
|
className="w-full px-3 py-2.5 rounded-xl border border-zinc-200 dark:border-white/[0.08] bg-zinc-50 dark:bg-zinc-800/60 text-[13px] text-zinc-900 dark:text-white placeholder-zinc-400 outline-none focus:ring-2 focus:ring-amber-500/40 focus:border-amber-400 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agent assignment */}
|
||||||
|
<div>
|
||||||
|
<label className="text-[11px] font-bold uppercase tracking-wider text-zinc-400 block mb-1.5">Assign Canvasser</label>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{DISPATCH_REPS.map(rep => (
|
||||||
|
<button
|
||||||
|
key={rep.id}
|
||||||
|
onClick={() => setAgentId(rep.id)}
|
||||||
|
className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-xl border text-left transition-all ${
|
||||||
|
agentId === rep.id
|
||||||
|
? 'border-amber-400 bg-amber-50 dark:bg-amber-500/10'
|
||||||
|
: 'border-zinc-200 dark:border-white/[0.07] hover:border-zinc-300 dark:hover:border-white/15 bg-white dark:bg-zinc-800/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-7 h-7 rounded-full flex items-center justify-center text-white text-[10px] font-black shrink-0"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
>
|
||||||
|
{rep.initials}
|
||||||
|
</div>
|
||||||
|
<span className={`text-[13px] font-semibold flex-1 ${
|
||||||
|
agentId === rep.id
|
||||||
|
? 'text-amber-700 dark:text-amber-300'
|
||||||
|
: 'text-zinc-700 dark:text-zinc-200'
|
||||||
|
}`}>
|
||||||
|
{rep.name}
|
||||||
|
</span>
|
||||||
|
{agentId === rep.id && <Check size={14} className="text-amber-500 shrink-0" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zone color */}
|
||||||
|
<div>
|
||||||
|
<label className="text-[11px] font-bold uppercase tracking-wider text-zinc-400 block mb-1.5">Zone Color</label>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{ZONE_COLORS.map(c => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
className="w-7 h-7 rounded-full transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: c,
|
||||||
|
outline: color === c ? `3px solid ${c}` : '2px solid transparent',
|
||||||
|
outlineOffset: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Linked storm (optional) */}
|
||||||
|
{events.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="text-[11px] font-bold uppercase tracking-wider text-zinc-400 block mb-1.5">
|
||||||
|
Linked Storm <span className="font-normal normal-case tracking-normal text-zinc-300 dark:text-zinc-600">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={stormId}
|
||||||
|
onChange={e => setStormId(e.target.value)}
|
||||||
|
className="w-full px-3 py-2.5 rounded-xl border border-zinc-200 dark:border-white/[0.08] bg-zinc-50 dark:bg-zinc-800/60 text-[13px] text-zinc-900 dark:text-white outline-none focus:ring-2 focus:ring-amber-500/40 focus:border-amber-400 transition-all"
|
||||||
|
>
|
||||||
|
<option value="">No storm linked</option>
|
||||||
|
{events.map(ev => (
|
||||||
|
<option key={ev.id} value={ev.id}>
|
||||||
|
{ev.areaName} — {new Date(ev.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-5 py-3 border-t border-zinc-100 dark:border-white/[0.06] flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 px-4 py-2.5 rounded-xl border border-zinc-200 dark:border-white/[0.07] text-[13px] font-bold text-zinc-600 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!canSave}
|
||||||
|
className="flex-1 px-4 py-2.5 rounded-xl text-[13px] font-bold text-white transition-all hover:opacity-90 active:scale-[0.98] disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
style={{ background: `linear-gradient(135deg, ${color}, ${color}bb)` }}
|
||||||
|
>
|
||||||
|
Save Zone
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ZoneAssignmentModal;
|
||||||
@@ -5080,6 +5080,7 @@ export const MockStoreProvider = ({ children }) => {
|
|||||||
const [templateAccessExcluded, setTemplateAccessExcluded] = useState([]);
|
const [templateAccessExcluded, setTemplateAccessExcluded] = useState([]);
|
||||||
const [kanbanColumns, setKanbanColumns] = useState(KANBAN_COLUMNS_INITIAL);
|
const [kanbanColumns, setKanbanColumns] = useState(KANBAN_COLUMNS_INITIAL);
|
||||||
const [kanbanLeads, setKanbanLeads] = useState(KANBAN_LEADS_INITIAL);
|
const [kanbanLeads, setKanbanLeads] = useState(KANBAN_LEADS_INITIAL);
|
||||||
|
const [stormZones, setStormZones] = useState(INITIAL_STORM_ZONES);
|
||||||
|
|
||||||
// Commission Settings — Org defaults + per-user overrides
|
// Commission Settings — Org defaults + per-user overrides
|
||||||
const [orgCommissionDefaults, setOrgCommissionDefaults] = useState({
|
const [orgCommissionDefaults, setOrgCommissionDefaults] = useState({
|
||||||
@@ -5396,6 +5397,20 @@ export const MockStoreProvider = ({ children }) => {
|
|||||||
removeUserCommissionOverride: (userId) => {
|
removeUserCommissionOverride: (userId) => {
|
||||||
setUserCommissionOverrides(prev => prev.filter(o => o.userId !== userId));
|
setUserCommissionOverrides(prev => prev.filter(o => o.userId !== userId));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Storm Zones
|
||||||
|
stormZones,
|
||||||
|
addStormZone: (zone) => {
|
||||||
|
const newZone = { ...zone, id: `sz-${Date.now()}`, createdAt: new Date().toISOString() };
|
||||||
|
setStormZones(prev => [...prev, newZone]);
|
||||||
|
return newZone;
|
||||||
|
},
|
||||||
|
updateStormZone: (id, data) => {
|
||||||
|
setStormZones(prev => prev.map(z => z.id === id ? { ...z, ...data } : z));
|
||||||
|
},
|
||||||
|
deleteStormZone: (id) => {
|
||||||
|
setStormZones(prev => prev.filter(z => z.id !== id));
|
||||||
|
},
|
||||||
updateProject: (projectId, data) => {
|
updateProject: (projectId, data) => {
|
||||||
setProjects(prev => prev.map(p => p.id === projectId ? { ...p, ...data } : p));
|
setProjects(prev => prev.map(p => p.id === projectId ? { ...p, ...data } : p));
|
||||||
},
|
},
|
||||||
@@ -5705,6 +5720,52 @@ export const MOCK_STORM_EVENTS = [
|
|||||||
// ── Storm-sourced leads — canvasser door-knock results linked to storm events ─
|
// ── Storm-sourced leads — canvasser door-knock results linked to storm events ─
|
||||||
// Each lead has a stormSource { id, areaName, date, maxHailSize, severity } and
|
// Each lead has a stormSource { id, areaName, date, maxHailSize, severity } and
|
||||||
// a contractValue (number) for Closed leads. createdAt is shortly after storm date.
|
// a contractValue (number) for Closed leads. createdAt is shortly after storm date.
|
||||||
|
// ── Canvasser zones — polygons drawn on Storm Intel map, assigned to agents ───
|
||||||
|
export const INITIAL_STORM_ZONES = [
|
||||||
|
{
|
||||||
|
id: 'sz-001',
|
||||||
|
name: 'Spring Creek Zone A',
|
||||||
|
stormId: 'SE-001',
|
||||||
|
polygon: [
|
||||||
|
[33.108, -96.720], [33.100, -96.702], [33.086, -96.702],
|
||||||
|
[33.080, -96.720], [33.086, -96.738], [33.100, -96.738],
|
||||||
|
],
|
||||||
|
assignedAgentId: 'REP-01',
|
||||||
|
assignedAgentName: 'Carlos Mendez',
|
||||||
|
color: '#F59E0B',
|
||||||
|
status: 'active',
|
||||||
|
createdAt: '2026-04-29T08:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sz-002',
|
||||||
|
name: 'Parker Rd Zone B',
|
||||||
|
stormId: 'SE-003',
|
||||||
|
polygon: [
|
||||||
|
[33.033, -96.712], [33.025, -96.697], [33.011, -96.697],
|
||||||
|
[33.003, -96.712], [33.011, -96.727], [33.025, -96.727],
|
||||||
|
],
|
||||||
|
assignedAgentId: 'REP-02',
|
||||||
|
assignedAgentName: 'Aisha Kumar',
|
||||||
|
color: '#8B5CF6',
|
||||||
|
status: 'active',
|
||||||
|
createdAt: '2026-02-04T09:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sz-003',
|
||||||
|
name: 'Haggard Park Zone C',
|
||||||
|
stormId: 'SE-006',
|
||||||
|
polygon: [
|
||||||
|
[33.047, -96.774], [33.039, -96.757], [33.025, -96.757],
|
||||||
|
[33.017, -96.774], [33.025, -96.791], [33.039, -96.791],
|
||||||
|
],
|
||||||
|
assignedAgentId: 'REP-04',
|
||||||
|
assignedAgentName: 'Nina Patel',
|
||||||
|
color: '#10B981',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: '2025-11-06T07:30:00Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const MOCK_STORM_ATTRIBUTION_LEADS = [
|
export const MOCK_STORM_ATTRIBUTION_LEADS = [
|
||||||
// ── SE-001 · Apr 28 2026 · Severe · NE Plano ──────────────────────────────
|
// ── SE-001 · Apr 28 2026 · Severe · NE Plano ──────────────────────────────
|
||||||
{ id: 'SAL-001', firstName: 'John', lastName: 'Martinez', address: '4821 Spring Creek Pkwy', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2026-04-29T10:14:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } },
|
{ id: 'SAL-001', firstName: 'John', lastName: 'Martinez', address: '4821 Spring Creek Pkwy', city: 'Plano', state: 'TX', urgency: 'high', leadSource: 'Door Knock', status: 'Contacted', contractValue: null, createdAt: '2026-04-29T10:14:00Z', stormSource: { id: 'SE-001', areaName: 'NE Plano / Spring Creek Pkwy', date: '2026-04-28', maxHailSize: 2.5, severity: 'severe' } },
|
||||||
|
|||||||
@@ -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;
|
||||||
+398
-80
@@ -1,7 +1,10 @@
|
|||||||
import React, { useState, useCallback, useRef, useEffect, memo } from 'react';
|
import React, { useState, useCallback, useRef, useEffect, memo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { MapContainer, TileLayer, Polygon, Tooltip, useMap } from 'react-leaflet';
|
import {
|
||||||
|
MapContainer, TileLayer, Polygon, Polyline, CircleMarker,
|
||||||
|
Tooltip, useMap, useMapEvents,
|
||||||
|
} from 'react-leaflet';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import {
|
import {
|
||||||
@@ -12,12 +15,17 @@ import {
|
|||||||
CloudLightning, Wind, Zap, Droplets, Filter, X, ChevronRight,
|
CloudLightning, Wind, Zap, Droplets, Filter, X, ChevronRight,
|
||||||
Home, AlertTriangle, TrendingUp, Users, Calendar, MapPin,
|
Home, AlertTriangle, TrendingUp, Users, Calendar, MapPin,
|
||||||
Flame, Snowflake, Clock, ArrowRight, Eye, UserPlus, Loader2,
|
Flame, Snowflake, Clock, ArrowRight, Eye, UserPlus, Loader2,
|
||||||
DollarSign, Target, BarChart2, Map,
|
DollarSign, Target, BarChart2, Map, Pencil, Sun, Cloud,
|
||||||
|
CloudRain, CloudDrizzle, CheckCircle, XCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { useTheme } from '../context/ThemeContext';
|
import { useTheme } from '../context/ThemeContext';
|
||||||
import { useMockStore } from '../data/mockStore';
|
import { useMockStore } from '../data/mockStore';
|
||||||
import { useStormEvents, conversionWindow } from '../hooks/useStormEvents';
|
import { useStormEvents, conversionWindow } from '../hooks/useStormEvents';
|
||||||
import { useStormAttribution } from '../hooks/useStormAttribution';
|
import { useStormAttribution } from '../hooks/useStormAttribution';
|
||||||
|
import { useStormForecast } from '../hooks/useStormForecast';
|
||||||
|
import ZoneAssignmentModal from '../components/storm/ZoneAssignmentModal';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -38,11 +46,28 @@ const TYPE_ICONS = {
|
|||||||
flood: Droplets,
|
flood: Droplets,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FORECAST_ICONS = {
|
||||||
|
1000: Sun, 1100: Sun,
|
||||||
|
1001: Cloud, 1101: Cloud, 1102: Cloud,
|
||||||
|
4000: CloudDrizzle, 4200: CloudDrizzle,
|
||||||
|
4001: CloudRain, 4201: CloudRain,
|
||||||
|
8000: CloudLightning,
|
||||||
|
7000: Snowflake, 7101: Snowflake, 7102: Snowflake,
|
||||||
|
5000: Snowflake, 5100: Snowflake, 5101: Snowflake,
|
||||||
|
};
|
||||||
|
|
||||||
|
function scoreColor(score) {
|
||||||
|
if (score >= 65) return '#DC2626';
|
||||||
|
if (score >= 40) return '#F97316';
|
||||||
|
if (score >= 20) return '#EAB308';
|
||||||
|
return '#10B981';
|
||||||
|
}
|
||||||
|
|
||||||
const MAP_STYLES = `
|
const MAP_STYLES = `
|
||||||
.storm-intel-map .leaflet-container { background: #e8e0d8; font-family: system-ui, sans-serif; }
|
.storm-intel-map .leaflet-container { background: #e8e0d8; font-family: system-ui, sans-serif; }
|
||||||
.storm-tooltip { background: white !important; border: 1px solid rgba(0,0,0,0.08) !important; border-radius: 10px !important; box-shadow: 0 4px 14px rgba(0,0,0,0.12) !important; padding: 8px 10px !important; pointer-events: none !important; }
|
.storm-tooltip { background: white !important; border: 1px solid rgba(0,0,0,0.08) !important; border-radius: 10px !important; box-shadow: 0 4px 14px rgba(0,0,0,0.12) !important; padding: 8px 10px !important; pointer-events: none !important; }
|
||||||
.storm-tooltip::before { display: none !important; }
|
.storm-tooltip::before { display: none !important; }
|
||||||
@keyframes stormPulse { 0%,100% { opacity: 0.7; } 50% { opacity: 1; } }
|
.draw-cursor .leaflet-container { cursor: crosshair !important; }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// ─── Map sub-components ───────────────────────────────────────────────────────
|
// ─── Map sub-components ───────────────────────────────────────────────────────
|
||||||
@@ -71,7 +96,113 @@ const MapFlyTo = ({ storm, flyCount }) => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const StormMap = memo(({ events, selectedId, onSelect, theme, flyCount, selectedStorm }) => {
|
const ZoneLayer = ({ zones }) => {
|
||||||
|
if (!zones?.length) return null;
|
||||||
|
return zones.map(zone => {
|
||||||
|
if (!zone.polygon?.length) return null;
|
||||||
|
const centroid = zone.polygon.reduce(
|
||||||
|
(acc, pt) => [acc[0] + pt[0] / zone.polygon.length, acc[1] + pt[1] / zone.polygon.length],
|
||||||
|
[0, 0]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={zone.id}>
|
||||||
|
<Polygon
|
||||||
|
positions={zone.polygon}
|
||||||
|
pathOptions={{
|
||||||
|
color: zone.color,
|
||||||
|
fillColor: zone.color,
|
||||||
|
fillOpacity: zone.status === 'completed' ? 0.06 : 0.14,
|
||||||
|
weight: 2,
|
||||||
|
opacity: zone.status === 'completed' ? 0.45 : 0.85,
|
||||||
|
dashArray: zone.status === 'completed' ? '5 4' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip direction="top" opacity={1} className="storm-tooltip" sticky={false}>
|
||||||
|
<div style={{ fontFamily: 'system-ui,sans-serif', minWidth: 140 }}>
|
||||||
|
<p style={{ fontSize: 12, fontWeight: 700, color: '#18181b', marginBottom: 2 }}>{zone.name}</p>
|
||||||
|
<p style={{ fontSize: 10, color: '#71717a' }}>
|
||||||
|
{zone.assignedAgentName} · {zone.status === 'completed' ? 'Completed' : 'Active'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Polygon>
|
||||||
|
{/* Agent initials circle at centroid */}
|
||||||
|
<CircleMarker
|
||||||
|
center={centroid}
|
||||||
|
radius={11}
|
||||||
|
pathOptions={{ color: zone.color, fillColor: zone.color, fillOpacity: 1, weight: 1.5 }}
|
||||||
|
>
|
||||||
|
<Tooltip direction="top" opacity={1} className="storm-tooltip">
|
||||||
|
<p style={{ fontSize: 11, fontWeight: 700, color: '#18181b' }}>{zone.name}</p>
|
||||||
|
</Tooltip>
|
||||||
|
</CircleMarker>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZoneDrawingLayer = ({ draftPolygon, onAddPoint, onFinish }) => {
|
||||||
|
const map = useMap();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
map.doubleClickZoom.disable();
|
||||||
|
map.getContainer().classList.add('draw-cursor');
|
||||||
|
return () => {
|
||||||
|
map.doubleClickZoom.enable();
|
||||||
|
map.getContainer().classList.remove('draw-cursor');
|
||||||
|
};
|
||||||
|
}, [map]);
|
||||||
|
|
||||||
|
useMapEvents({
|
||||||
|
click(e) {
|
||||||
|
onAddPoint([e.latlng.lat, e.latlng.lng]);
|
||||||
|
},
|
||||||
|
dblclick() {
|
||||||
|
if (draftPolygon.length >= 3) onFinish();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (draftPolygon.length === 0) return null;
|
||||||
|
|
||||||
|
const closedPath = draftPolygon.length > 1
|
||||||
|
? [...draftPolygon, draftPolygon[0]]
|
||||||
|
: draftPolygon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Polyline
|
||||||
|
positions={closedPath}
|
||||||
|
pathOptions={{ color: '#F59E0B', weight: 2.5, dashArray: '6 4', opacity: 0.9 }}
|
||||||
|
/>
|
||||||
|
{/* Fill preview */}
|
||||||
|
{draftPolygon.length >= 3 && (
|
||||||
|
<Polygon
|
||||||
|
positions={draftPolygon}
|
||||||
|
pathOptions={{ color: '#F59E0B', fillColor: '#F59E0B', fillOpacity: 0.12, weight: 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Vertex dots */}
|
||||||
|
{draftPolygon.map((pt, i) => (
|
||||||
|
<CircleMarker
|
||||||
|
key={i}
|
||||||
|
center={pt}
|
||||||
|
radius={i === 0 ? 6 : 4}
|
||||||
|
pathOptions={{
|
||||||
|
color: '#F59E0B',
|
||||||
|
fillColor: i === 0 ? '#fff' : '#F59E0B',
|
||||||
|
fillOpacity: 1,
|
||||||
|
weight: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StormMap = memo(({
|
||||||
|
events, selectedId, onSelect, theme, flyCount, selectedStorm,
|
||||||
|
stormZones, isDrawing, draftPolygon, onAddPoint, onFinishPolygon,
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full h-full storm-intel-map">
|
<div className="relative w-full h-full storm-intel-map">
|
||||||
<style>{MAP_STYLES}</style>
|
<style>{MAP_STYLES}</style>
|
||||||
@@ -91,11 +222,10 @@ const StormMap = memo(({ events, selectedId, onSelect, theme, flyCount, selected
|
|||||||
|
|
||||||
<MapFlyTo storm={selectedStorm} flyCount={flyCount} />
|
<MapFlyTo storm={selectedStorm} flyCount={flyCount} />
|
||||||
|
|
||||||
|
{/* Existing storm polygons */}
|
||||||
{events.map(storm => {
|
{events.map(storm => {
|
||||||
const sty = SEVERITY_STYLES[storm.severity] ?? SEVERITY_STYLES.trace;
|
const sty = SEVERITY_STYLES[storm.severity] ?? SEVERITY_STYLES.trace;
|
||||||
const isSelected = storm.id === selectedId;
|
const isSelected = storm.id === selectedId;
|
||||||
const TypeIcon = TYPE_ICONS[storm.type] ?? CloudLightning;
|
|
||||||
const days = Math.floor((Date.now() - new Date(storm.date).getTime()) / 86400000);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Polygon
|
<Polygon
|
||||||
@@ -110,7 +240,7 @@ const StormMap = memo(({ events, selectedId, onSelect, theme, flyCount, selected
|
|||||||
dashArray: isSelected ? undefined : '6 4',
|
dashArray: isSelected ? undefined : '6 4',
|
||||||
}}
|
}}
|
||||||
eventHandlers={{
|
eventHandlers={{
|
||||||
click: () => onSelect(storm),
|
click: () => !isDrawing && onSelect(storm),
|
||||||
mouseover: (e) => { e.target.setStyle({ fillOpacity: Math.min(sty.fillOpacity + 0.1, 0.4), weight: sty.weight + 1 }); },
|
mouseover: (e) => { e.target.setStyle({ fillOpacity: Math.min(sty.fillOpacity + 0.1, 0.4), weight: sty.weight + 1 }); },
|
||||||
mouseout: (e) => {
|
mouseout: (e) => {
|
||||||
if (storm.id === selectedId) return;
|
if (storm.id === selectedId) return;
|
||||||
@@ -134,13 +264,25 @@ const StormMap = memo(({ events, selectedId, onSelect, theme, flyCount, selected
|
|||||||
{storm.areaName}
|
{storm.areaName}
|
||||||
</p>
|
</p>
|
||||||
<p style={{ fontSize: 10, color: '#71717a' }}>
|
<p style={{ fontSize: 10, color: '#71717a' }}>
|
||||||
{days === 0 ? 'Today' : `${days}d ago`} · ~{(storm.estimatedHomes ?? '?').toLocaleString()} homes
|
{Math.floor((Date.now() - new Date(storm.date).getTime()) / 86400000)}d ago · ~{(storm.estimatedHomes ?? '?').toLocaleString()} homes
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Polygon>
|
</Polygon>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Canvasser zones layer */}
|
||||||
|
<ZoneLayer zones={stormZones} />
|
||||||
|
|
||||||
|
{/* Drawing layer (active only when isDrawing) */}
|
||||||
|
{isDrawing && (
|
||||||
|
<ZoneDrawingLayer
|
||||||
|
draftPolygon={draftPolygon}
|
||||||
|
onAddPoint={onAddPoint}
|
||||||
|
onFinish={onFinishPolygon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
|
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
@@ -165,11 +307,107 @@ const StormMap = memo(({ events, selectedId, onSelect, theme, flyCount, selected
|
|||||||
<span className="text-[11px] font-bold text-white">{events.length} storm{events.length !== 1 ? 's' : ''}</span>
|
<span className="text-[11px] font-bold text-white">{events.length} storm{events.length !== 1 ? 's' : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Drawing instructions */}
|
||||||
|
{isDrawing && (
|
||||||
|
<div className="absolute top-3 right-3 z-[1000]">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 rounded-xl bg-amber-500 text-white shadow-lg text-[11px] font-bold">
|
||||||
|
<Pencil size={12} />
|
||||||
|
{draftPolygon.length < 3
|
||||||
|
? `Click to place vertices (${draftPolygon.length}/3 min)`
|
||||||
|
: `Double-click to close polygon (${draftPolygon.length} pts)`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
StormMap.displayName = 'StormMap';
|
StormMap.displayName = 'StormMap';
|
||||||
|
|
||||||
|
// ─── Forecast strip ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ForecastStrip = ({ forecast, loading, alertDay, hasStormAlert }) => {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="shrink-0 border-b border-zinc-200 dark:border-white/[0.06] bg-white dark:bg-zinc-900/60 px-4 sm:px-6 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{Array.from({ length: 7 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-14 w-[72px] shrink-0 rounded-lg bg-zinc-100 dark:bg-zinc-800/40 animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!forecast.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shrink-0 border-b border-zinc-200 dark:border-white/[0.06] bg-white dark:bg-zinc-900/60">
|
||||||
|
{/* Storm alert banner */}
|
||||||
|
{hasStormAlert && alertDay && (
|
||||||
|
<div className="mx-4 sm:mx-6 mt-2.5 mb-0 px-3 py-2 rounded-xl border border-red-200 dark:border-red-500/20 bg-red-50 dark:bg-red-500/8 flex items-center gap-2.5">
|
||||||
|
<AlertTriangle size={14} className="text-red-500 shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-[12px] font-black text-red-700 dark:text-red-400">High-Risk Storm Day</span>
|
||||||
|
<span className="text-[12px] text-red-600 dark:text-red-400 ml-1.5">
|
||||||
|
{new Date(alertDay.date).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}
|
||||||
|
{' — '}{alertDay.weatherLabel} · {alertDay.windGust} mph gusts · {alertDay.precipProb}% rain
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-black text-red-600 dark:text-red-400 shrink-0">Score {alertDay.stormScore}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 7-day forecast row */}
|
||||||
|
<div className="flex items-stretch gap-1.5 px-4 sm:px-6 py-2.5 overflow-x-auto scrollbar-none">
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest text-zinc-400 self-center mr-1 shrink-0 hidden sm:block">7-Day</span>
|
||||||
|
{forecast.map((day, i) => {
|
||||||
|
const FIcon = FORECAST_ICONS[day.weatherCode] ?? Cloud;
|
||||||
|
const isAlert = day.stormScore >= 65;
|
||||||
|
const isWarn = day.stormScore >= 40 && !isAlert;
|
||||||
|
const dayLabel = i === 0
|
||||||
|
? 'Today'
|
||||||
|
: new Date(day.date).toLocaleDateString('en-US', { weekday: 'short' });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={day.date}
|
||||||
|
className={`flex-none w-[70px] rounded-xl border px-2 py-1.5 flex flex-col items-center gap-0.5 ${
|
||||||
|
isAlert
|
||||||
|
? 'border-red-200 dark:border-red-500/20 bg-red-50/60 dark:bg-red-500/8'
|
||||||
|
: isWarn
|
||||||
|
? 'border-orange-200 dark:border-orange-500/20 bg-orange-50/60 dark:bg-orange-500/8'
|
||||||
|
: 'border-zinc-100 dark:border-white/[0.06] bg-zinc-50/60 dark:bg-zinc-800/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`text-[10px] font-black uppercase tracking-wide ${i === 0 ? 'text-amber-600 dark:text-amber-400' : 'text-zinc-500 dark:text-zinc-400'}`}>
|
||||||
|
{dayLabel}
|
||||||
|
</span>
|
||||||
|
<FIcon
|
||||||
|
size={16}
|
||||||
|
style={{ color: isAlert ? '#DC2626' : isWarn ? '#F97316' : '#71717a' }}
|
||||||
|
/>
|
||||||
|
<span className={`text-[11px] font-black ${isAlert ? 'text-red-600 dark:text-red-400' : 'text-zinc-700 dark:text-zinc-200'}`}>
|
||||||
|
{day.tempF}°
|
||||||
|
</span>
|
||||||
|
{/* Storm score bar */}
|
||||||
|
<div className="w-full h-1 bg-zinc-200 dark:bg-zinc-700 rounded-full overflow-hidden mt-0.5">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full"
|
||||||
|
style={{ width: `${day.stormScore}%`, backgroundColor: scoreColor(day.stormScore) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={`text-[9px] font-bold ${isAlert ? 'text-red-500' : isWarn ? 'text-orange-500' : 'text-zinc-400'}`}>
|
||||||
|
{day.precipProb}% rain
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Storm card ───────────────────────────────────────────────────────────────
|
// ─── Storm card ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const StormCard = ({ storm, isSelected, onSelect }) => {
|
const StormCard = ({ storm, isSelected, onSelect }) => {
|
||||||
@@ -188,17 +426,14 @@ const StormCard = ({ storm, isSelected, onSelect }) => {
|
|||||||
: 'border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-900/60 hover:border-zinc-300 dark:hover:border-white/15 hover:shadow-sm'
|
: 'border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-900/60 hover:border-zinc-300 dark:hover:border-white/15 hover:shadow-sm'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Severity bar */}
|
|
||||||
<div className="h-1 w-full" style={{ backgroundColor: sty.dot }} />
|
<div className="h-1 w-full" style={{ backgroundColor: sty.dot }} />
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
<div className="flex items-start gap-2.5">
|
<div className="flex items-start gap-2.5">
|
||||||
{/* Icon */}
|
|
||||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 mt-0.5" style={{ backgroundColor: sty.dot + '18' }}>
|
<div className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 mt-0.5" style={{ backgroundColor: sty.dot + '18' }}>
|
||||||
<TypeIcon size={15} style={{ color: sty.dot }} />
|
<TypeIcon size={15} style={{ color: sty.dot }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{/* Area + date */}
|
|
||||||
<div className="flex items-start justify-between gap-1 mb-1">
|
<div className="flex items-start justify-between gap-1 mb-1">
|
||||||
<p className="text-[13px] font-bold text-zinc-900 dark:text-white leading-snug truncate">
|
<p className="text-[13px] font-bold text-zinc-900 dark:text-white leading-snug truncate">
|
||||||
{storm.areaName}
|
{storm.areaName}
|
||||||
@@ -210,27 +445,20 @@ const StormCard = ({ storm, isSelected, onSelect }) => {
|
|||||||
|
|
||||||
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 mb-2">{dateLabel}</p>
|
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 mb-2">{dateLabel}</p>
|
||||||
|
|
||||||
{/* Badges row */}
|
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
{/* Severity */}
|
|
||||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-md border ${sty.text} ${sty.bg} ${sty.border}`}>
|
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-md border ${sty.text} ${sty.bg} ${sty.border}`}>
|
||||||
{sty.label}
|
{sty.label}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Hail size */}
|
|
||||||
{storm.maxHailSize && (
|
{storm.maxHailSize && (
|
||||||
<span className="text-[10px] font-bold text-zinc-600 dark:text-zinc-300 bg-zinc-100 dark:bg-zinc-800 px-1.5 py-0.5 rounded-md border border-zinc-200 dark:border-white/10">
|
<span className="text-[10px] font-bold text-zinc-600 dark:text-zinc-300 bg-zinc-100 dark:bg-zinc-800 px-1.5 py-0.5 rounded-md border border-zinc-200 dark:border-white/10">
|
||||||
{storm.maxHailSize}"
|
{storm.maxHailSize}"
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Conversion window */}
|
|
||||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-md border ${conv.color} ${conv.bg} ${conv.border}`}>
|
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-md border ${conv.color} ${conv.bg} ${conv.border}`}>
|
||||||
{conv.label}
|
{conv.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Homes estimate */}
|
|
||||||
{storm.estimatedHomes && (
|
{storm.estimatedHomes && (
|
||||||
<div className="flex items-center gap-1 mt-1.5">
|
<div className="flex items-center gap-1 mt-1.5">
|
||||||
<Home size={10} className="text-zinc-400" />
|
<Home size={10} className="text-zinc-400" />
|
||||||
@@ -248,7 +476,7 @@ const StormCard = ({ storm, isSelected, onSelect }) => {
|
|||||||
|
|
||||||
// ─── Detail drawer ────────────────────────────────────────────────────────────
|
// ─── Detail drawer ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {
|
const StormDetailDrawer = ({ storm, open, onClose, onFlyTo, onAssignZone, canManageZones }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
|
||||||
@@ -274,13 +502,8 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViewInDispatch = () => {
|
|
||||||
navigate('/admin/dispatch');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Backdrop on mobile */}
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{open && (
|
{open && (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -292,7 +515,6 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Drawer panel */}
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{open && (
|
{open && (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -303,10 +525,8 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {
|
|||||||
transition={{ type: 'spring', stiffness: 320, damping: 34 }}
|
transition={{ type: 'spring', stiffness: 320, damping: 34 }}
|
||||||
className="fixed top-0 right-0 bottom-0 w-full max-w-sm z-50 flex flex-col bg-white dark:bg-zinc-900 border-l border-zinc-200 dark:border-white/[0.07] shadow-2xl"
|
className="fixed top-0 right-0 bottom-0 w-full max-w-sm z-50 flex flex-col bg-white dark:bg-zinc-900 border-l border-zinc-200 dark:border-white/[0.07] shadow-2xl"
|
||||||
>
|
>
|
||||||
{/* Severity bar */}
|
|
||||||
<div className="h-1 w-full shrink-0" style={{ backgroundColor: sty.dot }} />
|
<div className="h-1 w-full shrink-0" style={{ backgroundColor: sty.dot }} />
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-white/[0.06] shrink-0">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-white/[0.06] shrink-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center" style={{ backgroundColor: sty.dot + '18' }}>
|
<div className="w-8 h-8 rounded-lg flex items-center justify-center" style={{ backgroundColor: sty.dot + '18' }}>
|
||||||
@@ -327,9 +547,7 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable body */}
|
|
||||||
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
|
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
|
||||||
{/* Area name */}
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-black text-zinc-900 dark:text-white leading-tight">
|
<h2 className="text-lg font-black text-zinc-900 dark:text-white leading-tight">
|
||||||
{storm.areaName}
|
{storm.areaName}
|
||||||
@@ -337,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>
|
<p className="text-[12px] text-zinc-500 dark:text-zinc-400 mt-0.5">{storm.county} County, TX</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Conversion window */}
|
|
||||||
<div className={`flex items-center gap-2 px-3 py-2 rounded-xl border ${conv.bg} ${conv.border}`}>
|
<div className={`flex items-center gap-2 px-3 py-2 rounded-xl border ${conv.bg} ${conv.border}`}>
|
||||||
{conv.label === 'Hot Zone' && <Flame size={14} className={conv.color} />}
|
{conv.label === 'Hot Zone' && <Flame size={14} className={conv.color} />}
|
||||||
{conv.label === 'Warm' && <TrendingUp size={14} className={conv.color} />}
|
{conv.label === 'Warm' && <TrendingUp size={14} className={conv.color} />}
|
||||||
@@ -354,7 +571,6 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats grid */}
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{storm.maxHailSize && (
|
{storm.maxHailSize && (
|
||||||
<div className="rounded-xl bg-zinc-50 dark:bg-zinc-800/60 border border-zinc-100 dark:border-white/[0.06] p-3">
|
<div className="rounded-xl bg-zinc-50 dark:bg-zinc-800/60 border border-zinc-100 dark:border-white/[0.06] p-3">
|
||||||
@@ -382,7 +598,6 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] font-bold uppercase tracking-wider text-zinc-400 mb-1.5">Intel</p>
|
<p className="text-[11px] font-bold uppercase tracking-wider text-zinc-400 mb-1.5">Intel</p>
|
||||||
<p className="text-[13px] text-zinc-600 dark:text-zinc-300 leading-relaxed">
|
<p className="text-[13px] text-zinc-600 dark:text-zinc-300 leading-relaxed">
|
||||||
@@ -390,7 +605,6 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Map action */}
|
|
||||||
<button
|
<button
|
||||||
onClick={onFlyTo}
|
onClick={onFlyTo}
|
||||||
className="w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl border border-zinc-200 dark:border-white/[0.07] hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors group"
|
className="w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl border border-zinc-200 dark:border-white/[0.07] hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors group"
|
||||||
@@ -403,7 +617,6 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<div className="px-4 py-3 border-t border-zinc-100 dark:border-white/[0.06] space-y-2 shrink-0">
|
<div className="px-4 py-3 border-t border-zinc-100 dark:border-white/[0.06] space-y-2 shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={handleCreateLead}
|
onClick={handleCreateLead}
|
||||||
@@ -413,20 +626,24 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {
|
|||||||
<UserPlus size={15} />
|
<UserPlus size={15} />
|
||||||
Create Lead from Zone
|
Create Lead from Zone
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{canManageZones ? (
|
||||||
<button
|
<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]"
|
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} />
|
<Zap size={15} />
|
||||||
Open in LynkDispatch
|
Open in LynkDispatch
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
@@ -464,7 +681,7 @@ const AttributionView = ({ events, sessionLeads }) => {
|
|||||||
const chartData = perStorm
|
const chartData = perStorm
|
||||||
.filter(p => p.leadsCount > 0)
|
.filter(p => p.leadsCount > 0)
|
||||||
.map(p => ({
|
.map(p => ({
|
||||||
name: (p.storm.areaName ?? 'Unknown').split('/')[0].trim().replace('Plano', '').replace('Plano', '').trim() || p.storm.areaName,
|
name: (p.storm.areaName ?? 'Unknown').split('/')[0].trim().replace('Plano', '').trim() || p.storm.areaName,
|
||||||
Leads: p.leadsCount,
|
Leads: p.leadsCount,
|
||||||
Closed: p.closedCount,
|
Closed: p.closedCount,
|
||||||
stormId: p.storm.id,
|
stormId: p.storm.id,
|
||||||
@@ -482,8 +699,6 @@ const AttributionView = ({ events, sessionLeads }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-5 space-y-6">
|
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-5 space-y-6">
|
||||||
|
|
||||||
{/* ── Summary cards ── */}
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||||
{statCards.map(({ label, value, icon: Icon, color }) => (
|
{statCards.map(({ label, value, icon: Icon, color }) => (
|
||||||
<div key={label} className="rounded-xl border border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-900/60 px-4 py-3">
|
<div key={label} className="rounded-xl border border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-900/60 px-4 py-3">
|
||||||
@@ -496,21 +711,13 @@ const AttributionView = ({ events, sessionLeads }) => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Leads by storm chart ── */}
|
|
||||||
<div className="rounded-xl border border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-900/60 p-4">
|
<div className="rounded-xl border border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-900/60 p-4">
|
||||||
<p className="text-[12px] font-black uppercase tracking-wider text-zinc-400 mb-4">Leads Generated Per Storm</p>
|
<p className="text-[12px] font-black uppercase tracking-wider text-zinc-400 mb-4">Leads Generated Per Storm</p>
|
||||||
<ResponsiveContainer width="100%" height={200}>
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
<BarChart data={chartData} barGap={4} barCategoryGap="30%">
|
<BarChart data={chartData} barGap={4} barCategoryGap="30%">
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke={isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.06)'} vertical={false} />
|
<CartesianGrid strokeDasharray="3 3" stroke={isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.06)'} vertical={false} />
|
||||||
<XAxis
|
<XAxis dataKey="name" tick={{ fontSize: 10, fill: isDark ? '#71717a' : '#a1a1aa', fontWeight: 600 }} axisLine={false} tickLine={false} />
|
||||||
dataKey="name"
|
<YAxis tick={{ fontSize: 10, fill: isDark ? '#71717a' : '#a1a1aa' }} axisLine={false} tickLine={false} width={24} />
|
||||||
tick={{ fontSize: 10, fill: isDark ? '#71717a' : '#a1a1aa', fontWeight: 600 }}
|
|
||||||
axisLine={false} tickLine={false}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
tick={{ fontSize: 10, fill: isDark ? '#71717a' : '#a1a1aa' }}
|
|
||||||
axisLine={false} tickLine={false} width={24}
|
|
||||||
/>
|
|
||||||
<RechartsTooltip content={<CustomTooltip />} cursor={{ fill: isDark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.03)' }} />
|
<RechartsTooltip content={<CustomTooltip />} cursor={{ fill: isDark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.03)' }} />
|
||||||
<Bar dataKey="Leads" name="Generated" fill="#F59E0B" radius={[4, 4, 0, 0]}>
|
<Bar dataKey="Leads" name="Generated" fill="#F59E0B" radius={[4, 4, 0, 0]}>
|
||||||
{chartData.map((entry, i) => (
|
{chartData.map((entry, i) => (
|
||||||
@@ -526,7 +733,6 @@ const AttributionView = ({ events, sessionLeads }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Per-storm breakdown ── */}
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[12px] font-black uppercase tracking-wider text-zinc-400 mb-3">Storm Revenue Breakdown</p>
|
<p className="text-[12px] font-black uppercase tracking-wider text-zinc-400 mb-3">Storm Revenue Breakdown</p>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -539,12 +745,10 @@ const AttributionView = ({ events, sessionLeads }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.storm.id ?? idx} className="rounded-xl border border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-900/60 overflow-hidden">
|
<div key={item.storm.id ?? idx} className="rounded-xl border border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-900/60 overflow-hidden">
|
||||||
{/* Severity bar */}
|
|
||||||
<div className="h-0.5 w-full" style={{ backgroundColor: sty.dot }} />
|
<div className="h-0.5 w-full" style={{ backgroundColor: sty.dot }} />
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
<div className="flex items-start justify-between gap-3 mb-3">
|
<div className="flex items-start justify-between gap-3 mb-3">
|
||||||
<div className="flex items-center gap-2.5 min-w-0">
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
{/* Rank */}
|
|
||||||
<div className="w-6 h-6 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center shrink-0">
|
<div className="w-6 h-6 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center shrink-0">
|
||||||
<span className="text-[10px] font-black text-zinc-500">#{idx + 1}</span>
|
<span className="text-[10px] font-black text-zinc-500">#{idx + 1}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -560,7 +764,6 @@ const AttributionView = ({ events, sessionLeads }) => {
|
|||||||
<p className="text-[11px] text-zinc-400 mt-0.5">{stormDate}{item.storm.maxHailSize ? ` · ${item.storm.maxHailSize}"` : ''}</p>
|
<p className="text-[11px] text-zinc-400 mt-0.5">{stormDate}{item.storm.maxHailSize ? ` · ${item.storm.maxHailSize}"` : ''}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Revenue */}
|
|
||||||
<div className="text-right shrink-0">
|
<div className="text-right shrink-0">
|
||||||
<p className="text-lg font-black text-zinc-900 dark:text-white">
|
<p className="text-lg font-black text-zinc-900 dark:text-white">
|
||||||
{item.revenue > 0 ? `$${item.revenue.toLocaleString()}` : '—'}
|
{item.revenue > 0 ? `$${item.revenue.toLocaleString()}` : '—'}
|
||||||
@@ -569,7 +772,6 @@ const AttributionView = ({ events, sessionLeads }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats row */}
|
|
||||||
<div className="flex items-center gap-4 mb-3 flex-wrap">
|
<div className="flex items-center gap-4 mb-3 flex-wrap">
|
||||||
{[
|
{[
|
||||||
{ label: 'Leads', value: item.leadsCount, color: null },
|
{ label: 'Leads', value: item.leadsCount, color: null },
|
||||||
@@ -590,7 +792,6 @@ const AttributionView = ({ events, sessionLeads }) => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Revenue progress bar */}
|
|
||||||
{item.revenue > 0 && (
|
{item.revenue > 0 && (
|
||||||
<div className="h-1.5 w-full bg-zinc-100 dark:bg-zinc-800 rounded-full overflow-hidden">
|
<div className="h-1.5 w-full bg-zinc-100 dark:bg-zinc-800 rounded-full overflow-hidden">
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -609,7 +810,6 @@ const AttributionView = ({ events, sessionLeads }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Best zone insight ── */}
|
|
||||||
{perStorm.length > 0 && perStorm[0].revenue > 0 && (
|
{perStorm.length > 0 && perStorm[0].revenue > 0 && (
|
||||||
<div className="rounded-xl border border-emerald-200 dark:border-emerald-500/20 bg-emerald-50 dark:bg-emerald-500/5 px-4 py-3 flex items-start gap-3">
|
<div className="rounded-xl border border-emerald-200 dark:border-emerald-500/20 bg-emerald-50 dark:bg-emerald-500/5 px-4 py-3 flex items-start gap-3">
|
||||||
<div className="w-8 h-8 rounded-lg bg-emerald-500/15 flex items-center justify-center shrink-0">
|
<div className="w-8 h-8 rounded-lg bg-emerald-500/15 flex items-center justify-center shrink-0">
|
||||||
@@ -628,7 +828,7 @@ const AttributionView = ({ events, sessionLeads }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Filter bar ───────────────────────────────────────────────────────────────
|
// ─── Filter chip ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const FilterChip = ({ active, onClick, children }) => (
|
const FilterChip = ({ active, onClick, children }) => (
|
||||||
<button
|
<button
|
||||||
@@ -647,7 +847,10 @@ const FilterChip = ({ active, onClick, children }) => (
|
|||||||
|
|
||||||
const StormIntelPage = () => {
|
const StormIntelPage = () => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const { leads: storeLeads = [] } = useMockStore();
|
const { user } = useAuth();
|
||||||
|
const { leads: storeLeads = [], stormZones, addStormZone } = useMockStore();
|
||||||
|
|
||||||
|
const canManageZones = ['ADMIN', 'OWNER'].includes(user?.role);
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState('map');
|
const [activeTab, setActiveTab] = useState('map');
|
||||||
const [dateRange, setDateRange] = useState('6m');
|
const [dateRange, setDateRange] = useState('6m');
|
||||||
@@ -659,9 +862,15 @@ const StormIntelPage = () => {
|
|||||||
const [flyCount, setFlyCount] = useState(0);
|
const [flyCount, setFlyCount] = useState(0);
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
|
||||||
// Always load all events (attribution uses the full set regardless of date filter)
|
// 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, loading, error, totalHomes } = useStormEvents({ dateRange, typeFilter, severityFilter, sortBy });
|
||||||
const { events: allEvents } = useStormEvents({ dateRange: '1y' });
|
const { events: allEvents } = useStormEvents({ dateRange: '1y' });
|
||||||
|
const { forecast, loading: forecastLoading, alertDay, hasStormAlert } = useStormForecast();
|
||||||
|
|
||||||
const handleSelect = useCallback((storm) => {
|
const handleSelect = useCallback((storm) => {
|
||||||
setSelectedStorm(storm);
|
setSelectedStorm(storm);
|
||||||
@@ -677,6 +886,40 @@ const StormIntelPage = () => {
|
|||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleStartDrawing = useCallback((linkedStormId = null) => {
|
||||||
|
setIsDrawing(true);
|
||||||
|
setDraftPolygon([]);
|
||||||
|
setDrawingLinkedStormId(linkedStormId);
|
||||||
|
setDrawerOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddPoint = useCallback((pt) => {
|
||||||
|
setDraftPolygon(prev => [...prev, pt]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFinishPolygon = useCallback(() => {
|
||||||
|
if (draftPolygon.length < 3) {
|
||||||
|
toast.error('Need at least 3 points to create a zone');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsDrawing(false);
|
||||||
|
setShowZoneModal(true);
|
||||||
|
}, [draftPolygon]);
|
||||||
|
|
||||||
|
const handleCancelDrawing = useCallback(() => {
|
||||||
|
setIsDrawing(false);
|
||||||
|
setDraftPolygon([]);
|
||||||
|
setDrawingLinkedStormId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSaveZone = useCallback((zoneData) => {
|
||||||
|
addStormZone({ ...zoneData, polygon: draftPolygon });
|
||||||
|
setShowZoneModal(false);
|
||||||
|
setDraftPolygon([]);
|
||||||
|
setDrawingLinkedStormId(null);
|
||||||
|
toast.success(`Zone "${zoneData.name}" saved and assigned to ${zoneData.assignedAgentName}`);
|
||||||
|
}, [addStormZone, draftPolygon]);
|
||||||
|
|
||||||
// Responsive map height
|
// Responsive map height
|
||||||
const [mapHeight, setMapHeight] = useState('320px');
|
const [mapHeight, setMapHeight] = useState('320px');
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -689,8 +932,6 @@ const StormIntelPage = () => {
|
|||||||
return () => window.removeEventListener('resize', update);
|
return () => window.removeEventListener('resize', update);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isDark = theme === 'dark';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-zinc-50 dark:bg-zinc-950">
|
<div className="flex flex-col h-full bg-zinc-50 dark:bg-zinc-950">
|
||||||
|
|
||||||
@@ -703,9 +944,7 @@ const StormIntelPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-black text-zinc-900 dark:text-white tracking-tight">Storm Intel</h1>
|
<h1 className="text-xl font-black text-zinc-900 dark:text-white tracking-tight">Storm Intel</h1>
|
||||||
<p className="text-[12px] text-zinc-400 dark:text-zinc-500">
|
<p className="text-[12px] text-zinc-400 dark:text-zinc-500">Plano TX · Collin County</p>
|
||||||
Plano TX · Collin County
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -727,9 +966,40 @@ const StormIntelPage = () => {
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400">Hot Zones</p>
|
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400">Hot Zones</p>
|
||||||
</div>
|
</div>
|
||||||
|
{stormZones.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="w-px h-8 bg-zinc-200 dark:bg-white/10" />
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xl font-black text-emerald-500">{stormZones.filter(z => z.status === 'active').length}</p>
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400">Active Zones</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Draw Zone button — Admin/Owner only */}
|
||||||
|
{canManageZones && activeTab === 'map' && !isDrawing && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleStartDrawing(null)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-amber-300 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300 text-[12px] font-bold hover:bg-amber-100 dark:hover:bg-amber-500/20 transition-colors"
|
||||||
|
>
|
||||||
|
<Pencil size={13} />
|
||||||
|
<span className="hidden sm:inline">Draw Zone</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cancel drawing */}
|
||||||
|
{isDrawing && (
|
||||||
|
<button
|
||||||
|
onClick={handleCancelDrawing}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-red-200 dark:border-red-500/20 bg-red-50 dark:bg-red-500/10 text-red-600 dark:text-red-400 text-[12px] font-bold hover:bg-red-100 dark:hover:bg-red-500/20 transition-colors"
|
||||||
|
>
|
||||||
|
<XCircle size={13} />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tab switcher */}
|
{/* Tab switcher */}
|
||||||
<div className="flex items-center p-1 rounded-xl gap-0.5 bg-zinc-100 dark:bg-zinc-800">
|
<div className="flex items-center p-1 rounded-xl gap-0.5 bg-zinc-100 dark:bg-zinc-800">
|
||||||
<button
|
<button
|
||||||
@@ -756,7 +1026,6 @@ const StormIntelPage = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile filter toggle — map tab only */}
|
|
||||||
{activeTab === 'map' && (
|
{activeTab === 'map' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowFilters(f => !f)}
|
onClick={() => setShowFilters(f => !f)}
|
||||||
@@ -781,27 +1050,20 @@ const StormIntelPage = () => {
|
|||||||
className={`shrink-0 border-b border-zinc-200 dark:border-white/[0.06] bg-white dark:bg-zinc-900/60 ${showFilters || window.innerWidth >= 1024 ? '' : 'hidden lg:flex'}`}
|
className={`shrink-0 border-b border-zinc-200 dark:border-white/[0.06] bg-white dark:bg-zinc-900/60 ${showFilters || window.innerWidth >= 1024 ? '' : 'hidden lg:flex'}`}
|
||||||
>
|
>
|
||||||
<div className="px-4 sm:px-6 py-3 flex flex-wrap gap-x-6 gap-y-2 items-center">
|
<div className="px-4 sm:px-6 py-3 flex flex-wrap gap-x-6 gap-y-2 items-center">
|
||||||
{/* Date range */}
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-[11px] font-bold text-zinc-400 uppercase tracking-wider mr-1">Range</span>
|
<span className="text-[11px] font-bold text-zinc-400 uppercase tracking-wider mr-1">Range</span>
|
||||||
{[['1m', '1 Month'], ['3m', '3 Months'], ['6m', '6 Months'], ['1y', '1 Year']].map(([val, label]) => (
|
{[['1m', '1 Month'], ['3m', '3 Months'], ['6m', '6 Months'], ['1y', '1 Year']].map(([val, label]) => (
|
||||||
<FilterChip key={val} active={dateRange === val} onClick={() => setDateRange(val)}>{label}</FilterChip>
|
<FilterChip key={val} active={dateRange === val} onClick={() => setDateRange(val)}>{label}</FilterChip>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-px h-5 bg-zinc-200 dark:bg-white/10 hidden sm:block" />
|
<div className="w-px h-5 bg-zinc-200 dark:bg-white/10 hidden sm:block" />
|
||||||
|
|
||||||
{/* Severity */}
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-[11px] font-bold text-zinc-400 uppercase tracking-wider mr-1">Severity</span>
|
<span className="text-[11px] font-bold text-zinc-400 uppercase tracking-wider mr-1">Severity</span>
|
||||||
{[['all', 'All'], ['severe', 'Severe'], ['significant', 'Significant'], ['moderate', 'Moderate'], ['trace', 'Trace']].map(([val, label]) => (
|
{[['all', 'All'], ['severe', 'Severe'], ['significant', 'Significant'], ['moderate', 'Moderate'], ['trace', 'Trace']].map(([val, label]) => (
|
||||||
<FilterChip key={val} active={severityFilter === val} onClick={() => setSeverityFilter(val)}>{label}</FilterChip>
|
<FilterChip key={val} active={severityFilter === val} onClick={() => setSeverityFilter(val)}>{label}</FilterChip>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-px h-5 bg-zinc-200 dark:bg-white/10 hidden sm:block" />
|
<div className="w-px h-5 bg-zinc-200 dark:bg-white/10 hidden sm:block" />
|
||||||
|
|
||||||
{/* Sort */}
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-[11px] font-bold text-zinc-400 uppercase tracking-wider mr-1">Sort</span>
|
<span className="text-[11px] font-bold text-zinc-400 uppercase tracking-wider mr-1">Sort</span>
|
||||||
<FilterChip active={sortBy === 'date'} onClick={() => setSortBy('date')}>Newest</FilterChip>
|
<FilterChip active={sortBy === 'date'} onClick={() => setSortBy('date')}>Newest</FilterChip>
|
||||||
@@ -812,6 +1074,16 @@ const StormIntelPage = () => {
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* ── Forecast strip — map tab only ── */}
|
||||||
|
{activeTab === 'map' && (
|
||||||
|
<ForecastStrip
|
||||||
|
forecast={forecast}
|
||||||
|
loading={forecastLoading}
|
||||||
|
alertDay={alertDay}
|
||||||
|
hasStormAlert={hasStormAlert}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Attribution tab ── */}
|
{/* ── Attribution tab ── */}
|
||||||
{activeTab === 'attribution' && (
|
{activeTab === 'attribution' && (
|
||||||
<AttributionView events={allEvents} sessionLeads={storeLeads} />
|
<AttributionView events={allEvents} sessionLeads={storeLeads} />
|
||||||
@@ -849,13 +1121,17 @@ const StormIntelPage = () => {
|
|||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
flyCount={flyCount}
|
flyCount={flyCount}
|
||||||
|
stormZones={stormZones}
|
||||||
|
isDrawing={isDrawing}
|
||||||
|
draftPolygon={draftPolygon}
|
||||||
|
onAddPoint={handleAddPoint}
|
||||||
|
onFinishPolygon={handleFinishPolygon}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Storm list */}
|
{/* Storm list */}
|
||||||
<div className="lg:w-[23rem] flex flex-col border-t lg:border-t-0 lg:border-l border-zinc-200 dark:border-white/[0.06] bg-white dark:bg-zinc-900/40 min-h-0">
|
<div className="lg:w-[23rem] flex flex-col border-t lg:border-t-0 lg:border-l border-zinc-200 dark:border-white/[0.06] bg-white dark:bg-zinc-900/40 min-h-0">
|
||||||
{/* List header */}
|
|
||||||
<div className="px-4 py-3 border-b border-zinc-100 dark:border-white/[0.06] shrink-0 flex items-center justify-between">
|
<div className="px-4 py-3 border-b border-zinc-100 dark:border-white/[0.06] shrink-0 flex items-center justify-between">
|
||||||
<p className="text-[12px] font-black uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
|
<p className="text-[12px] font-black uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
|
||||||
{events.length} Event{events.length !== 1 ? 's' : ''}
|
{events.length} Event{events.length !== 1 ? 's' : ''}
|
||||||
@@ -870,7 +1146,37 @@ const StormIntelPage = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cards */}
|
{/* Zone summary strip */}
|
||||||
|
{stormZones.length > 0 && (
|
||||||
|
<div className="px-4 py-2 border-b border-zinc-100 dark:border-white/[0.06] shrink-0 bg-zinc-50/50 dark:bg-white/[0.02]">
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-wider text-zinc-400">Canvasser Zones</p>
|
||||||
|
{canManageZones && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleStartDrawing(null)}
|
||||||
|
className="text-[10px] font-bold text-amber-600 dark:text-amber-400 hover:underline flex items-center gap-0.5"
|
||||||
|
>
|
||||||
|
<Pencil size={10} /> New
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{stormZones.slice(0, 4).map(zone => (
|
||||||
|
<div key={zone.id} className="flex items-center gap-1.5 px-2 py-1 rounded-lg border border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-800/40">
|
||||||
|
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: zone.color }} />
|
||||||
|
<span className="text-[10px] font-semibold text-zinc-600 dark:text-zinc-300 max-w-[90px] truncate">{zone.name}</span>
|
||||||
|
{zone.status === 'completed' && <CheckCircle size={9} className="text-emerald-500 shrink-0" />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{stormZones.length > 4 && (
|
||||||
|
<div className="px-2 py-1 rounded-lg bg-zinc-100 dark:bg-zinc-800/40">
|
||||||
|
<span className="text-[10px] font-semibold text-zinc-400">+{stormZones.length - 4}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-3 py-3 space-y-2">
|
<div className="flex-1 overflow-y-auto px-3 py-3 space-y-2">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
Array.from({ length: 4 }).map((_, i) => (
|
Array.from({ length: 4 }).map((_, i) => (
|
||||||
@@ -901,6 +1207,18 @@ const StormIntelPage = () => {
|
|||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
onFlyTo={handleFlyTo}
|
onFlyTo={handleFlyTo}
|
||||||
|
onAssignZone={handleStartDrawing}
|
||||||
|
canManageZones={canManageZones}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Zone assignment modal */}
|
||||||
|
<ZoneAssignmentModal
|
||||||
|
open={showZoneModal}
|
||||||
|
onClose={() => { setShowZoneModal(false); setDraftPolygon([]); setDrawingLinkedStormId(null); }}
|
||||||
|
onSave={handleSaveZone}
|
||||||
|
polygon={draftPolygon}
|
||||||
|
linkedStormId={drawingLinkedStormId}
|
||||||
|
events={allEvents}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user