feat(leadverification): port verification page + components (table, cards, summary, assign/verify modals)

This commit is contained in:
Satyam Rastogi
2026-05-29 21:16:46 +05:30
parent 56f6075ceb
commit f579769e19
9 changed files with 1163 additions and 0 deletions
@@ -0,0 +1,428 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useMockStore } from '../../data/mockStore';
import { usePermissions } from '../../hooks/usePermissions';
import { SpotlightCard } from '../../components/SpotlightCard';
import SummaryCards from '../../components/LeadVerification/SummaryCards';
import LeadTableRow from '../../components/LeadVerification/LeadTableRow';
import LeadCard from '../../components/LeadVerification/LeadCard';
import AssignLeadModal from '../../components/LeadVerification/AssignLeadModal';
import LeadDetailsModal from '../../components/LeadVerification/LeadDetailsModal';
import Select from '../../components/ui/Select';
import {
Search, Filter, ShieldCheck, ChevronLeft, ChevronRight, AlertCircle,
} from 'lucide-react';
const PAGE_SIZE = 8;
// Canonical status list — matches the 5 statuses defined in statusConfig.js.
const LV_STATUSES = ['Pending', 'Assigned', 'In Progress', 'Verified', 'Unverified'];
const Th = ({ children, align }) => (
<th className={`px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 ${align === 'right' ? 'text-right' : ''}`}>
{children}
</th>
);
const EmptyState = () => (
<div className="py-16 text-center text-zinc-500">
<ShieldCheck size={32} className="mx-auto mb-3 text-zinc-400" />
<p className="font-semibold">No verification leads match your filters.</p>
<p className="text-sm mt-1">Try clearing filters or adjust the search.</p>
</div>
);
const ConfirmDialog = ({ title, body, confirmLabel, confirmColor, onConfirm, onClose }) => {
useEffect(() => {
const handler = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onClose]);
return (
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4" role="dialog" aria-modal="true">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-sm bg-white dark:bg-[#121214] rounded-2xl shadow-2xl border border-zinc-200 dark:border-white/10 overflow-hidden animate-in zoom-in-95 duration-200">
<div className="p-5 space-y-3">
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-amber-500/10 text-amber-500">
<AlertCircle size={20} />
</div>
<h2 className="text-base font-bold text-zinc-900 dark:text-white">{title}</h2>
</div>
<div className="text-sm text-zinc-600 dark:text-zinc-400">{body}</div>
</div>
<div className="px-5 py-3 border-t border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5 flex justify-end gap-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-xs font-bold uppercase tracking-wider rounded-lg border border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
className={`px-4 py-2 text-xs font-bold uppercase tracking-wider rounded-lg text-white shadow-lg transition-colors ${confirmColor}`}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
};
export default function LeadVerificationPage() {
// Store keys: leadVerifications, verifyLead, unverifyLead, setLeadVerificationStatus,
// assignLeadVerification, reassignLeadVerification, addLeadVerificationNote, users.
// (leadVerificationStatuses and leadVerificationAssignees don't exist in our store —
// statuses are hardcoded as LV_STATUSES; assignees are derived from users filtered to ADMIN.)
const {
leadVerifications,
verifyLead,
unverifyLead,
setLeadVerificationStatus,
users,
} = useMockStore();
const { can } = usePermissions();
// Owners/Admins/Sales reps with `leads:verify` get the destructive actions.
// Everyone else stays read-only.
const canManage = can('leads', 'verify') || can('leads', 'log');
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [sourceFilter, setSourceFilter] = useState('all');
const [assigneeFilter, setAssigneeFilter] = useState('all');
const [page, setPage] = useState(1);
const [openMenuId, setOpenMenuId] = useState(null);
// Modal state
const [viewLead, setViewLead] = useState(null);
const [assignLead, setAssignLead] = useState(null);
const [reassignLead, setReassignLead] = useState(null);
const [confirmVerify, setConfirmVerify] = useState(null);
const [confirmUnverify, setConfirmUnverify] = useState(null);
// Admin users for assignee filter dropdown (id + name).
const adminUsers = useMemo(
() => (users || []).filter(u => u.role === 'ADMIN'),
[users],
);
// Sources from data (no hard-coded list).
const sources = useMemo(
() => Array.from(new Set(leadVerifications.map(l => l.source))).sort(),
[leadVerifications],
);
// Field names match our store shape: assigneeId / assigneeName.
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return leadVerifications.filter(lv => {
if (q) {
const blob = `${lv.customerName} ${lv.leadId} ${lv.phone} ${lv.email || ''} ${lv.address || ''} ${lv.source} ${lv.assigneeName || ''}`.toLowerCase();
if (!blob.includes(q)) return false;
}
if (statusFilter !== 'all' && lv.status !== statusFilter) return false;
if (sourceFilter !== 'all' && lv.source !== sourceFilter) return false;
if (assigneeFilter !== 'all') {
if (assigneeFilter === 'unassigned' && lv.assigneeId) return false;
if (assigneeFilter !== 'unassigned' && lv.assigneeId !== assigneeFilter) return false;
}
return true;
});
}, [leadVerifications, search, statusFilter, sourceFilter, assigneeFilter]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const pageStart = (safePage - 1) * PAGE_SIZE;
const paginated = filtered.slice(pageStart, pageStart + PAGE_SIZE);
useEffect(() => { setPage(1); }, [search, statusFilter, sourceFilter, assigneeFilter]);
useEffect(() => {
const handler = () => setOpenMenuId(null);
if (openMenuId) document.addEventListener('click', handler);
return () => document.removeEventListener('click', handler);
}, [openMenuId]);
const stats = useMemo(() => ({
total: leadVerifications.length,
verified: leadVerifications.filter(l => l.status === 'Verified').length,
inProgress: leadVerifications.filter(l => l.status === 'In Progress').length,
assigned: leadVerifications.filter(l => l.status === 'Assigned').length,
pending: leadVerifications.filter(l => l.status === 'Pending').length,
unverified: leadVerifications.filter(l => l.status === 'Unverified').length,
}), [leadVerifications]);
const clearFilters = () => {
setSearch('');
setStatusFilter('all');
setSourceFilter('all');
setAssigneeFilter('all');
};
const handleVerify = () => {
if (!confirmVerify) return;
verifyLead(confirmVerify.id);
setConfirmVerify(null);
};
const handleUnverify = () => {
if (!confirmUnverify) return;
unverifyLead(confirmUnverify.id);
setConfirmUnverify(null);
};
return (
<div className="min-h-full bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white transition-colors duration-300 relative">
{/* Ambient bg — matches other CRM pages */}
<div className="fixed top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
<div className="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] bg-emerald-500/5 dark:bg-emerald-900/10 rounded-full blur-[120px]" />
<div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] bg-blue-500/5 dark:bg-blue-900/10 rounded-full blur-[120px]" />
</div>
<div className="relative z-10 p-4 sm:p-6 lg:p-8 max-w-7xl mx-auto space-y-6 pb-20">
{/* Header */}
<header className="flex flex-col md:flex-row justify-between items-start md:items-end gap-4 border-b border-zinc-200 dark:border-white/5 pb-6">
<div>
<h1 className="text-3xl sm:text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-zinc-900 to-zinc-600 dark:from-white dark:to-white/60 tracking-tight">
Lead Verification
</h1>
<p className="text-zinc-500 dark:text-zinc-400 mt-1 text-sm">
{stats.total} total · {stats.verified} verified · {stats.inProgress} in progress · {stats.pending} pending
</p>
</div>
</header>
{/* Summary cards */}
<SummaryCards stats={stats} />
{/* Filters */}
<SpotlightCard className="p-4">
<div className="grid grid-cols-1 md:grid-cols-12 gap-3">
{/* Search */}
<div className="relative md:col-span-5">
<label htmlFor="lv-search" className="sr-only">Search verification leads</label>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={16} />
<input
id="lv-search"
type="text"
placeholder="Search name, lead ID, phone, source…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-zinc-100 dark:bg-black/40 border border-zinc-200 dark:border-white/10 rounded-xl py-2 pl-10 pr-4 text-sm text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500/20"
/>
</div>
{/* Status */}
<div className="md:col-span-3">
<label htmlFor="lv-filter-status" className="sr-only">Filter by status</label>
<Select
id="lv-filter-status"
ariaLabel="Filter by status"
value={statusFilter}
onChange={setStatusFilter}
options={[
{ value: 'all', label: 'All statuses' },
...LV_STATUSES.map(s => ({ value: s, label: s })),
]}
/>
</div>
{/* Source */}
<div className="md:col-span-2">
<label htmlFor="lv-filter-source" className="sr-only">Filter by source</label>
<Select
id="lv-filter-source"
ariaLabel="Filter by source"
value={sourceFilter}
onChange={setSourceFilter}
options={[
{ value: 'all', label: 'All sources' },
...sources.map(s => ({ value: s, label: s })),
]}
/>
</div>
{/* Assignee */}
<div className="md:col-span-2">
<label htmlFor="lv-filter-assignee" className="sr-only">Filter by assignee</label>
<Select
id="lv-filter-assignee"
ariaLabel="Filter by assignee"
value={assigneeFilter}
onChange={setAssigneeFilter}
options={[
{ value: 'all', label: 'All assignees' },
{ value: 'unassigned', label: 'Unassigned' },
...adminUsers.map(a => ({ value: a.id, label: a.name })),
]}
/>
</div>
</div>
{(search || statusFilter !== 'all' || sourceFilter !== 'all' || assigneeFilter !== 'all') && (
<div className="mt-3 flex items-center justify-between text-xs text-zinc-500 dark:text-zinc-400">
<span className="flex items-center gap-1.5">
<Filter size={12} />
{filtered.length} match{filtered.length === 1 ? '' : 'es'}
</span>
<button
type="button"
onClick={clearFilters}
className="font-bold uppercase tracking-wider text-blue-600 dark:text-blue-400 hover:underline"
>
Clear filters
</button>
</div>
)}
</SpotlightCard>
{/* Table / cards */}
<SpotlightCard className="overflow-hidden">
{/* Desktop */}
<div className="hidden lg:block overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead className="bg-zinc-50 dark:bg-[#18181b] border-b border-zinc-200 dark:border-white/10">
<tr>
<Th>Lead ID</Th>
<Th>Customer</Th>
<Th>Phone</Th>
<Th>Source</Th>
<Th>Assigned To</Th>
<Th>Status</Th>
<Th>Verification</Th>
<Th>Created</Th>
<Th align="right">Actions</Th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
{paginated.length === 0 ? (
<tr>
<td colSpan={9}>
<EmptyState />
</td>
</tr>
) : paginated.map(lead => (
<LeadTableRow
key={lead.id}
lead={lead}
canManage={canManage}
openMenu={openMenuId === lead.id}
setOpenMenu={(open) => setOpenMenuId(open ? lead.id : null)}
onView={() => setViewLead(lead)}
onAssign={() => setAssignLead(lead)}
onReassign={() => setReassignLead(lead)}
onVerify={() => setConfirmVerify(lead)}
onUnverify={() => setConfirmUnverify(lead)}
onMarkPending={() => setLeadVerificationStatus(lead.id, 'Pending')}
/>
))}
</tbody>
</table>
</div>
{/* Mobile / tablet cards */}
<div className="lg:hidden divide-y divide-zinc-100 dark:divide-white/5">
{paginated.length === 0 ? (
<EmptyState />
) : paginated.map(lead => (
<LeadCard
key={lead.id}
lead={lead}
canManage={canManage}
onView={() => setViewLead(lead)}
onAssign={() => setAssignLead(lead)}
onReassign={() => setReassignLead(lead)}
onVerify={() => setConfirmVerify(lead)}
onUnverify={() => setConfirmUnverify(lead)}
onMarkPending={() => setLeadVerificationStatus(lead.id, 'Pending')}
/>
))}
</div>
{/* Pagination */}
<div className="px-5 py-3 border-t border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5 flex flex-col sm:flex-row justify-between items-center gap-3 text-xs">
<span className="text-zinc-500">
Showing {filtered.length === 0 ? 0 : pageStart + 1}{Math.min(pageStart + PAGE_SIZE, filtered.length)} of {filtered.length}
</span>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={safePage === 1}
aria-label="Previous page"
className="p-1.5 rounded-lg bg-white dark:bg-white/5 border border-zinc-200 dark:border-white/10 text-zinc-500 hover:bg-zinc-100 dark:hover:bg-white/10 disabled:opacity-40 disabled:pointer-events-none transition-colors"
>
<ChevronLeft size={14} />
</button>
<span className="px-3 py-1.5 rounded-lg bg-white dark:bg-white/5 border border-zinc-200 dark:border-white/10 font-mono font-bold text-zinc-900 dark:text-white">
{safePage} / {totalPages}
</span>
<button
type="button"
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={safePage === totalPages}
aria-label="Next page"
className="p-1.5 rounded-lg bg-white dark:bg-white/5 border border-zinc-200 dark:border-white/10 text-zinc-500 hover:bg-zinc-100 dark:hover:bg-white/10 disabled:opacity-40 disabled:pointer-events-none transition-colors"
>
<ChevronRight size={14} />
</button>
</div>
</div>
</SpotlightCard>
</div>
{/* Modals */}
<LeadDetailsModal
isOpen={Boolean(viewLead)}
lead={viewLead}
onClose={() => setViewLead(null)}
/>
<AssignLeadModal
isOpen={Boolean(assignLead)}
lead={assignLead}
mode="assign"
onClose={() => setAssignLead(null)}
/>
<AssignLeadModal
isOpen={Boolean(reassignLead)}
lead={reassignLead}
mode="reassign"
onClose={() => setReassignLead(null)}
/>
{confirmVerify && (
<ConfirmDialog
title="Verify this lead?"
body={
<>
<span className="font-bold text-zinc-900 dark:text-white">{confirmVerify.customerName}</span> will be marked as <span className="font-bold text-emerald-600">Verified</span> and automatically pushed into <span className="font-bold">New Leads</span>.
</>
}
confirmLabel="Verify & Convert"
confirmColor="bg-emerald-600 hover:bg-emerald-500 shadow-emerald-500/20"
onConfirm={handleVerify}
onClose={() => setConfirmVerify(null)}
/>
)}
{confirmUnverify && (
<ConfirmDialog
title="Mark as unverified?"
body={
<>
This will move <span className="font-bold text-zinc-900 dark:text-white">{confirmUnverify.customerName}</span> to <span className="font-bold text-red-600">Unverified</span>. They will remain in the list for follow-up.
</>
}
confirmLabel="Mark Unverified"
confirmColor="bg-red-600 hover:bg-red-500 shadow-red-500/20"
onConfirm={handleUnverify}
onClose={() => setConfirmUnverify(null)}
/>
)}
</div>
);
}