# 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
