feat(leads): source-driven fields + full-form wizard

- Add Lead source dropdown options (Door Knock, Referral, Storm Chase,
  Mailer/Postcard, Sign Call, Insurance Agent Referral, Repeat Customer,
  Social Media, Other)
- Quick form: Referral shows a referral-note textarea; Door Knock shows a
  canvasser search (by name or email) with picker + chip
- Full form is now a Next/Back wizard ending in Create Lead
- Reps gain email for canvasser search

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-22 11:08:47 +05:30
parent 102b66dfca
commit faade49b13
3 changed files with 107 additions and 21 deletions
+14
View File
@@ -1265,6 +1265,20 @@
.dash-root .nl-photo-chip { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 600; padding: 5px 10px; border-radius: 99px; background: color-mix(in srgb, var(--green) 15%, transparent); color: var(--green); } .dash-root .nl-photo-chip { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 600; padding: 5px 10px; border-radius: 99px; background: color-mix(in srgb, var(--green) 15%, transparent); color: var(--green); }
.dash-root .ds-select.is-placeholder { color: var(--faint, var(--muted)); } .dash-root .ds-select.is-placeholder { color: var(--faint, var(--muted)); }
/* Canvasser search (Door Knock source) */
.dash-root .nl-canvasser { position: relative; }
.dash-root .nl-canvasser-menu { position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 30; max-height: 220px; overflow-y: auto; border: 1px solid var(--border-2); border-radius: 12px; background: var(--panel); box-shadow: 0 12px 32px rgba(0,0,0,0.28); padding: 5px; }
.dash-root .nl-canvasser-opt { display: flex; align-items: center; gap: 9px; width: 100%; padding: 7px 9px; border: none; border-radius: 9px; background: transparent; color: var(--text); font-family: inherit; font-size: 13px; text-align: left; cursor: pointer; transition: 0.12s; }
.dash-root .nl-canvasser-opt:hover { background: var(--panel-2); }
.dash-root .nl-canvasser-name { font-weight: 700; }
.dash-root .nl-canvasser-email { color: var(--muted); font-size: 12px; }
.dash-root .nl-canvasser-opt .nl-canvasser-email { margin-left: auto; }
.dash-root .nl-canvasser-empty { padding: 10px; color: var(--muted); font-size: 12.5px; text-align: center; }
.dash-root .nl-canvasser-chip { display: flex; align-items: center; gap: 9px; padding: 7px 10px; border: 1px solid var(--border-2); border-radius: 12px; background: var(--panel-2); }
.dash-root .nl-canvasser-chip .nl-canvasser-email { margin-left: 2px; }
.dash-root .nl-canvasser-clear { margin-left: auto; display: grid; place-items: center; width: 26px; height: 26px; border: none; border-radius: 8px; background: transparent; color: var(--muted); cursor: pointer; transition: 0.12s; }
.dash-root .nl-canvasser-clear:hover { background: color-mix(in srgb, var(--red) 15%, transparent); color: var(--red); }
/* ========================================================== */ /* ========================================================== */
/* Lead Verification — stat tiles + filters + table */ /* Lead Verification — stat tiles + filters + table */
/* ========================================================== */ /* ========================================================== */
+7 -7
View File
@@ -202,17 +202,17 @@ export const PRIORITY_META: Record<LeadPriority, { label: string; tone: string }
/* Reps + option lists — power the New Lead form */ /* Reps + option lists — power the New Lead form */
/* ---------------------------------------------------------- */ /* ---------------------------------------------------------- */
export type Rep = { id: string; initials: string; name: string }; export type Rep = { id: string; initials: string; name: string; email: string };
export const REPS: Rep[] = [ export const REPS: Rep[] = [
{ id: "LUP-1040", initials: "CT", name: "Cody Tatum" }, { id: "LUP-1040", initials: "CT", name: "Cody Tatum", email: "cody.tatum@lynkeduppro.com" },
{ id: "LUP-1041", initials: "HR", name: "Hannah Reyes" }, { id: "LUP-1041", initials: "HR", name: "Hannah Reyes", email: "hannah.reyes@lynkeduppro.com" },
{ id: "LUP-1042", initials: "TB", name: "Travis Boone" }, { id: "LUP-1042", initials: "TB", name: "Travis Boone", email: "travis.boone@lynkeduppro.com" },
{ id: "LUP-1043", initials: "SG", name: "Shelby Greer" }, { id: "LUP-1043", initials: "SG", name: "Shelby Greer", email: "shelby.greer@lynkeduppro.com" },
{ id: "LUP-1044", initials: "DP", name: "Dalton Pruitt" }, { id: "LUP-1044", initials: "DP", name: "Dalton Pruitt", email: "dalton.pruitt@lynkeduppro.com" },
]; ];
export const LEAD_SOURCES = ["Door Knock", "Referral", "Web Form", "Call-In", "Storm Canvass"]; export const LEAD_SOURCES = ["Door Knock", "Referral", "Storm Chase", "Mailer / Postcard", "Sign Call", "Insurance Agent Referral", "Repeat Customer", "Social Media", "Other"];
export const LEAD_TYPES = ["Insurance", "Retail"]; export const LEAD_TYPES = ["Insurance", "Retail"];
export const WORK_TYPES = ["Roof Replacement", "Roof Repair", "Inspection", "Gutter Install"]; export const WORK_TYPES = ["Roof Replacement", "Roof Repair", "Inspection", "Gutter Install"];
export const TRADE_TYPES = ["Roofing", "Gutters", "Siding", "Windows"]; export const TRADE_TYPES = ["Roofing", "Gutters", "Siding", "Windows"];
+82 -10
View File
@@ -313,6 +313,14 @@ type Urgency = "Standard" | "High" | "Emergency";
type PhoneRow = { number: string; type: string }; type PhoneRow = { number: string; type: string };
type EmailRow = { address: string }; type EmailRow = { address: string };
const FULL_STEPS = [
{ value: "contact", label: "Contact", icon: "user" },
{ value: "property", label: "Property", icon: "owners" },
{ value: "job", label: "Job Details", icon: "projects" },
{ value: "insurance", label: "Insurance", icon: "shield" },
{ value: "assignment", label: "Assignment", icon: "team" },
];
const LEAD_TYPE_OPTS = ["Residential", "Commercial", "Multi-Family"]; const LEAD_TYPE_OPTS = ["Residential", "Commercial", "Multi-Family"];
const PROPERTY_TYPE_OPTS = ["Residential", "Commercial", "Multi-Family", "Industrial"]; const PROPERTY_TYPE_OPTS = ["Residential", "Commercial", "Multi-Family", "Industrial"];
@@ -322,7 +330,7 @@ const BLANK = {
emails: [] as EmailRow[], emails: [] as EmailRow[],
address: "", city: "", state: "TX", zip: "", propertyType: "", address: "", city: "", state: "TX", zip: "", propertyType: "",
photos: [] as string[], photos: [] as string[],
source: "", leadType: "", workType: "", tradeType: "", urgency: "Standard" as Urgency, notes: "", source: "", referralNote: "", canvasser: "", leadType: "", workType: "", tradeType: "", urgency: "Standard" as Urgency, notes: "",
insCompany: "", claimNumber: "", claimStatus: "", adjusterName: "", adjusterPhone: "", policyNumber: "", insCompany: "", claimNumber: "", claimStatus: "", adjusterName: "", adjusterPhone: "", policyNumber: "",
assignRep: "", priority: "Medium" as Priority, followUp: "", assignRep: "", priority: "Medium" as Priority, followUp: "",
}; };
@@ -357,6 +365,12 @@ function NewLead({ open, onClose }: { open: boolean; onClose: () => void }) {
const repOptions = [{ id: "", initials: "—", name: "Unassigned" }, ...REPS]; const repOptions = [{ id: "", initials: "—", name: "Unassigned" }, ...REPS];
const stepIdx = FULL_STEPS.findIndex((s) => s.value === section);
const isFirstStep = stepIdx <= 0;
const isLastStep = stepIdx === FULL_STEPS.length - 1;
const goNext = () => { if (!isLastStep) setSection(FULL_STEPS[stepIdx + 1].value); };
const goBack = () => { if (!isFirstStep) setSection(FULL_STEPS[stepIdx - 1].value); };
return ( return (
<Modal <Modal
open={open} open={open}
@@ -366,10 +380,20 @@ function NewLead({ open, onClose }: { open: boolean; onClose: () => void }) {
subtitle="Full lead profile with insurance and assignment details." subtitle="Full lead profile with insurance and assignment details."
icon="plus" icon="plus"
footer={ footer={
mode === "full" ? (
<>
<Btn variant="ghost" onClick={close}>Cancel</Btn>
{!isFirstStep && <Btn variant="ghost" onClick={goBack}>Back</Btn>}
{isLastStep
? <Btn icon="check" onClick={submit}>Create Lead</Btn>
: <Btn icon="arrow" onClick={goNext}>Next</Btn>}
</>
) : (
<> <>
<Btn variant="ghost" onClick={close}>Cancel</Btn> <Btn variant="ghost" onClick={close}>Cancel</Btn>
<Btn icon="check" onClick={submit}>Create Lead</Btn> <Btn icon="check" onClick={submit}>Create Lead</Btn>
</> </>
)
} }
> >
<div className="nl-form"> <div className="nl-form">
@@ -384,12 +408,17 @@ function NewLead({ open, onClose }: { open: boolean; onClose: () => void }) {
<Field label="First name" required><input className="ds-input" value={f.firstName} onChange={set("firstName")} placeholder="John" /></Field> <Field label="First name" required><input className="ds-input" value={f.firstName} onChange={set("firstName")} placeholder="John" /></Field>
<Field label="Last name"><input className="ds-input" value={f.lastName} onChange={set("lastName")} placeholder="Smith" /></Field> <Field label="Last name"><input className="ds-input" value={f.lastName} onChange={set("lastName")} placeholder="Smith" /></Field>
<Field label="Phone"><input className="ds-input" value={f.phones[0]?.number ?? ""} onChange={(e) => setPhone(0, "number", e.target.value)} placeholder="(555) 000-0000" /></Field> <Field label="Phone"><input className="ds-input" value={f.phones[0]?.number ?? ""} onChange={(e) => setPhone(0, "number", e.target.value)} placeholder="(555) 000-0000" /></Field>
<Field label="Lead source"><Select value={f.source} onChange={set("source")} options={LEAD_SOURCES} placeholder="How did you find this lead?" /></Field>
<div className="nl-full"><Field label="Street address"><input className="ds-input" value={f.address} onChange={set("address")} placeholder="123 Main St" /></Field></div> <div className="nl-full"><Field label="Street address"><input className="ds-input" value={f.address} onChange={set("address")} placeholder="123 Main St" /></Field></div>
<Field label="City"><input className="ds-input" value={f.city} onChange={set("city")} placeholder="Plano" /></Field> <Field label="City"><input className="ds-input" value={f.city} onChange={set("city")} placeholder="Plano" /></Field>
<Field label="State"><input className="ds-input" value={f.state} onChange={set("state")} /></Field> <Field label="State"><input className="ds-input" value={f.state} onChange={set("state")} /></Field>
<Field label="ZIP"><input className="ds-input" value={f.zip} onChange={set("zip")} placeholder="75023" /></Field> <Field label="ZIP"><input className="ds-input" value={f.zip} onChange={set("zip")} placeholder="75023" /></Field>
<Field label="Assign rep"><RepSelect value={f.assignRep} onChange={set("assignRep")} options={repOptions} /></Field> <Field label="Lead source"><Select value={f.source} onChange={set("source")} options={LEAD_SOURCES} placeholder="How did you find this lead?" /></Field>
{f.source === "Referral" && (
<div className="nl-full"><Field label="Referral note"><textarea className="ds-textarea" rows={3} value={f.referralNote} onChange={set("referralNote")} placeholder="Who referred this lead? Any details…" /></Field></div>
)}
{f.source === "Door Knock" && (
<div className="nl-full"><Field label="Canvasser"><CanvasserSearch value={f.canvasser} onChange={(v) => setF((s) => ({ ...s, canvasser: v }))} options={REPS} /></Field></div>
)}
<div className="nl-full"><PriorityPicker value={f.priority} onChange={(p) => setF((s) => ({ ...s, priority: p }))} /></div> <div className="nl-full"><PriorityPicker value={f.priority} onChange={(p) => setF((s) => ({ ...s, priority: p }))} /></div>
<Field label="Follow-up date"><input className="ds-input" type="date" value={f.followUp} onChange={set("followUp")} /></Field> <Field label="Follow-up date"><input className="ds-input" type="date" value={f.followUp} onChange={set("followUp")} /></Field>
</div> </div>
@@ -398,13 +427,7 @@ function NewLead({ open, onClose }: { open: boolean; onClose: () => void }) {
<SegTabs <SegTabs
value={section} value={section}
onChange={setSection} onChange={setSection}
tabs={[ tabs={FULL_STEPS}
{ value: "contact", label: "Contact", icon: "user" },
{ value: "property", label: "Property", icon: "owners" },
{ value: "job", label: "Job Details", icon: "projects" },
{ value: "insurance", label: "Insurance", icon: "shield" },
{ value: "assignment", label: "Assignment", icon: "team" },
]}
/> />
{section === "contact" && ( {section === "contact" && (
@@ -516,6 +539,55 @@ function RepSelect({ value, onChange, options }: { value: string; onChange: (e:
); );
} }
function CanvasserSearch({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: { id: string; initials: string; name: string; email: string }[] }) {
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const selected = options.find((o) => o.id === value);
const matches = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return options;
return options.filter((o) => o.name.toLowerCase().includes(q) || o.email.toLowerCase().includes(q));
}, [query, options]);
if (selected) {
return (
<div className="nl-canvasser-chip">
<Avatar initials={selected.initials} size={28} />
<span className="nl-canvasser-name">{selected.name}</span>
<span className="nl-canvasser-email">{selected.email}</span>
<button type="button" className="nl-canvasser-clear" onClick={() => { onChange(""); setQuery(""); }} aria-label="Clear canvasser"><Icon name="x" size={14} /></button>
</div>
);
}
return (
<div className="nl-canvasser">
<input
className="ds-input"
value={query}
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
placeholder="Search canvasser by name or email"
/>
{open && matches.length > 0 && (
<div className="nl-canvasser-menu">
{matches.map((o) => (
<button key={o.id} type="button" className="nl-canvasser-opt" onMouseDown={(e) => { e.preventDefault(); onChange(o.id); setQuery(""); setOpen(false); }}>
<Avatar initials={o.initials} size={26} />
<span className="nl-canvasser-name">{o.name}</span>
<span className="nl-canvasser-email">{o.email}</span>
</button>
))}
</div>
)}
{open && matches.length === 0 && (
<div className="nl-canvasser-menu"><div className="nl-canvasser-empty">No canvasser found</div></div>
)}
</div>
);
}
function PriorityPicker({ value, onChange }: { value: Priority; onChange: (p: Priority) => void }) { function PriorityPicker({ value, onChange }: { value: Priority; onChange: (p: Priority) => void }) {
return ( return (
<div className="ds-field"> <div className="ds-field">