597 lines
26 KiB
React
597 lines
26 KiB
React
import React, { useState, useRef, useEffect } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { useNavigate, useLocation } from 'react-router-dom';
|
|
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, CloudLightning, X,
|
|
} from 'lucide-react';
|
|
import LeadSectionWrapper from '../components/leads/LeadSectionWrapper';
|
|
import LeadJobSection from '../components/leads/LeadJobSection';
|
|
import LeadContactSection from '../components/leads/LeadContactSection';
|
|
import LeadPropertySection from '../components/leads/LeadPropertySection';
|
|
import LeadInsuranceSection from '../components/leads/LeadInsuranceSection';
|
|
import LeadAssignmentSection from '../components/leads/LeadAssignmentSection';
|
|
import gsap from 'gsap';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Section definitions
|
|
// ---------------------------------------------------------------------------
|
|
const SECTIONS = [
|
|
{
|
|
id: 'contact',
|
|
title: 'Contact',
|
|
icon: User,
|
|
accentColor: '#3B82F6',
|
|
quickCapture: true,
|
|
description: 'Name, phone numbers, and email addresses',
|
|
},
|
|
{
|
|
id: 'property',
|
|
title: 'Property',
|
|
icon: MapPin,
|
|
accentColor: '#10B981',
|
|
quickCapture: true,
|
|
description: 'Property address, type, and site photos',
|
|
},
|
|
{
|
|
id: 'job',
|
|
title: 'Job Details',
|
|
icon: Briefcase,
|
|
accentColor: '#F59E0B',
|
|
quickCapture: true,
|
|
description: 'Lead source, work type, urgency, and notes',
|
|
},
|
|
{
|
|
id: 'insurance',
|
|
title: 'Insurance',
|
|
icon: Shield,
|
|
accentColor: '#8B5CF6',
|
|
quickCapture: false,
|
|
description: 'Carrier, claim number, adjuster, and policy details',
|
|
},
|
|
{
|
|
id: 'assignment',
|
|
title: 'Assignment',
|
|
icon: Users,
|
|
accentColor: '#06B6D4',
|
|
quickCapture: false,
|
|
description: 'Assign a rep, set priority, and schedule follow-up',
|
|
},
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Initial form state
|
|
// ---------------------------------------------------------------------------
|
|
const INITIAL_FORM = {
|
|
// Job Details
|
|
leadSource: '',
|
|
canvasserId: '',
|
|
canvasserName: '',
|
|
referralNote: '',
|
|
leadType: '',
|
|
workType: '',
|
|
tradeType: '',
|
|
urgency: 'standard',
|
|
notes: '',
|
|
// Contact
|
|
firstName: '',
|
|
lastName: '',
|
|
phones: [{ id: '1', number: '', type: 'Mobile', isPrimary: true }],
|
|
emails: [],
|
|
// Property
|
|
address: '',
|
|
city: '',
|
|
state: 'TX',
|
|
zip: '',
|
|
propertyType: '',
|
|
propertyPhotos: [],
|
|
// Insurance (Phase 5)
|
|
insuranceCompany: '',
|
|
claimNumber: '',
|
|
claimStatus: '',
|
|
adjusterName: '',
|
|
adjusterPhone: '',
|
|
policyNumber: '',
|
|
// Assignment (Phase 6)
|
|
assignedTo: '',
|
|
priority: 'medium',
|
|
followUpDate: '',
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component
|
|
// ---------------------------------------------------------------------------
|
|
export default function CreateLeadPage() {
|
|
const { theme } = useTheme();
|
|
const { user } = useAuth();
|
|
const { addLead } = useMockStore();
|
|
const registerAddress = useRegisterAddress();
|
|
const isDark = theme === 'dark';
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const [validationError, setValidationError] = useState('');
|
|
|
|
// Storm zone context — passed from StormIntelPage via router state
|
|
const stormSource = location.state?.stormSource ?? null;
|
|
const [stormBannerDismissed, setStormBannerDismissed] = useState(false);
|
|
|
|
const [isQuickCapture, setIsQuickCapture] = useState(true);
|
|
const [openSections, setOpenSections] = useState({
|
|
contact: true,
|
|
property: false,
|
|
job: false,
|
|
insurance: false,
|
|
assignment: false,
|
|
});
|
|
|
|
// Pre-fill form when arriving from Storm Intel
|
|
const [formData, setFormData] = useState(() => {
|
|
if (!stormSource) return INITIAL_FORM;
|
|
const urgency = (stormSource.severity === 'severe' || stormSource.severity === 'significant') ? 'high' : 'standard';
|
|
const stormDate = stormSource.date
|
|
? new Date(stormSource.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
|
: '';
|
|
return {
|
|
...INITIAL_FORM,
|
|
city: 'Plano',
|
|
state: 'TX',
|
|
leadSource: 'Canvassers',
|
|
urgency,
|
|
notes: `Storm zone canvass — ${stormSource.areaName}.${stormSource.maxHailSize ? ` ${stormSource.maxHailSize}" hail` : ''}${stormDate ? ` on ${stormDate}` : ''}.`,
|
|
stormSource,
|
|
};
|
|
});
|
|
|
|
const progressBarRef = useRef(null);
|
|
|
|
// Section completion map
|
|
const sectionCompletion = {
|
|
contact: !!(
|
|
formData.firstName &&
|
|
formData.lastName &&
|
|
formData.phones.some(p => p.number.replace(/\D/g, '').length >= 10)
|
|
),
|
|
property: !!(formData.address && formData.city),
|
|
job: isQuickCapture
|
|
? !!formData.leadSource
|
|
: !!(formData.leadSource && formData.leadType && formData.workType),
|
|
insurance: !!(formData.insuranceCompany && formData.claimStatus),
|
|
assignment: !!(formData.assignedTo && formData.followUpDate),
|
|
};
|
|
|
|
// Progress = % of visible sections that are complete
|
|
const visibleSectionIds = (isQuickCapture ? SECTIONS.filter(s => s.quickCapture) : SECTIONS).map(s => s.id);
|
|
const completedCount = visibleSectionIds.filter(id => sectionCompletion[id]).length;
|
|
const progress = visibleSectionIds.length > 0
|
|
? Math.round((completedCount / visibleSectionIds.length) * 100)
|
|
: 0;
|
|
|
|
// GSAP animates the progress bar whenever progress changes
|
|
useEffect(() => {
|
|
if (progressBarRef.current) {
|
|
gsap.to(progressBarRef.current, {
|
|
width: `${progress}%`,
|
|
duration: 0.45,
|
|
ease: 'power2.out',
|
|
});
|
|
}
|
|
}, [progress]);
|
|
|
|
const updateField = (field, value) => {
|
|
setFormData(prev => {
|
|
const next = { ...prev, [field]: value };
|
|
if (field === 'leadSource') {
|
|
if (value !== 'Canvassers') { next.canvasserId = ''; next.canvasserName = ''; }
|
|
if (value !== 'Referral') { next.referralNote = ''; }
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const toggleSection = (id) => {
|
|
setOpenSections(prev => {
|
|
const isOpening = !prev[id];
|
|
const next = Object.keys(prev).reduce((acc, key) => {
|
|
acc[key] = false;
|
|
return acc;
|
|
}, {});
|
|
next[id] = isOpening;
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const handleSave = () => {
|
|
// Required field validation
|
|
const missingFields = [];
|
|
if (!formData.firstName) missingFields.push('First Name');
|
|
if (!formData.lastName) missingFields.push('Last Name');
|
|
if (!formData.phones.some(p => p.number.replace(/\D/g, '').length >= 10))
|
|
missingFields.push('Phone Number');
|
|
if (!formData.address) missingFields.push('Street Address');
|
|
if (!formData.city) missingFields.push('City');
|
|
if (!formData.leadSource) missingFields.push('Lead Source');
|
|
if (formData.leadSource === 'Canvassers' && !formData.canvasserId) missingFields.push('Canvasser');
|
|
|
|
if (missingFields.length > 0) {
|
|
setValidationError(`Required: ${missingFields.join(', ')}`);
|
|
// Open the first incomplete section (close others for accordion behavior)
|
|
const openOnly = (id) => setOpenSections(prev =>
|
|
Object.keys(prev).reduce((acc, key) => ({ ...acc, [key]: key === id }), {})
|
|
);
|
|
if (!formData.firstName || !formData.lastName || !formData.phones.some(p => p.number.replace(/\D/g, '').length >= 10)) {
|
|
openOnly('contact');
|
|
} else if (!formData.address || !formData.city) {
|
|
openOnly('property');
|
|
} else {
|
|
openOnly('job');
|
|
}
|
|
toast.error(`Missing required fields: ${missingFields.join(', ')}`);
|
|
return;
|
|
}
|
|
|
|
setValidationError('');
|
|
const savedLead = addLead({
|
|
...formData,
|
|
createdBy: user?.id,
|
|
createdByName: user?.name,
|
|
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',
|
|
});
|
|
navigate('/emp/fa/leads');
|
|
};
|
|
|
|
const visibleSections = isQuickCapture ? SECTIONS.filter(s => s.quickCapture) : SECTIONS;
|
|
|
|
const modeSwitchTo = (quick) => {
|
|
if (quick === isQuickCapture) return;
|
|
setIsQuickCapture(quick);
|
|
if (quick) {
|
|
setOpenSections(prev => ({ ...prev, insurance: false, assignment: false }));
|
|
}
|
|
};
|
|
|
|
// Render the content for a given section (placeholder until its phase is built)
|
|
const renderSectionContent = (section) => {
|
|
if (section.id === 'contact') {
|
|
return (
|
|
<LeadContactSection
|
|
formData={formData}
|
|
updateField={updateField}
|
|
isQuickCapture={isQuickCapture}
|
|
/>
|
|
);
|
|
}
|
|
if (section.id === 'property') {
|
|
return (
|
|
<LeadPropertySection
|
|
formData={formData}
|
|
updateField={updateField}
|
|
isQuickCapture={isQuickCapture}
|
|
/>
|
|
);
|
|
}
|
|
if (section.id === 'job') {
|
|
return (
|
|
<LeadJobSection
|
|
formData={formData}
|
|
updateField={updateField}
|
|
isQuickCapture={isQuickCapture}
|
|
/>
|
|
);
|
|
}
|
|
if (section.id === 'insurance') {
|
|
return (
|
|
<LeadInsuranceSection
|
|
formData={formData}
|
|
updateField={updateField}
|
|
/>
|
|
);
|
|
}
|
|
if (section.id === 'assignment') {
|
|
return (
|
|
<LeadAssignmentSection
|
|
formData={formData}
|
|
updateField={updateField}
|
|
currentUser={user}
|
|
/>
|
|
);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
return (
|
|
<div className={`min-h-screen transition-colors duration-300
|
|
${isDark ? 'bg-[#09090b]' : 'bg-zinc-100/80'}
|
|
`}>
|
|
{/* --- Progress bar --- */}
|
|
<div className={`h-[3px] w-full ${isDark ? 'bg-zinc-800' : 'bg-zinc-200'}`}>
|
|
<div
|
|
ref={progressBarRef}
|
|
className="h-full bg-blue-500"
|
|
style={{ width: '0%' }}
|
|
/>
|
|
</div>
|
|
|
|
{/* ----------------------------------------------------------------
|
|
Page content
|
|
---------------------------------------------------------------- */}
|
|
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-6 lg:py-10 pb-44 md:pb-40">
|
|
|
|
{/* --- Page Header --- */}
|
|
<div className="mb-7">
|
|
<button
|
|
type="button"
|
|
onClick={() => navigate('/emp/fa/leads')}
|
|
className="flex items-center gap-1 text-xs uppercase tracking-widest font-bold text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 mb-3 transition-colors"
|
|
>
|
|
<ChevronLeft size={13} strokeWidth={2.5} />
|
|
Leads
|
|
</button>
|
|
|
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 sm:gap-4">
|
|
{/* Title */}
|
|
<div className="min-w-0">
|
|
<h1 className="font-['Barlow_Condensed'] font-black uppercase tracking-widest text-3xl sm:text-4xl leading-none text-zinc-900 dark:text-white">
|
|
New Lead
|
|
</h1>
|
|
<p className="text-xs sm:text-sm text-zinc-500 dark:text-zinc-400 mt-1.5">
|
|
{isQuickCapture
|
|
? 'Essential fields only — fastest capture at the door.'
|
|
: 'Full lead profile with insurance and assignment details.'}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Quick / Full Form toggle */}
|
|
<div className={`self-start sm:self-auto flex items-center p-1 rounded-xl gap-0.5 shrink-0
|
|
${isDark ? 'bg-zinc-800' : 'bg-zinc-200/80'}
|
|
`}>
|
|
<button
|
|
type="button"
|
|
onClick={() => modeSwitchTo(true)}
|
|
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-[11px] font-bold uppercase tracking-wider transition-all duration-200
|
|
${isQuickCapture
|
|
? 'bg-white dark:bg-zinc-700 text-zinc-900 dark:text-white shadow-sm'
|
|
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'
|
|
}`}
|
|
>
|
|
<Zap size={11} strokeWidth={2.5} />
|
|
Quick
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => modeSwitchTo(false)}
|
|
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-[11px] font-bold uppercase tracking-wider transition-all duration-200
|
|
${!isQuickCapture
|
|
? 'bg-white dark:bg-zinc-700 text-zinc-900 dark:text-white shadow-sm'
|
|
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'
|
|
}`}
|
|
>
|
|
<FileText size={11} strokeWidth={2.5} />
|
|
Full Form
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Section indicator pills */}
|
|
<div className="flex items-center gap-2 mt-4 flex-wrap">
|
|
{SECTIONS.map(s => (
|
|
<div
|
|
key={s.id}
|
|
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider transition-all duration-300
|
|
${(!s.quickCapture && isQuickCapture)
|
|
? 'opacity-30 bg-zinc-200 dark:bg-zinc-800 text-zinc-400'
|
|
: 'opacity-100'
|
|
}`}
|
|
style={(!s.quickCapture && isQuickCapture)
|
|
? {}
|
|
: { backgroundColor: s.accentColor + '22', color: s.accentColor }
|
|
}
|
|
>
|
|
<s.icon size={9} strokeWidth={2.5} />
|
|
{s.title}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* --- Storm zone banner --- */}
|
|
<AnimatePresence>
|
|
{stormSource && !stormBannerDismissed && (
|
|
<motion.div
|
|
key="storm-banner"
|
|
initial={{ opacity: 0, y: -8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -8 }}
|
|
transition={{ duration: 0.22 }}
|
|
className="mb-5 rounded-xl border border-amber-300 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/8 overflow-hidden"
|
|
>
|
|
<div className="flex items-start gap-3 px-4 py-3">
|
|
<div className="w-8 h-8 rounded-lg bg-amber-500/15 dark:bg-amber-500/20 flex items-center justify-center shrink-0 mt-0.5">
|
|
<CloudLightning size={16} className="text-amber-600 dark:text-amber-400" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-[12px] font-black uppercase tracking-wider text-amber-700 dark:text-amber-400 mb-0.5">
|
|
Storm Zone Lead
|
|
</p>
|
|
<p className="text-[13px] font-semibold text-zinc-800 dark:text-zinc-200">
|
|
{stormSource.areaName}
|
|
{stormSource.maxHailSize && (
|
|
<span className="ml-2 text-[11px] font-bold text-amber-600 dark:text-amber-400">
|
|
{stormSource.maxHailSize}" hail
|
|
</span>
|
|
)}
|
|
</p>
|
|
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 mt-0.5">
|
|
Lead source, urgency, and notes pre-filled from storm data.
|
|
{(stormSource.severity === 'severe' || stormSource.severity === 'significant') && (
|
|
<span className="ml-1 text-amber-600 dark:text-amber-400 font-semibold">Urgency set to High.</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setStormBannerDismissed(true)}
|
|
className="p-1 rounded-lg text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 hover:bg-amber-100 dark:hover:bg-amber-500/10 transition-colors shrink-0"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* --- Sections --- */}
|
|
<div className="space-y-3 mb-8">
|
|
<AnimatePresence mode="popLayout">
|
|
{visibleSections.map(section => (
|
|
<motion.div
|
|
key={section.id}
|
|
initial={{ opacity: 0, y: 22 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ type: 'spring', stiffness: 280, damping: 28 }}
|
|
layout
|
|
exit={{
|
|
opacity: 0,
|
|
scale: 0.97,
|
|
y: -6,
|
|
transition: { duration: 0.18, ease: 'easeIn' },
|
|
}}
|
|
>
|
|
<LeadSectionWrapper
|
|
title={section.title}
|
|
icon={section.icon}
|
|
accentColor={section.accentColor}
|
|
isOpen={openSections[section.id]}
|
|
onToggle={() => toggleSection(section.id)}
|
|
isComplete={sectionCompletion[section.id]}
|
|
>
|
|
{renderSectionContent(section)}
|
|
</LeadSectionWrapper>
|
|
</motion.div>
|
|
))}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ----------------------------------------------------------------
|
|
Mobile sticky save footer
|
|
---------------------------------------------------------------- */}
|
|
<div className={`fixed bottom-0 left-0 right-0 md:hidden px-4 py-3
|
|
border-t backdrop-blur-md
|
|
${isDark
|
|
? 'bg-zinc-900/90 border-white/10'
|
|
: 'bg-white/90 border-zinc-200'
|
|
}
|
|
`}>
|
|
<div className="flex items-center gap-3 max-w-3xl mx-auto">
|
|
{/* Compact mode toggle */}
|
|
<div className={`flex items-center p-0.5 rounded-lg gap-0.5 shrink-0
|
|
${isDark ? 'bg-zinc-800' : 'bg-zinc-100'}
|
|
`}>
|
|
<button
|
|
type="button"
|
|
onClick={() => modeSwitchTo(true)}
|
|
className={`p-2 rounded-md transition-all duration-200
|
|
${isQuickCapture
|
|
? 'bg-white dark:bg-zinc-700 shadow-sm text-zinc-900 dark:text-white'
|
|
: 'text-zinc-400'
|
|
}`}
|
|
>
|
|
<Zap size={14} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => modeSwitchTo(false)}
|
|
className={`p-2 rounded-md transition-all duration-200
|
|
${!isQuickCapture
|
|
? 'bg-white dark:bg-zinc-700 shadow-sm text-zinc-900 dark:text-white'
|
|
: 'text-zinc-400'
|
|
}`}
|
|
>
|
|
<FileText size={14} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Save */}
|
|
<button
|
|
type="button"
|
|
onClick={handleSave}
|
|
className="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl font-bold text-sm uppercase tracking-widest text-white transition-all active:scale-[0.97]"
|
|
style={{ backgroundColor: '#3B82F6' }}
|
|
>
|
|
<Save size={15} />
|
|
Save Lead
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ----------------------------------------------------------------
|
|
Desktop save bar (hidden on mobile — mobile uses sticky footer)
|
|
---------------------------------------------------------------- */}
|
|
<div className={`hidden md:block fixed bottom-0 left-0 right-0 px-6 py-4
|
|
border-t backdrop-blur-md
|
|
${isDark ? 'bg-zinc-900/90 border-white/10' : 'bg-white/90 border-zinc-200'}
|
|
`}>
|
|
<div className="max-w-3xl mx-auto flex items-center justify-between gap-4">
|
|
{/* Validation error */}
|
|
<AnimatePresence>
|
|
{validationError && (
|
|
<motion.div
|
|
initial={{ opacity: 0, x: -8 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: -8 }}
|
|
className="flex items-center gap-2 text-sm text-red-500 dark:text-red-400"
|
|
>
|
|
<AlertCircle size={14} />
|
|
{validationError}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
<div className="ml-auto flex items-center gap-3">
|
|
<span className="text-xs text-zinc-400 dark:text-zinc-500 uppercase tracking-widest font-bold">
|
|
{progress}% complete
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={handleSave}
|
|
className="flex items-center gap-2 px-6 py-2.5 rounded-xl font-bold text-sm uppercase tracking-widest text-white transition-all active:scale-[0.97] hover:opacity-90"
|
|
style={{ backgroundColor: '#3B82F6' }}
|
|
>
|
|
<Save size={14} />
|
|
Save Lead
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|