diff --git a/src/App.jsx b/src/App.jsx index b420791..c1bfb3c 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -30,6 +30,7 @@ import VendorOrders from './pages/vendor/VendorOrders'; import ContractorDashboard from './pages/contractor/ContractorDashboard'; import SubContractorDashboard from './pages/subcontractor/SubContractorDashboard'; import CreateLeadPage from './pages/CreateLeadPage'; +import LeadsListPage from './pages/LeadsListPage'; // ... (existing imports) const ProtectedRoute = ({ children, allowedRoles }) => { @@ -107,6 +108,14 @@ function App() { } /> + + + + } + /> { { to: "/owner/snapshot", icon: LayoutDashboard, label: "Owners Box" }, { to: "/admin/dashboard", icon: LayoutDashboard, label: "Dashboard" }, { to: "/owner/projects", icon: Briefcase, label: "Projects" }, - { to: "/emp/fa/leads/new", icon: PlusCircle, label: "New Lead" }, + { to: "/emp/fa/leads", icon: ClipboardList, label: "Leads" }, { to: "/owner/maps", icon: Map, label: "Territory Map" }, { to: "/owner/pro-canvas", @@ -154,7 +154,7 @@ const Layout = () => { { to: "/admin/dashboard", icon: LayoutDashboard, label: "Dashboard" }, { to: "/admin/schedule", icon: Calendar, label: "Schedule" }, { to: "/admin/leaderboard", icon: Trophy, label: "Leaderboard" }, - { to: "/emp/fa/leads/new", icon: PlusCircle, label: "New Lead" }, + { to: "/emp/fa/leads", icon: ClipboardList, label: "Leads" }, { to: "/admin/maps", icon: Map, label: "Territory Map" }, { to: "/admin/pro-canvas", @@ -181,7 +181,7 @@ const Layout = () => { return [ { to: "/emp/fa/dashboard", icon: LayoutDashboard, label: "Dashboard" }, { to: "/emp/fa/maps", icon: Map, label: "My Map" }, - { to: "/emp/fa/leads/new", icon: PlusCircle, label: "New Lead" }, + { to: "/emp/fa/leads", icon: ClipboardList, label: "Leads" }, { to: "/emp/fa/pro-canvas", icon: LayoutDashboard, diff --git a/src/data/mockStore.jsx b/src/data/mockStore.jsx index 1042d82..9ba41df 100644 --- a/src/data/mockStore.jsx +++ b/src/data/mockStore.jsx @@ -2735,6 +2735,7 @@ export const MockStoreProvider = ({ children }) => { const [projects, setProjects] = useState(MOCK_PROJECTS); const [orders, setOrders] = useState(MOCK_ORDERS); const [vendorInvoices, setVendorInvoices] = useState(MOCK_VENDOR_INVOICES); + const [leads, setLeads] = useState([]); // Initialize properties once useEffect(() => { @@ -2823,6 +2824,18 @@ export const MockStoreProvider = ({ children }) => { orders, vendorInvoices, + leads, + addLead: (lead) => { + const newLead = { + ...lead, + id: `lead_${Date.now()}`, + createdAt: new Date().toISOString(), + status: 'New', + }; + setLeads(prev => [newLead, ...prev]); + return newLead; + }, + updatePropertyStatus, addMeeting, updateUser: (updatedUser) => { diff --git a/src/pages/CreateLeadPage.jsx b/src/pages/CreateLeadPage.jsx index 12374e5..e9afb3a 100644 --- a/src/pages/CreateLeadPage.jsx +++ b/src/pages/CreateLeadPage.jsx @@ -3,9 +3,11 @@ import { motion, AnimatePresence } from 'framer-motion'; import { useNavigate } from 'react-router-dom'; import { useTheme } from '../context/ThemeContext'; import { useAuth } from '../context/AuthContext'; +import { useMockStore } from '../data/mockStore'; +import { toast } from 'sonner'; import { User, MapPin, Briefcase, Shield, Users, - ChevronLeft, Zap, FileText, Save, + ChevronLeft, Zap, FileText, Save, AlertCircle, } from 'lucide-react'; import LeadSectionWrapper from '../components/leads/LeadSectionWrapper'; import LeadJobSection from '../components/leads/LeadJobSection'; @@ -103,8 +105,10 @@ const INITIAL_FORM = { export default function CreateLeadPage() { const { theme } = useTheme(); const { user } = useAuth(); + const { addLead } = useMockStore(); const isDark = theme === 'dark'; const navigate = useNavigate(); + const [validationError, setValidationError] = useState(''); const [isQuickCapture, setIsQuickCapture] = useState(true); const [openSections, setOpenSections] = useState({ @@ -159,6 +163,45 @@ export default function CreateLeadPage() { setOpenSections(prev => ({ ...prev, [id]: !prev[id] })); }; + 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 (missingFields.length > 0) { + setValidationError(`Required: ${missingFields.join(', ')}`); + // Open the first incomplete section + if (!formData.firstName || !formData.lastName || !formData.phones.some(p => p.number.replace(/\D/g, '').length >= 10)) { + setOpenSections(prev => ({ ...prev, contact: true })); + } else if (!formData.address || !formData.city) { + setOpenSections(prev => ({ ...prev, property: true })); + } else { + setOpenSections(prev => ({ ...prev, job: true })); + } + toast.error(`Missing required fields: ${missingFields.join(', ')}`); + return; + } + + setValidationError(''); + const savedLead = addLead({ + ...formData, + createdBy: user?.id, + createdByName: user?.name, + isQuickCapture, + }); + + 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) => { @@ -240,7 +283,7 @@ export default function CreateLeadPage() {
+ + {/* ---------------------------------------------------------------- + Desktop save bar (hidden on mobile — mobile uses sticky footer) + ---------------------------------------------------------------- */} +
+
+ {/* Validation error */} + + {validationError && ( + + + {validationError} + + )} + +
+ + {progress}% complete + + +
+
+
); } diff --git a/src/pages/LeadsListPage.jsx b/src/pages/LeadsListPage.jsx new file mode 100644 index 0000000..4d6e267 --- /dev/null +++ b/src/pages/LeadsListPage.jsx @@ -0,0 +1,268 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useTheme } from '../context/ThemeContext'; +import { useMockStore } from '../data/mockStore'; +import { + Plus, Search, MapPin, Phone, Zap, FileText, + Clock, User, ChevronRight, Filter, +} from 'lucide-react'; + +// --------------------------------------------------------------------------- +// Urgency badge +// --------------------------------------------------------------------------- +const URGENCY_CONFIG = { + standard: { label: 'Standard', bg: 'bg-zinc-200 dark:bg-zinc-700', text: 'text-zinc-600 dark:text-zinc-300' }, + high: { label: 'High', bg: 'bg-amber-100 dark:bg-amber-500/20', text: 'text-amber-700 dark:text-amber-400' }, + emergency: { label: 'Emergency', bg: 'bg-red-100 dark:bg-red-500/20', text: 'text-red-700 dark:text-red-400' }, +}; + +const STATUS_CONFIG = { + New: { bg: 'bg-blue-100 dark:bg-blue-500/20', text: 'text-blue-700 dark:text-blue-400' }, + Contacted: { bg: 'bg-purple-100 dark:bg-purple-500/20', text: 'text-purple-700 dark:text-purple-400' }, + Appointed: { bg: 'bg-emerald-100 dark:bg-emerald-500/20', text: 'text-emerald-700 dark:text-emerald-400' }, + Closed: { bg: 'bg-zinc-100 dark:bg-zinc-700', text: 'text-zinc-500 dark:text-zinc-400' }, +}; + +function UrgencyBadge({ urgency }) { + const cfg = URGENCY_CONFIG[urgency] ?? URGENCY_CONFIG.standard; + return ( + + {cfg.label} + + ); +} + +function StatusBadge({ status }) { + const cfg = STATUS_CONFIG[status] ?? STATUS_CONFIG.New; + return ( + + {status} + + ); +} + +// --------------------------------------------------------------------------- +// Relative time formatter +// --------------------------------------------------------------------------- +function timeAgo(isoString) { + const diff = Math.floor((Date.now() - new Date(isoString)) / 1000); + if (diff < 60) return 'just now'; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} + +// --------------------------------------------------------------------------- +// Lead Card +// --------------------------------------------------------------------------- +function LeadCard({ lead, index }) { + const { theme } = useTheme(); + const isDark = theme === 'dark'; + const primaryPhone = lead.phones?.find(p => p.isPrimary) ?? lead.phones?.[0]; + + return ( + + {/* Avatar */} +
+ {(lead.firstName?.[0] ?? '?')}{(lead.lastName?.[0] ?? '')} +
+ + {/* Main info */} +
+
+ + {lead.firstName} {lead.lastName} + + + + {lead.isQuickCapture && ( + + + Quick + + )} +
+ +
+ {(lead.address || lead.city) && ( + + + {[lead.address, lead.city, lead.state].filter(Boolean).join(', ')} + + )} + {primaryPhone?.number && ( + + + {primaryPhone.number} + + )} + {lead.leadSource && ( + + + {lead.leadSource} + + )} +
+
+ + {/* Right side */} +
+ + + {timeAgo(lead.createdAt)} + + {lead.createdByName && ( + + + {lead.createdByName.split(' ')[0]} + + )} + +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Empty state +// --------------------------------------------------------------------------- +function EmptyState({ isDark, onNewLead }) { + return ( + +
+ +
+
+

+ No Leads Yet +

+

+ Capture your first lead at the door or from the office. +

+
+ +
+ ); +} + +// --------------------------------------------------------------------------- +// Main Page +// --------------------------------------------------------------------------- +export default function LeadsListPage() { + const { theme } = useTheme(); + const { leads } = useMockStore(); + const navigate = useNavigate(); + const isDark = theme === 'dark'; + + const [search, setSearch] = useState(''); + + const filtered = leads.filter(lead => { + if (!search.trim()) return true; + const q = search.toLowerCase(); + return ( + `${lead.firstName} ${lead.lastName}`.toLowerCase().includes(q) || + lead.address?.toLowerCase().includes(q) || + lead.city?.toLowerCase().includes(q) || + lead.leadSource?.toLowerCase().includes(q) || + lead.status?.toLowerCase().includes(q) + ); + }); + + return ( +
+
+ + {/* --- Header --- */} +
+
+

+ Leads +

+

+ {leads.length} total lead{leads.length !== 1 ? 's' : ''} +

+
+ +
+ + {/* --- Search bar --- */} + {leads.length > 0 && ( +
+ + setSearch(e.target.value)} + placeholder="Search by name, address, source…" + className="flex-1 py-3 pl-3 pr-4 text-sm outline-none bg-transparent + text-zinc-800 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-500" + /> +
+ )} + + {/* --- List --- */} + {leads.length === 0 ? ( + navigate('/emp/fa/leads/new')} /> + ) : filtered.length === 0 ? ( +
+ No leads match "{search}" +
+ ) : ( +
+ + {filtered.map((lead, i) => ( + + ))} + +
+ )} +
+
+ ); +}