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

- Tomorrow.io 7-day forecast strip with weather icons, storm score bar, and per-day risk color coding
- High-risk alert banner in forecast strip when any day scores >=65
- api/storm-forecast.js: Vercel proxy that reads TOMORROW_API_KEY server-side; graceful empty response when key unset
- useStormForecast hook: USE_MOCK=false with realistic MOCK_FORECAST fallback
- ZoneDrawingLayer: click-to-place polygon vertices using react-leaflet useMapEvents; disables double-click zoom while active; shows live vertex dots + dashed preview polygon
- ZoneLayer: renders stormZones as colored semi-transparent polygons with agent initials at centroid
- ZoneAssignmentModal: name + canvasser picker + color selector + optional storm link; portaled modal
- StormIntelPage: Draw Zone button (Admin/Owner only); drawing state/cancel flow; stormZones displayed on map; zone summary strip in sidebar; StormDetailDrawer has working "Draw Canvasser Zone" action
- FieldStormZonePage: canvasser mobile view at /field/storm-zones showing assigned zones with map, linked storm details, conversion window badge, and peak window alert
- Layout: Storm Zones entry in FIELD_AGENT nav
- App: /field/storm-zones route (FIELD_AGENT + Admin + Owner)
This commit is contained in:
Satyam-Rastogi
2026-05-18 18:06:17 +05:30
parent df50f27ba6
commit 12bd2fe0de
8 changed files with 1199 additions and 115 deletions
@@ -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;