feat(leadverification): port verification page + components (table, cards, summary, assign/verify modals)
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, User, Repeat, UserPlus, Loader2 } from 'lucide-react';
|
||||
import { useMockStore } from '../../data/mockStore';
|
||||
import Select from '../ui/Select';
|
||||
|
||||
// One modal handles both "Assign" and "Reassign" — `mode` differentiates copy
|
||||
// and target action. `mode='reassign'` is the In Progress flow.
|
||||
// Assignee list is sourced from `users` (store key) filtered to role === 'ADMIN'.
|
||||
// Field names match our store shape: assigneeId / assigneeName (not assignedToId/assignedToName).
|
||||
const AssignLeadModal = ({ isOpen, onClose, lead, mode = 'assign' }) => {
|
||||
const { users, assignLeadVerification, reassignLeadVerification } = useMockStore();
|
||||
const [selectedId, setSelectedId] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const isReassign = mode === 'reassign';
|
||||
|
||||
// Build the assignee list from admin users, excluding the currently assigned one.
|
||||
const adminUsers = useMemo(
|
||||
() => (users || []).filter(u => u.role === 'ADMIN'),
|
||||
[users],
|
||||
);
|
||||
|
||||
const available = useMemo(
|
||||
() => adminUsers.filter(p => p.id !== lead?.assigneeId),
|
||||
[adminUsers, lead],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSelectedId('');
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.body.style.overflow = prevOverflow;
|
||||
window.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen || !lead) return null;
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!selectedId) return;
|
||||
const target = adminUsers.find(a => a.id === selectedId);
|
||||
if (!target) return;
|
||||
setSubmitting(true);
|
||||
await new Promise(r => setTimeout(r, 250));
|
||||
if (isReassign) reassignLeadVerification(lead.id, target.id, target.name);
|
||||
else assignLeadVerification(lead.id, target.id, target.name);
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const Icon = isReassign ? Repeat : UserPlus;
|
||||
const headerIconCls = isReassign
|
||||
? 'bg-purple-500/10 text-purple-500'
|
||||
: 'bg-blue-500/10 text-blue-500';
|
||||
|
||||
return createPortal(
|
||||
<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={submitting ? undefined : onClose}
|
||||
/>
|
||||
<div className="relative w-full max-w-md 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="px-5 py-4 border-b border-zinc-200 dark:border-white/10 flex items-center justify-between bg-zinc-50/50 dark:bg-white/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-xl ${headerIconCls}`}>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<h2 className="text-base font-bold text-zinc-900 dark:text-white uppercase tracking-wider">
|
||||
{isReassign ? 'Reassign Lead' : 'Assign Lead'}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
aria-label="Close"
|
||||
className="p-2 rounded-lg bg-zinc-100 dark:bg-white/10 text-zinc-500 hover:text-red-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{isReassign ? 'Reassign ' : 'Assign '}
|
||||
<span className="font-bold text-zinc-900 dark:text-white">{lead.customerName}</span>
|
||||
{' '}({lead.leadId})
|
||||
{lead.assigneeName && (
|
||||
<>
|
||||
{' currently with '}
|
||||
<span className="font-bold text-zinc-900 dark:text-white">{lead.assigneeName}</span>
|
||||
</>
|
||||
)}
|
||||
.
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="lv-assignee"
|
||||
className="text-xs font-bold uppercase tracking-wider text-zinc-600 dark:text-zinc-400 flex items-center gap-1.5"
|
||||
>
|
||||
<User size={12} /> Assignee
|
||||
</label>
|
||||
<Select
|
||||
id="lv-assignee"
|
||||
ariaLabel="Assignee"
|
||||
value={selectedId}
|
||||
onChange={setSelectedId}
|
||||
placeholder="Select an assignee…"
|
||||
options={available.map(a => ({ value: a.id, label: `${a.name} — ${a.role}` }))}
|
||||
/>
|
||||
{available.length === 0 && (
|
||||
<p className="text-xs text-zinc-500 mt-1">
|
||||
No other assignees available.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 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}
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 text-sm font-bold rounded-xl 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 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedId || submitting}
|
||||
className={`inline-flex items-center gap-2 px-5 py-2 text-sm font-bold rounded-xl text-white shadow-lg transition-all disabled:opacity-50 ${
|
||||
isReassign
|
||||
? 'bg-purple-600 hover:bg-purple-500 shadow-purple-500/20'
|
||||
: 'bg-blue-600 hover:bg-blue-500 shadow-blue-500/20'
|
||||
}`}
|
||||
>
|
||||
{submitting && <Loader2 size={14} className="animate-spin" />}
|
||||
{isReassign ? 'Reassign' : 'Assign'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
export default AssignLeadModal;
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import { ShieldCheck, ShieldX, UserPlus, Repeat, Clock, Eye } from 'lucide-react';
|
||||
|
||||
const MenuItem = ({ icon: Icon, children, onClick, disabled, variant }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 text-xs font-semibold transition-colors disabled:opacity-40 disabled:cursor-not-allowed text-left ${
|
||||
variant === 'success'
|
||||
? 'text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/10'
|
||||
: variant === 'danger'
|
||||
? 'text-red-600 dark:text-red-400 hover:bg-red-500/10'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<Icon size={13} className="shrink-0" />
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
// Reused both by table row's dropdown and the mobile card's stack of buttons.
|
||||
// `canManage` mirrors the existing PermissionGate pattern (leads:verify).
|
||||
// Field names match our store shape: assigneeId (not assignedToId).
|
||||
export const LeadActionsMenu = ({
|
||||
lead,
|
||||
canManage,
|
||||
onView,
|
||||
onAssign,
|
||||
onReassign,
|
||||
onVerify,
|
||||
onUnverify,
|
||||
onMarkPending,
|
||||
}) => {
|
||||
const status = lead.status;
|
||||
const isVerified = status === 'Verified';
|
||||
const isUnverified = status === 'Unverified';
|
||||
const isInProgress = status === 'In Progress';
|
||||
const hasAssignee = Boolean(lead.assigneeId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuItem icon={Eye} onClick={onView}>View Details</MenuItem>
|
||||
<MenuItem icon={ShieldCheck} onClick={onVerify} disabled={!canManage || isVerified} variant="success">
|
||||
Verify Lead
|
||||
</MenuItem>
|
||||
<MenuItem icon={ShieldX} onClick={onUnverify} disabled={!canManage || isUnverified} variant="danger">
|
||||
Mark Unverified
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={UserPlus}
|
||||
onClick={onAssign}
|
||||
disabled={!canManage || isVerified}
|
||||
>
|
||||
{hasAssignee ? 'Change Assignee' : 'Assign'}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={Repeat}
|
||||
onClick={onReassign}
|
||||
disabled={!canManage || !isInProgress}
|
||||
title={!isInProgress ? 'Reassign is only available while In Progress' : undefined}
|
||||
>
|
||||
Reassign (In Progress)
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={Clock}
|
||||
onClick={onMarkPending}
|
||||
disabled={!canManage || status === 'Pending' || isVerified}
|
||||
>
|
||||
Move to Pending
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LeadActionsMenu;
|
||||
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Eye, ShieldCheck, ShieldX, UserPlus, Repeat, Clock, Phone, MapPin, User,
|
||||
} from 'lucide-react';
|
||||
import { LeadStatusBadge, VerificationStatusPill } from './LeadStatusBadge';
|
||||
|
||||
const formatDate = (iso) => {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString('en-US', {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
});
|
||||
} catch { return iso; }
|
||||
};
|
||||
|
||||
const CardActionButton = ({ icon: Icon, children, onClick, disabled, variant }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wider border transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
|
||||
variant === 'success'
|
||||
? 'text-emerald-600 dark:text-emerald-400 border-emerald-500/20 hover:bg-emerald-500/10'
|
||||
: variant === 'danger'
|
||||
? 'text-red-600 dark:text-red-400 border-red-500/20 hover:bg-red-500/10'
|
||||
: 'text-zinc-700 dark:text-zinc-300 border-zinc-200 dark:border-white/10 hover:bg-zinc-100 dark:hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<Icon size={12} />
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
// Field names match our store shape: assigneeId / assigneeName (not assignedToId/assignedToName).
|
||||
const LeadCard = ({
|
||||
lead,
|
||||
canManage,
|
||||
onView,
|
||||
onAssign,
|
||||
onReassign,
|
||||
onVerify,
|
||||
onUnverify,
|
||||
onMarkPending,
|
||||
}) => {
|
||||
const isVerified = lead.status === 'Verified';
|
||||
const isUnverified = lead.status === 'Unverified';
|
||||
const isInProgress = lead.status === 'In Progress';
|
||||
const hasAssignee = Boolean(lead.assigneeId);
|
||||
|
||||
return (
|
||||
<div className="p-4 active:bg-zinc-50 dark:active:bg-white/5 transition-colors">
|
||||
<button type="button" onClick={onView} className="w-full text-left">
|
||||
<div className="flex justify-between items-start gap-3 mb-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="font-semibold text-zinc-900 dark:text-white truncate">
|
||||
{lead.customerName}
|
||||
</h4>
|
||||
<p className="text-[11px] text-zinc-500 font-mono mt-0.5">{lead.leadId}</p>
|
||||
</div>
|
||||
<LeadStatusBadge status={lead.status} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-zinc-500 mb-2">
|
||||
<span className="flex items-center gap-1">
|
||||
<Phone size={11} />
|
||||
{lead.phone}
|
||||
</span>
|
||||
{lead.address && (
|
||||
<span className="flex items-center gap-1 max-w-full">
|
||||
<MapPin size={11} />
|
||||
<span className="truncate max-w-[180px]">{lead.address}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-1">
|
||||
<VerificationStatusPill label={lead.verificationStatus} />
|
||||
<span className="flex items-center gap-1 text-[11px] text-zinc-500">
|
||||
<User size={11} />
|
||||
{lead.assigneeName || 'Unassigned'}
|
||||
</span>
|
||||
<span className="text-[11px] text-zinc-400 font-mono">
|
||||
{formatDate(lead.createdAt)} · {lead.source}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<div className="mt-3 flex flex-wrap gap-2 pt-3 border-t border-zinc-100 dark:border-white/5">
|
||||
<CardActionButton icon={Eye} onClick={onView}>View</CardActionButton>
|
||||
<CardActionButton icon={ShieldCheck} onClick={onVerify} disabled={!canManage || isVerified} variant="success">Verify</CardActionButton>
|
||||
<CardActionButton icon={ShieldX} onClick={onUnverify} disabled={!canManage || isUnverified} variant="danger">Unverify</CardActionButton>
|
||||
<CardActionButton icon={UserPlus} onClick={onAssign} disabled={!canManage || isVerified}>
|
||||
{hasAssignee ? 'Reassign' : 'Assign'}
|
||||
</CardActionButton>
|
||||
<CardActionButton icon={Repeat} onClick={onReassign} disabled={!canManage || !isInProgress}>
|
||||
Reassign (IP)
|
||||
</CardActionButton>
|
||||
<CardActionButton icon={Clock} onClick={onMarkPending} disabled={!canManage || lead.status === 'Pending' || isVerified}>
|
||||
Pending
|
||||
</CardActionButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LeadCard;
|
||||
@@ -0,0 +1,151 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
X, Phone, Mail, MapPin, User, FileText, Clock, Hash, History as HistoryIcon,
|
||||
} from 'lucide-react';
|
||||
import { LeadStatusBadge, VerificationStatusPill } from './LeadStatusBadge';
|
||||
|
||||
const formatDateTime = (iso) => {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
return new Date(iso).toLocaleString('en-US', {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: 'numeric', minute: '2-digit',
|
||||
});
|
||||
} catch { return iso; }
|
||||
};
|
||||
|
||||
const Row = ({ icon: Icon, label, value }) => (
|
||||
<div className="flex items-start gap-3 py-2 border-b border-zinc-100 dark:border-white/5 last:border-0">
|
||||
<div className="shrink-0 p-1.5 rounded-lg bg-zinc-100 dark:bg-white/5 text-zinc-500">
|
||||
<Icon size={14} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[10px] uppercase tracking-wider text-zinc-400 dark:text-zinc-500 font-bold">
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-900 dark:text-white font-medium break-words">
|
||||
{value || '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Field names match our store shape: assigneeName (not assignedToName).
|
||||
const LeadDetailsModal = ({ isOpen, onClose, lead }) => {
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.body.style.overflow = prevOverflow;
|
||||
window.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen || !lead) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-end sm:items-center justify-center p-0 sm: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 sm:max-w-2xl max-h-[92vh] sm:max-h-[88vh] flex flex-col bg-white dark:bg-[#121214] sm:rounded-2xl rounded-t-2xl shadow-2xl border border-zinc-200 dark:border-white/10 overflow-hidden animate-in zoom-in-95 duration-200">
|
||||
{/* Header */}
|
||||
<div className="px-5 py-4 border-b border-zinc-200 dark:border-white/10 flex items-start justify-between gap-3 bg-zinc-50/50 dark:bg-white/5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h2 className="text-base font-bold text-zinc-900 dark:text-white truncate">
|
||||
{lead.customerName}
|
||||
</h2>
|
||||
<LeadStatusBadge status={lead.status} />
|
||||
<VerificationStatusPill label={lead.verificationStatus} />
|
||||
</div>
|
||||
<p className="mt-0.5 text-[11px] font-mono text-zinc-500">{lead.leadId}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="p-2 rounded-lg bg-zinc-100 dark:bg-white/10 text-zinc-500 hover:text-red-500 transition-colors shrink-0"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="overflow-y-auto custom-scrollbar p-5 space-y-6">
|
||||
{/* Contact Info */}
|
||||
<section>
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-zinc-500 mb-2 flex items-center gap-1.5">
|
||||
<User size={12} /> Contact
|
||||
</h3>
|
||||
<div className="rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-white/5 px-3">
|
||||
<Row icon={Phone} label="Phone" value={lead.phone} />
|
||||
<Row icon={Mail} label="Email" value={lead.email} />
|
||||
<Row icon={MapPin} label="Address" value={lead.address} />
|
||||
<Row icon={Hash} label="Source" value={lead.source} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Assignment */}
|
||||
<section>
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-zinc-500 mb-2 flex items-center gap-1.5">
|
||||
<User size={12} /> Assignment
|
||||
</h3>
|
||||
<div className="rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-white/5 px-3">
|
||||
<Row icon={User} label="Assigned To" value={lead.assigneeName} />
|
||||
<Row icon={Clock} label="Created" value={formatDateTime(lead.createdAt)} />
|
||||
<Row icon={Clock} label="Verified At" value={formatDateTime(lead.verifiedAt)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Notes */}
|
||||
<section>
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-zinc-500 mb-2 flex items-center gap-1.5">
|
||||
<FileText size={12} /> Verification Notes
|
||||
</h3>
|
||||
<div className="rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-white/5 p-3 text-sm text-zinc-700 dark:text-zinc-300 leading-relaxed whitespace-pre-wrap">
|
||||
{lead.verificationNotes || 'No notes yet.'}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* History */}
|
||||
{lead.history?.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-zinc-500 mb-2 flex items-center gap-1.5">
|
||||
<HistoryIcon size={12} /> Activity
|
||||
</h3>
|
||||
<ol className="relative border-l-2 border-zinc-100 dark:border-white/10 ml-1.5 space-y-3 pl-4">
|
||||
{lead.history.map((h, i) => (
|
||||
<li key={i} className="relative">
|
||||
<span className="absolute -left-[22px] top-1.5 w-3 h-3 rounded-full bg-blue-500 ring-4 ring-white dark:ring-[#121214]" />
|
||||
<p className="text-sm text-zinc-900 dark:text-white font-medium">
|
||||
{h.action}
|
||||
</p>
|
||||
<p className="text-[11px] text-zinc-500 mt-0.5">
|
||||
{formatDateTime(h.at)} · {h.by}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-bold rounded-xl 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"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
export default LeadDetailsModal;
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { LV_STATUS_CONFIG, lvVerificationStatusCls } from './statusConfig';
|
||||
|
||||
export const LeadStatusBadge = ({ status }) => {
|
||||
const cfg = LV_STATUS_CONFIG[status] || LV_STATUS_CONFIG.Pending;
|
||||
const Icon = cfg.icon;
|
||||
const spin = status === 'In Progress';
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wide ${cfg.cls}`}>
|
||||
<Icon size={11} className={spin ? 'animate-spin' : ''} />
|
||||
{cfg.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const VerificationStatusPill = ({ label }) => {
|
||||
if (!label) return null;
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-md text-[10px] font-semibold uppercase tracking-wide border ${lvVerificationStatusCls(label)}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import { MoreVertical, Phone, Eye } from 'lucide-react';
|
||||
import { LeadStatusBadge, VerificationStatusPill } from './LeadStatusBadge';
|
||||
import LeadActionsMenu from './LeadActionsMenu';
|
||||
|
||||
const formatDate = (iso) => {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString('en-US', {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
});
|
||||
} catch { return iso; }
|
||||
};
|
||||
|
||||
// Field names match our store shape: assigneeId / assigneeName (not assignedToId/assignedToName).
|
||||
const LeadTableRow = ({
|
||||
lead,
|
||||
openMenu,
|
||||
setOpenMenu,
|
||||
canManage,
|
||||
onView,
|
||||
onAssign,
|
||||
onReassign,
|
||||
onVerify,
|
||||
onUnverify,
|
||||
onMarkPending,
|
||||
}) => {
|
||||
return (
|
||||
<tr className="hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors group">
|
||||
<td className="px-5 py-4 whitespace-nowrap">
|
||||
<button type="button" onClick={onView} className="text-left">
|
||||
<div className="font-mono text-[11px] text-zinc-500">{lead.leadId}</div>
|
||||
<div className="text-[10px] text-zinc-400 mt-0.5">
|
||||
{formatDate(lead.createdAt)}
|
||||
</div>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<button type="button" onClick={onView} className="text-left flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-500/10 text-blue-500 flex items-center justify-center text-xs font-bold shrink-0">
|
||||
{lead.customerName?.charAt(0)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-sm text-zinc-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors truncate max-w-[180px]">
|
||||
{lead.customerName}
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-500 truncate max-w-[180px]">
|
||||
{lead.address}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-5 py-4 whitespace-nowrap">
|
||||
<a
|
||||
href={`tel:${lead.phone}`}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-zinc-700 dark:text-zinc-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<Phone size={12} className="text-zinc-400" />
|
||||
{lead.phone}
|
||||
</a>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className="text-sm text-zinc-700 dark:text-zinc-300">{lead.source}</span>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
{lead.assigneeName ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-full bg-purple-500/10 text-purple-500 flex items-center justify-center text-[10px] font-bold shrink-0">
|
||||
{lead.assigneeName?.charAt(0)}
|
||||
</div>
|
||||
<span className="text-sm text-zinc-700 dark:text-zinc-300 truncate max-w-[140px]">
|
||||
{lead.assigneeName}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs italic text-zinc-400">Unassigned</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<LeadStatusBadge status={lead.status} />
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<VerificationStatusPill label={lead.verificationStatus} />
|
||||
</td>
|
||||
<td className="px-5 py-4 whitespace-nowrap text-sm text-zinc-500">
|
||||
{formatDate(lead.createdAt)}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right relative">
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onView}
|
||||
className="p-1.5 rounded-lg text-zinc-500 hover:text-blue-600 hover:bg-blue-500/10 transition-colors"
|
||||
aria-label="View details"
|
||||
title="View"
|
||||
>
|
||||
<Eye size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setOpenMenu(!openMenu); }}
|
||||
className="p-1.5 rounded-lg text-zinc-500 hover:text-zinc-900 dark:hover:text-white hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors"
|
||||
aria-label="More actions"
|
||||
aria-expanded={openMenu}
|
||||
>
|
||||
<MoreVertical size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{openMenu && (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute right-5 top-full mt-1 w-52 bg-white dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-xl shadow-xl z-20 overflow-hidden text-left"
|
||||
>
|
||||
<LeadActionsMenu
|
||||
lead={lead}
|
||||
canManage={canManage}
|
||||
onView={() => { setOpenMenu(false); onView(); }}
|
||||
onAssign={() => { setOpenMenu(false); onAssign(); }}
|
||||
onReassign={() => { setOpenMenu(false); onReassign(); }}
|
||||
onVerify={() => { setOpenMenu(false); onVerify(); }}
|
||||
onUnverify={() => { setOpenMenu(false); onUnverify(); }}
|
||||
onMarkPending={() => { setOpenMenu(false); onMarkPending(); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
export default LeadTableRow;
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { SpotlightCard } from '../SpotlightCard';
|
||||
import { ShieldCheck, ShieldX, UserCheck, Loader2, Clock } from 'lucide-react';
|
||||
|
||||
const TILE_DEF = [
|
||||
{ key: 'verified', label: 'Verified', icon: ShieldCheck, accent: 'text-emerald-500', bg: 'bg-emerald-500/10' },
|
||||
{ key: 'inProgress', label: 'In Progress', icon: Loader2, accent: 'text-blue-500', bg: 'bg-blue-500/10' },
|
||||
{ key: 'assigned', label: 'Assigned', icon: UserCheck, accent: 'text-amber-500', bg: 'bg-amber-500/10' },
|
||||
{ key: 'pending', label: 'Pending', icon: Clock, accent: 'text-zinc-500', bg: 'bg-zinc-400/15' },
|
||||
{ key: 'unverified', label: 'Unverified', icon: ShieldX, accent: 'text-red-500', bg: 'bg-red-500/10' },
|
||||
];
|
||||
|
||||
export default function SummaryCards({ stats }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 sm:gap-4">
|
||||
{TILE_DEF.map(({ key, label, icon: Icon, accent, bg }) => (
|
||||
<SpotlightCard key={key} className="p-4 sm:p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-zinc-500 dark:text-zinc-400 truncate">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl sm:text-3xl font-extrabold text-zinc-900 dark:text-white tabular-nums">
|
||||
{stats[key] ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`shrink-0 p-2.5 rounded-xl ${bg} ${accent}`}>
|
||||
<Icon size={18} className={key === 'inProgress' ? 'animate-spin' : ''} />
|
||||
</div>
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Clock, UserCheck, Loader2, ShieldCheck, ShieldX } from 'lucide-react';
|
||||
|
||||
// Centralised lead-verification status visuals + icons. Reused by table, cards,
|
||||
// summary tiles, and modals so the badge always looks identical.
|
||||
export const LV_STATUS_CONFIG = {
|
||||
Pending: {
|
||||
label: 'Pending',
|
||||
icon: Clock,
|
||||
cls: 'bg-zinc-200 text-zinc-700 dark:bg-zinc-700 dark:text-zinc-300',
|
||||
dot: 'bg-zinc-400',
|
||||
accent: 'text-zinc-500',
|
||||
},
|
||||
Assigned: {
|
||||
label: 'Assigned',
|
||||
icon: UserCheck,
|
||||
cls: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400',
|
||||
dot: 'bg-amber-500',
|
||||
accent: 'text-amber-500',
|
||||
},
|
||||
'In Progress': {
|
||||
label: 'In Progress',
|
||||
icon: Loader2,
|
||||
cls: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400',
|
||||
dot: 'bg-blue-500',
|
||||
accent: 'text-blue-500',
|
||||
},
|
||||
Verified: {
|
||||
label: 'Verified',
|
||||
icon: ShieldCheck,
|
||||
cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400',
|
||||
dot: 'bg-emerald-500',
|
||||
accent: 'text-emerald-500',
|
||||
},
|
||||
Unverified: {
|
||||
label: 'Unverified',
|
||||
icon: ShieldX,
|
||||
cls: 'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-400',
|
||||
dot: 'bg-red-500',
|
||||
accent: 'text-red-500',
|
||||
},
|
||||
};
|
||||
|
||||
export const LV_VERIFICATION_STATUS_CLS = {
|
||||
Verified: 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:border-emerald-500/20',
|
||||
'Awaiting Confirmation': 'bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-500/10 dark:text-blue-300 dark:border-blue-500/20',
|
||||
'Verification In Progress': 'bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-500/10 dark:text-blue-300 dark:border-blue-500/20',
|
||||
'Verifying Identity': 'bg-purple-50 text-purple-700 border-purple-200 dark:bg-purple-500/10 dark:text-purple-300 dark:border-purple-500/20',
|
||||
'Reviewing Insurance': 'bg-purple-50 text-purple-700 border-purple-200 dark:bg-purple-500/10 dark:text-purple-300 dark:border-purple-500/20',
|
||||
'Pending Outreach': 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:border-amber-500/20',
|
||||
Unassigned: 'bg-zinc-50 text-zinc-600 border-zinc-200 dark:bg-white/5 dark:text-zinc-300 dark:border-white/10',
|
||||
'Failed Contact': 'bg-red-50 text-red-700 border-red-200 dark:bg-red-500/10 dark:text-red-300 dark:border-red-500/20',
|
||||
'Emergency — Awaiting Verifier':'bg-red-50 text-red-700 border-red-200 dark:bg-red-500/10 dark:text-red-300 dark:border-red-500/20',
|
||||
};
|
||||
|
||||
export const lvVerificationStatusCls = (label) =>
|
||||
LV_VERIFICATION_STATUS_CLS[label] ||
|
||||
'bg-zinc-50 text-zinc-600 border-zinc-200 dark:bg-white/5 dark:text-zinc-300 dark:border-white/10';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user