feat(hail-recon): Hail Recon integration — storm intel panel, hail history, auto storm mode, address monitoring
This commit is contained in:
@@ -5,6 +5,7 @@ import { useTheme } from '../context/ThemeContext';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useMockStore } from '../data/mockStore';
|
||||
import { toast } from 'sonner';
|
||||
import { useRegisterAddress } from '../hooks/useHailRecon';
|
||||
import {
|
||||
User, MapPin, Briefcase, Shield, Users,
|
||||
ChevronLeft, Zap, FileText, Save, AlertCircle,
|
||||
@@ -109,6 +110,7 @@ export default function CreateLeadPage() {
|
||||
const { theme } = useTheme();
|
||||
const { user } = useAuth();
|
||||
const { addLead } = useMockStore();
|
||||
const registerAddress = useRegisterAddress();
|
||||
const isDark = theme === 'dark';
|
||||
const navigate = useNavigate();
|
||||
const [validationError, setValidationError] = useState('');
|
||||
@@ -218,6 +220,26 @@ export default function CreateLeadPage() {
|
||||
isQuickCapture,
|
||||
});
|
||||
|
||||
// Silently register the address with Hail Recon for future monitoring
|
||||
registerAddress({
|
||||
id: savedLead?.id,
|
||||
urgency: formData.urgency,
|
||||
leadType: formData.jobType,
|
||||
property: {
|
||||
address: formData.address,
|
||||
city: formData.city || 'Plano',
|
||||
state: formData.state || 'TX',
|
||||
zip: formData.zip || '',
|
||||
lat: formData.lat || null,
|
||||
lng: formData.lng || null,
|
||||
},
|
||||
customer: {
|
||||
name: `${formData.firstName || ''} ${formData.lastName || ''}`.trim(),
|
||||
phone: formData.phone || '',
|
||||
email: formData.email || '',
|
||||
},
|
||||
});
|
||||
|
||||
toast.success(`Lead saved — ${formData.firstName} ${formData.lastName}`, {
|
||||
description: formData.address || 'No address provided',
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useMockStore } from '../data/mockStore';
|
||||
import { useHailHistory, hailSeverityColor, daysAgo } from '../hooks/useHailRecon';
|
||||
import { SpotlightCard } from '../components/SpotlightCard';
|
||||
import { AnimatedCounter } from '../components/AnimatedCounter';
|
||||
import { toast } from 'sonner';
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
MessageSquare, CheckCircle, ChevronDown, Activity, AlertTriangle,
|
||||
TrendingUp, Calendar, DollarSign, GitPullRequest, AlertCircle,
|
||||
Milestone, FolderOpen, Upload, Trash2, Eye, Pencil, X as XIcon,
|
||||
File, StickyNote, Download, Image as ImageIcon
|
||||
File, StickyNote, Download, Image as ImageIcon, CloudLightning
|
||||
} from 'lucide-react';
|
||||
|
||||
// ── Neomorphic constants (mirrors OwnerProjectDetail) ─────────────────────────
|
||||
@@ -209,6 +210,89 @@ const TABS_SIMPLE = [
|
||||
];
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
// ── Hail History Section ──────────────────────────────────────────────────────
|
||||
function HailHistorySection({ leadId, lat, lng }) {
|
||||
const { history, loading, hitCount, lastHit, maxSize } = useHailHistory(leadId, lat, lng, 12);
|
||||
|
||||
const summaryColor = maxSize >= 2.0 ? 'text-red-600 dark:text-red-400' :
|
||||
maxSize >= 1.5 ? 'text-orange-600 dark:text-orange-400' :
|
||||
maxSize >= 1.0 ? 'text-yellow-600 dark:text-yellow-400' :
|
||||
'text-zinc-400';
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-zinc-200 dark:border-white/[0.06] bg-white dark:bg-zinc-900/40 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-5 py-4 border-b border-zinc-100 dark:border-white/[0.04]">
|
||||
<div className="p-2 rounded-xl bg-amber-500/10 border border-amber-500/20">
|
||||
<CloudLightning size={16} className="text-amber-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-zinc-900 dark:text-white">Hail Impact History</h3>
|
||||
<p className="text-[10px] text-zinc-500 mt-0.5">Last 12 months · Powered by Hail Recon</p>
|
||||
</div>
|
||||
{!loading && hitCount > 0 && (
|
||||
<div className="text-right">
|
||||
<p className={`text-lg font-black font-mono ${summaryColor}`}>{hitCount}x</p>
|
||||
<p className="text-[10px] text-zinc-400">
|
||||
{lastHit ? `Last: ${daysAgo(lastHit.date)}d ago` : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 py-4">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-amber-500 border-t-transparent animate-spin" />
|
||||
<p className="text-xs text-zinc-400">Checking hail history...</p>
|
||||
</div>
|
||||
) : hitCount === 0 ? (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<Shield size={14} className="text-emerald-500 shrink-0" />
|
||||
<p className="text-sm text-zinc-500">No hail impacts recorded in the last 12 months.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{history.map((event, i) => {
|
||||
const textCls = hailSeverityColor(event.hailSize, 'text');
|
||||
const bgCls = hailSeverityColor(event.hailSize, 'bg');
|
||||
const brdCls = hailSeverityColor(event.hailSize, 'border');
|
||||
const days = daysAgo(event.date);
|
||||
const dateObj = new Date(event.date);
|
||||
const dateLabel = dateObj.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-3 py-2.5 border-b border-zinc-100 dark:border-white/[0.04] last:border-0">
|
||||
{/* Date */}
|
||||
<div className="w-28 shrink-0">
|
||||
<p className="text-xs font-semibold text-zinc-700 dark:text-zinc-200">{dateLabel}</p>
|
||||
<p className="text-[10px] text-zinc-400">{days === 0 ? 'Today' : `${days}d ago`}</p>
|
||||
</div>
|
||||
{/* Size badge */}
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-lg text-xs font-black border ${bgCls} ${brdCls} ${textCls}`}>
|
||||
{event.hailSize?.toFixed(2)}"
|
||||
</span>
|
||||
{/* Severity label */}
|
||||
<span className={`text-[11px] font-semibold ${textCls}`}>
|
||||
{event.hailSize >= 2.0 ? 'Severe' :
|
||||
event.hailSize >= 1.5 ? 'Significant' :
|
||||
event.hailSize >= 1.0 ? 'Moderate' : 'Trace'}
|
||||
</span>
|
||||
{i === 0 && (
|
||||
<span className="ml-auto text-[10px] font-bold px-2 py-0.5 rounded-full bg-amber-100 dark:bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-200 dark:border-amber-500/20">
|
||||
Most Recent
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LeadProjectPage() {
|
||||
const { leadId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -468,11 +552,17 @@ export default function LeadProjectPage() {
|
||||
)}
|
||||
</NeoCard>
|
||||
</div>
|
||||
<HailHistorySection
|
||||
leadId={leadId}
|
||||
lat={lead.lat ?? 33.055}
|
||||
lng={lead.lng ?? -96.752}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overview for simple (no project data) */}
|
||||
{activeTab === 'overview' && !proj && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<NeoCard spotlightColor="rgba(59,130,246,0.08)">
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
@@ -528,6 +618,12 @@ export default function LeadProjectPage() {
|
||||
</div>
|
||||
</NeoCard>
|
||||
</div>
|
||||
<HailHistorySection
|
||||
leadId={leadId}
|
||||
lat={lead.lat ?? 33.055}
|
||||
lng={lead.lng ?? -96.752}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── CONTACT (simple only) ── */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Zap, CloudLightning, Inbox, CheckCircle, Clock, AlertTriangle, Bot, ChevronDown, History, BarChart2 } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { toast } from 'sonner';
|
||||
import { useMockStore, DISPATCH_REPS } from '../data/mockStore';
|
||||
import DispatchLeadQueue from '../components/dispatch/DispatchLeadQueue';
|
||||
import DispatchRecommendationDrawer from '../components/dispatch/DispatchRecommendationDrawer';
|
||||
@@ -8,6 +9,9 @@ import DispatchMapPanel from '../components/dispatch/DispatchMapPanel';
|
||||
import LeadQuickViewModal from '../components/dispatch/LeadQuickViewModal';
|
||||
import DispatchLogDrawer from '../components/dispatch/DispatchLogDrawer';
|
||||
import DispatchChartModal from '../components/dispatch/DispatchChartModal';
|
||||
import StormIntelPanel from '../components/dispatch/StormIntelPanel';
|
||||
import { useHailSummaries } from '../hooks/useHailRecon';
|
||||
import { useStormStatus } from '../hooks/useStormStatus';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Panel — themed container
|
||||
@@ -145,6 +149,28 @@ const LynkDispatchPage = () => {
|
||||
const [stormMode, setStormMode] = useState(false);
|
||||
const [logDrawerOpen, setLogDrawerOpen] = useState(false);
|
||||
const [chartModalOpen, setChartModalOpen] = useState(false);
|
||||
const [filterHailOnly, setFilterHailOnly] = useState(false);
|
||||
|
||||
// Hail Recon — load summaries for all leads in queue
|
||||
const { summaries: hailSummaries, loading: hailLoading } = useHailSummaries(dispatchLeads);
|
||||
|
||||
// Auto Storm Mode from webhook polling
|
||||
const { stormEvent } = useStormStatus(useCallback((event) => {
|
||||
setStormMode(true);
|
||||
toast.custom(toastId => (
|
||||
<div className="flex items-start gap-3 px-4 py-3 rounded-2xl bg-amber-50 border border-amber-300 shadow-lg shadow-amber-100 dark:bg-zinc-900 dark:border-amber-500/40 dark:shadow-amber-500/10">
|
||||
<CloudLightning size={18} className="text-amber-500 animate-pulse mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-black text-amber-700 dark:text-amber-400 uppercase tracking-wide">Hail Alert — Storm Mode Activated</p>
|
||||
<p className="text-xs text-amber-600 dark:text-amber-500 mt-0.5">
|
||||
{event?.address ?? 'A monitored property'} was just hit
|
||||
{event?.hailSize ? ` with ${event.hailSize}" hail` : ''}.
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => toast.dismiss(toastId)} className="ml-auto text-amber-400 hover:text-amber-600 text-xs font-bold">✕</button>
|
||||
</div>
|
||||
), { duration: 8000 });
|
||||
}, []));
|
||||
|
||||
// Rep status live cycling — overrides static DISPATCH_REPS.status
|
||||
const [repStatuses, setRepStatuses] = useState(
|
||||
@@ -485,6 +511,28 @@ const LynkDispatchPage = () => {
|
||||
)
|
||||
}
|
||||
/>
|
||||
{/* Storm Intel Panel — Storm Mode only */}
|
||||
<AnimatePresence>
|
||||
{stormMode && !hailLoading && (
|
||||
<motion.div
|
||||
key="storm-intel"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className="shrink-0 px-3 pb-2 overflow-hidden"
|
||||
>
|
||||
<StormIntelPanel
|
||||
leads={dispatchLeads}
|
||||
summaries={hailSummaries}
|
||||
stormEvent={stormEvent}
|
||||
filterHailOnly={filterHailOnly}
|
||||
onFilterChange={setFilterHailOnly}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{isDesktop ? (
|
||||
<DispatchLeadQueue
|
||||
selectedLead={selectedLead}
|
||||
@@ -493,6 +541,8 @@ const LynkDispatchPage = () => {
|
||||
onQuickAssign={handleAssign}
|
||||
stormMode={stormMode}
|
||||
accent={accent}
|
||||
hailSummaries={hailSummaries}
|
||||
filterHailOnly={filterHailOnly}
|
||||
/>
|
||||
) : (
|
||||
<CollapsePanel open={!collapsed.queue}>
|
||||
@@ -503,6 +553,8 @@ const LynkDispatchPage = () => {
|
||||
onQuickAssign={handleAssign}
|
||||
stormMode={stormMode}
|
||||
accent={accent}
|
||||
hailSummaries={hailSummaries}
|
||||
filterHailOnly={filterHailOnly}
|
||||
/>
|
||||
</CollapsePanel>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user