6.4 KiB
CSV Lead Import — Design Spec
Date: 2026-03-31 Status: Approved
Overview
Supervisors can import leads from a CSV file into an existing campaign. The feature is a modal wizard accessible from the Campaigns page. Leads are created via the platform GraphQL API and linked to the selected campaign. Existing patients are detected by phone number matching.
User Flow
Entry Point
"Import Leads" button on the Campaigns page (/campaigns). Admin role only.
Step 1 — Select Campaign (modal opens)
- Campaign cards in a grid layout inside the modal
- Each card shows: campaign name, platform badge (Facebook/Google/Instagram/Manual), status badge (Active/Paused/Completed), lead count
- Click a card to select → proceeds to Step 2
- Only ACTIVE and PAUSED campaigns shown (not COMPLETED)
Step 2 — Upload & Preview
- File drop zone at top of modal (accepts
.csvonly) - On file upload, parse CSV client-side
- Show preview table with:
- Column mapping row: each CSV column header has a dropdown to map to a Lead field. Fuzzy auto-match on load (e.g., "Phone" → contactPhone, "Name" → contactName.firstName, "Email" → contactEmail, "Service" → interestedService)
- Data rows: all rows displayed (paginated at 20 per page if large file)
- Patient match column (rightmost): for each row, check phone against existing patients in DataProvider
- Green badge: "Existing — {Patient Name}" (phone matched)
- Gray badge: "New" (no match)
- Duplicate lead column: check phone against existing leads
- Orange badge: "Duplicate" (phone already exists as a lead)
- No badge if clean
- Validation:
contactPhonemapping is required — show error banner if unmapped- Rows with empty phone values are flagged as "Skip — no phone"
- Footer shows summary: "48 leads ready, 3 existing patients, 2 duplicates, 1 skipped"
- "Import" button enabled only when contactPhone is mapped and at least 1 valid row exists
Step 3 — Import Progress
- "Import" button triggers sequential lead creation
- Progress bar: "Importing 12 / 48..."
- Each lead created via GraphQL mutation:
mutation($data: LeadCreateInput!) { createLead(data: $data) { id } } - Data payload per lead:
name: "{firstName} {lastName}" or phone if no namecontactName:{ firstName, lastName }from mapped columnscontactPhone:{ primaryPhoneNumber }from mapped column (normalized with +91 prefix)contactEmail:{ primaryEmail }if mappedinterestedService: if mappedsource: campaign platform (FACEBOOK_AD, GOOGLE_AD, etc.) or MANUALstatus: NEWcampaignId: selected campaign IDpatientId: if phone matched an existing patient- All other mapped fields set accordingly
- Duplicate leads (phone already exists) are skipped
- On complete: summary card — "45 created, 3 linked to existing patients, 2 skipped (duplicates), 1 skipped (no phone)"
Step 4 — Done
- Summary with green checkmark
- "Done" button closes modal
- Campaigns page refreshes to show updated lead count
Column Mapping — Fuzzy Match Rules
CSV headers are normalized (lowercase, trim, remove special chars) and matched against Lead field labels:
| CSV Header Pattern | Maps To | Field Type |
|---|---|---|
| name, first name, patient name | contactName.firstName | FULL_NAME |
| last name, surname | contactName.lastName | FULL_NAME |
| phone, mobile, contact number, cell | contactPhone | PHONES |
| email, email address | contactEmail | EMAILS |
| service, interested in, department, specialty | interestedService | TEXT |
| priority | priority | SELECT |
| source, lead source, channel | source | SELECT |
| notes, comments, remarks | (stored as lead name suffix or skipped) | — |
| utm_source, utm_medium, utm_campaign, utm_term, utm_content | utmSource/utmMedium/utmCampaign/utmTerm/utmContent | TEXT |
Unmapped columns are ignored. User can override any auto-match via dropdown.
Phone Normalization
Before matching and creating:
- Strip all non-digit characters
- Remove leading
+91or91if 12+ digits - Take last 10 digits
- Store as
+91{10digits}on the Lead
Patient Matching
Uses the patients array from DataProvider (already loaded in memory):
- For each CSV row, normalize the phone number
- Check against
patient.phones.primaryPhoneNumber(last 10 digits) - If match found: set
patientIdon the created Lead, show patient name in preview - If no match: leave
patientIdnull, caller resolution will handle it on first call
Duplicate Lead Detection
Uses the leads array from DataProvider:
- For each CSV row, check normalized phone against existing
lead.contactPhone[0].number - If match found: mark as duplicate in preview, skip during import
- If no match: create normally
Error Handling
- Invalid CSV (no headers, empty file): show error banner in modal, no preview
- File too large (>5000 rows): show warning, allow import but warn about duration
- Individual mutation failures: log error, continue with remaining rows, show count in summary
- Network failure mid-import: show partial result — "23 of 48 imported, import interrupted"
Architecture
No new sidecar endpoint needed
CSV parsing happens client-side. Lead creation uses the existing GraphQL proxy (/graphql → platform). Patient/lead matching uses DataProvider data already in memory.
Files
| File | Action |
|---|---|
src/components/campaigns/lead-import-wizard.tsx |
New — Modal wizard component (Steps 1-4) |
src/pages/campaigns.tsx |
Modified — Add "Import Leads" button |
src/lib/csv-parser.ts |
New — CSV parsing + column fuzzy matching utility |
Dependencies
- No new npm packages needed —
FileReaderAPI + string split for CSV parsing (or use existingpapaparseif already in node_modules) - Untitled UI components: Modal, Button, Badge, Table, Input (file), FeaturedIcon
Scope Boundaries
In scope:
- Campaign selection via cards
- CSV upload and client-side parsing
- Fuzzy column mapping with manual override
- Preview with patient match + duplicate detection
- Sequential lead creation with progress
- Phone normalization
Out of scope (future):
- Dynamic campaign-specific entity creation (AI-driven schema)
- Campaign content/template creation
- Bulk update of existing leads from CSV
- API-based lead ingestion (Facebook/Google webhooks)
- Code generation webhook on schema changes