feat(leads): Phase 8 — Save action, lead list page, and nav update

Save action (CreateLeadPage):
- Validate required fields: first/last name, 10-digit phone, address, city, lead source
- On failure: Sonner error toast + auto-opens first incomplete section
- On success: Sonner success toast with name + address, redirects to /emp/fa/leads
- Desktop save bar with progress % indicator and validation error message
- Back button navigates to /emp/fa/leads instead of browser history

Leads List page (LeadsListPage.jsx):
- Route: /emp/fa/leads (FIELD_AGENT, ADMIN, OWNER)
- Lead cards: name, urgency + status badges, address, phone, lead source, time ago, creator
- Search bar: filters by name, address, city, source, status
- Empty state with CTA when no leads saved yet
- Animated card entrance with staggered spring transitions

mockStore:
- Add leads[] state and addLead() action — prepends new lead with id, createdAt, status: New

Navigation:
- Sidebar "New Lead" replaced with "Leads" (ClipboardList icon) for all roles
- New Lead button lives on the list page header

Author: Satyam Rastogi
This commit is contained in:
Satyam
2026-03-14 23:51:31 +05:30
parent d801f54600
commit c393ae676a
5 changed files with 379 additions and 6 deletions
+85 -2
View File
@@ -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() {
<div className="mb-7">
<button
type="button"
onClick={() => navigate(-1)}
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} />
@@ -388,6 +431,7 @@ export default function CreateLeadPage() {
{/* 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' }}
>
@@ -396,6 +440,45 @@ export default function CreateLeadPage() {
</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>
);
}