# 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 `.csv` only) - 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: - `contactPhone` mapping 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: ```graphql mutation($data: LeadCreateInput!) { createLead(data: $data) { id } } ``` - Data payload per lead: - `name`: "{firstName} {lastName}" or phone if no name - `contactName`: `{ firstName, lastName }` from mapped columns - `contactPhone`: `{ primaryPhoneNumber }` from mapped column (normalized with +91 prefix) - `contactEmail`: `{ primaryEmail }` if mapped - `interestedService`: if mapped - `source`: campaign platform (FACEBOOK_AD, GOOGLE_AD, etc.) or MANUAL - `status`: NEW - `campaignId`: selected campaign ID - `patientId`: 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: 1. Strip all non-digit characters 2. Remove leading `+91` or `91` if 12+ digits 3. Take last 10 digits 4. 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 `patientId` on the created Lead, show patient name in preview - If no match: leave `patientId` null, 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 — `FileReader` API + string split for CSV parsing (or use existing `papaparse` if 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