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,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;
|
||||
Reference in New Issue
Block a user