door knock and referral attribution
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Check, ChevronDown, Search } from 'lucide-react';
|
||||
import { useMockStore } from '../../data/mockStore';
|
||||
|
||||
function FieldLabel({ children }) {
|
||||
return (
|
||||
<label className="block text-[11px] uppercase tracking-widest font-bold text-zinc-400 dark:text-zinc-500 mb-1.5">
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CanvasserSelector({ value, onChange, onNameChange }) {
|
||||
const { orgMembers } = useMockStore();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [pos, setPos] = useState(null);
|
||||
const inputRef = useRef(null);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
const canvassers = useMemo(() =>
|
||||
orgMembers.filter(m => m.roleKey === 'CANVASSER' && m.status === 'active'),
|
||||
[orgMembers]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!query.trim()) return canvassers;
|
||||
const q = query.toLowerCase();
|
||||
return canvassers.filter(m =>
|
||||
m.name.toLowerCase().includes(q) || m.email.toLowerCase().includes(q)
|
||||
);
|
||||
}, [query, canvassers]);
|
||||
|
||||
const selectedMember = useMemo(() =>
|
||||
canvassers.find(m => m.userId === value),
|
||||
[canvassers, value]
|
||||
);
|
||||
|
||||
const computePos = () => {
|
||||
if (inputRef.current) {
|
||||
const r = inputRef.current.getBoundingClientRect();
|
||||
setPos({
|
||||
top: r.bottom + window.scrollY + 4,
|
||||
left: r.left + window.scrollX,
|
||||
width: Math.max(r.width, 280),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
computePos();
|
||||
setQuery('');
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
setQuery(e.target.value);
|
||||
if (!open) {
|
||||
computePos();
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (member) => {
|
||||
onChange(member.userId);
|
||||
if (onNameChange) onNameChange(member.name);
|
||||
setQuery('');
|
||||
setOpen(false);
|
||||
inputRef.current?.blur();
|
||||
};
|
||||
|
||||
const handleBlur = (e) => {
|
||||
if (dropdownRef.current?.contains(e.relatedTarget)) return;
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Escape') { setOpen(false); setQuery(''); inputRef.current?.blur(); }
|
||||
if (e.key === 'Enter' && filtered.length > 0) { handleSelect(filtered[0]); }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e) => {
|
||||
const inInput = inputRef.current?.contains(e.target);
|
||||
const inDropdown = dropdownRef.current?.contains(e.target);
|
||||
if (!inInput && !inDropdown) { setOpen(false); setQuery(''); }
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e) => {
|
||||
const path = e.composedPath?.() ?? [];
|
||||
if (dropdownRef.current && path.includes(dropdownRef.current)) return;
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
};
|
||||
window.addEventListener('scroll', close, true);
|
||||
window.addEventListener('resize', close);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', close, true);
|
||||
window.removeEventListener('resize', close);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const displayValue = open ? query : (selectedMember?.name || '');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FieldLabel>Canvasser *</FieldLabel>
|
||||
|
||||
<div className={`flex items-center rounded-xl border transition-all duration-150
|
||||
bg-white dark:bg-zinc-800/60
|
||||
${open
|
||||
? 'border-blue-400 dark:border-blue-500 ring-2 ring-blue-100 dark:ring-blue-500/20'
|
||||
: 'border-zinc-200 dark:border-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<Search size={13} className="ml-3.5 shrink-0 text-zinc-400 pointer-events-none" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={displayValue}
|
||||
placeholder="Search canvasser by name or email…"
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="flex-1 min-w-0 py-3 pl-2.5 pr-1 text-sm font-medium outline-none bg-transparent
|
||||
text-zinc-800 dark:text-white
|
||||
placeholder-zinc-400 dark:placeholder-zinc-500"
|
||||
/>
|
||||
<motion.span
|
||||
animate={{ rotate: open ? 180 : 0 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 28 }}
|
||||
className="pr-2.5 shrink-0 pointer-events-none"
|
||||
>
|
||||
<ChevronDown size={13} className="text-zinc-400" />
|
||||
</motion.span>
|
||||
</div>
|
||||
|
||||
{createPortal(
|
||||
<AnimatePresence>
|
||||
{open && pos && (
|
||||
<motion.div
|
||||
ref={dropdownRef}
|
||||
key="canvasser-dropdown"
|
||||
initial={{ opacity: 0, y: -6, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -6, scale: 0.98 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: pos.top,
|
||||
left: pos.left,
|
||||
width: pos.width,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
className="rounded-xl overflow-hidden
|
||||
bg-white dark:bg-zinc-900
|
||||
border border-zinc-200 dark:border-zinc-700
|
||||
shadow-xl shadow-black/10 dark:shadow-black/50"
|
||||
>
|
||||
<div className="max-h-52 overflow-y-auto">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-4 py-3 text-xs text-zinc-400 dark:text-zinc-500">
|
||||
No canvassers match "{query}"
|
||||
</p>
|
||||
) : (
|
||||
filtered.map(member => {
|
||||
const isSelected = member.userId === value;
|
||||
return (
|
||||
<button
|
||||
key={member.userId}
|
||||
type="button"
|
||||
onMouseDown={(e) => { e.preventDefault(); handleSelect(member); }}
|
||||
className={`w-full flex items-center justify-between px-4 py-2.5 text-sm text-left transition-colors duration-100
|
||||
${isSelected
|
||||
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-500/10'
|
||||
: 'text-zinc-700 dark:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-3 min-w-0">
|
||||
<span className="w-7 h-7 rounded-lg flex items-center justify-center text-[10px] font-black uppercase text-white shrink-0"
|
||||
style={{ backgroundColor: '#10b981' }}
|
||||
>
|
||||
{member.name.split(' ').map(n => n[0]).join('')}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-semibold truncate">{member.name}</span>
|
||||
<span className="block text-[11px] text-zinc-400 dark:text-zinc-500 truncate">{member.email}</span>
|
||||
</span>
|
||||
</span>
|
||||
{isSelected && <Check size={12} strokeWidth={2.5} className="shrink-0 ml-2 opacity-70" />}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { LEAD_FORM_OPTIONS } from '../../data/mockStore';
|
||||
import UrgencyPillSelector from './UrgencyPillSelector';
|
||||
import LeadCustomSelect from './LeadCustomSelect';
|
||||
import CanvasserSelector from './CanvasserSelector';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared field primitives (local to Job section)
|
||||
@@ -61,6 +62,52 @@ export default function LeadJobSection({ formData, updateField, isQuickCapture }
|
||||
accent="blue"
|
||||
/>
|
||||
|
||||
{/* --- Door Knock → Canvasser selector --- */}
|
||||
<AnimatePresence initial={false}>
|
||||
{formData.leadSource === 'Door Knock' && (
|
||||
<motion.div
|
||||
key="canvasser"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={collapseTransition}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="pt-4">
|
||||
<CanvasserSelector
|
||||
value={formData.canvasserId}
|
||||
onChange={v => updateField('canvasserId', v)}
|
||||
onNameChange={v => updateField('canvasserName', v)}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* --- Referral → Referral Note --- */}
|
||||
<AnimatePresence initial={false}>
|
||||
{formData.leadSource === 'Referral' && (
|
||||
<motion.div
|
||||
key="referralNote"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={collapseTransition}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="pt-4">
|
||||
<LeadTextarea
|
||||
label="Referral Note"
|
||||
value={formData.referralNote}
|
||||
onChange={v => updateField('referralNote', v)}
|
||||
placeholder="e.g. Referred by John Smith, existing customer on Oak Street…"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* --- Lead Type — Full Form only --- */}
|
||||
<AnimatePresence initial={false}>
|
||||
{!isQuickCapture && (
|
||||
|
||||
@@ -69,6 +69,9 @@ const SECTIONS = [
|
||||
const INITIAL_FORM = {
|
||||
// Job Details
|
||||
leadSource: '',
|
||||
canvasserId: '',
|
||||
canvasserName: '',
|
||||
referralNote: '',
|
||||
leadType: '',
|
||||
workType: '',
|
||||
tradeType: '',
|
||||
@@ -156,7 +159,14 @@ export default function CreateLeadPage() {
|
||||
}, [progress]);
|
||||
|
||||
const updateField = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
setFormData(prev => {
|
||||
const next = { ...prev, [field]: value };
|
||||
if (field === 'leadSource') {
|
||||
if (value !== 'Door Knock') { next.canvasserId = ''; next.canvasserName = ''; }
|
||||
if (value !== 'Referral') { next.referralNote = ''; }
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSection = (id) => {
|
||||
@@ -173,6 +183,7 @@ export default function CreateLeadPage() {
|
||||
if (!formData.address) missingFields.push('Street Address');
|
||||
if (!formData.city) missingFields.push('City');
|
||||
if (!formData.leadSource) missingFields.push('Lead Source');
|
||||
if (formData.leadSource === 'Door Knock' && !formData.canvasserId) missingFields.push('Canvasser');
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
setValidationError(`Required: ${missingFields.join(', ')}`);
|
||||
|
||||
@@ -8,6 +8,7 @@ import PermissionGate from '../components/PermissionGate';
|
||||
import {
|
||||
Plus, Search, MapPin, Phone, Zap, FileText,
|
||||
Clock, User, ChevronRight, Filter, Lock,
|
||||
DoorOpen, MessageSquare,
|
||||
} from 'lucide-react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -117,6 +118,18 @@ function LeadCard({ lead, index }) {
|
||||
{lead.leadSource}
|
||||
</span>
|
||||
)}
|
||||
{lead.canvasserName && (
|
||||
<span className="flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
|
||||
<DoorOpen size={10} className="shrink-0" />
|
||||
{lead.canvasserName}
|
||||
</span>
|
||||
)}
|
||||
{lead.referralNote && (
|
||||
<span className="flex items-center gap-1 text-xs text-purple-600 dark:text-purple-400 max-w-[200px] truncate">
|
||||
<MessageSquare size={10} className="shrink-0" />
|
||||
{lead.referralNote}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user