# CSV Lead Import — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Allow supervisors to import leads from CSV into an existing campaign via a modal wizard with column mapping and patient matching. **Architecture:** Client-side CSV parsing with a 3-step modal wizard (select campaign → upload/map/preview → import). Leads created via existing GraphQL proxy. No new sidecar endpoints needed. **Tech Stack:** React modal (Untitled UI), native FileReader + string split for CSV parsing, existing DataProvider for patient/lead matching, platform GraphQL mutations for lead creation. --- ## File Map | File | Action | Responsibility | |---|---|---| | `src/lib/csv-utils.ts` | Create | CSV parsing, phone normalization, fuzzy column matching | | `src/components/campaigns/lead-import-wizard.tsx` | Create | Modal wizard: campaign select → upload/preview → import | | `src/pages/campaigns.tsx` | Modify | Add "Import Leads" button | --- ### Task 1: CSV Parsing & Column Matching Utility **Files:** - Create: `src/lib/csv-utils.ts` - [ ] **Step 1: Create csv-utils.ts with parseCSV function** ```typescript // src/lib/csv-utils.ts export type CSVRow = Record; export type CSVParseResult = { headers: string[]; rows: CSVRow[]; }; export const parseCSV = (text: string): CSVParseResult => { const lines = text.split(/\r?\n/).filter(line => line.trim()); if (lines.length === 0) return { headers: [], rows: [] }; const parseLine = (line: string): string[] => { const result: string[] = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const char = line[i]; if (char === '"') { if (inQuotes && line[i + 1] === '"') { current += '"'; i++; } else { inQuotes = !inQuotes; } } else if (char === ',' && !inQuotes) { result.push(current.trim()); current = ''; } else { current += char; } } result.push(current.trim()); return result; }; const headers = parseLine(lines[0]); const rows = lines.slice(1).map(line => { const values = parseLine(line); const row: CSVRow = {}; headers.forEach((header, i) => { row[header] = values[i] ?? ''; }); return row; }); return { headers, rows }; }; ``` - [ ] **Step 2: Add normalizePhone function** ```typescript export const normalizePhone = (raw: string): string => { const digits = raw.replace(/\D/g, ''); const stripped = digits.length >= 12 && digits.startsWith('91') ? digits.slice(2) : digits; return stripped.slice(-10); }; ``` - [ ] **Step 3: Add fuzzy column matching** ```typescript export type LeadFieldMapping = { csvHeader: string; leadField: string | null; label: string; }; const LEAD_FIELDS = [ { field: 'contactName.firstName', label: 'First Name', patterns: ['first name', 'firstname', 'name', 'patient name', 'patient'] }, { field: 'contactName.lastName', label: 'Last Name', patterns: ['last name', 'lastname', 'surname'] }, { field: 'contactPhone', label: 'Phone', patterns: ['phone', 'mobile', 'contact number', 'cell', 'phone number', 'mobile number'] }, { field: 'contactEmail', label: 'Email', patterns: ['email', 'email address', 'mail'] }, { field: 'interestedService', label: 'Interested Service', patterns: ['service', 'interested in', 'department', 'specialty', 'interest'] }, { field: 'priority', label: 'Priority', patterns: ['priority', 'urgency'] }, { field: 'utmSource', label: 'UTM Source', patterns: ['utm_source', 'utmsource', 'source'] }, { field: 'utmMedium', label: 'UTM Medium', patterns: ['utm_medium', 'utmmedium', 'medium'] }, { field: 'utmCampaign', label: 'UTM Campaign', patterns: ['utm_campaign', 'utmcampaign'] }, { field: 'utmTerm', label: 'UTM Term', patterns: ['utm_term', 'utmterm', 'term'] }, { field: 'utmContent', label: 'UTM Content', patterns: ['utm_content', 'utmcontent'] }, ]; export const fuzzyMatchColumns = (csvHeaders: string[]): LeadFieldMapping[] => { const used = new Set(); return csvHeaders.map(header => { const normalized = header.toLowerCase().trim().replace(/[^a-z0-9 ]/g, ''); let bestMatch: string | null = null; for (const field of LEAD_FIELDS) { if (used.has(field.field)) continue; if (field.patterns.some(p => normalized === p || normalized.includes(p))) { bestMatch = field.field; used.add(field.field); break; } } return { csvHeader: header, leadField: bestMatch, label: bestMatch ? LEAD_FIELDS.find(f => f.field === bestMatch)!.label : '', }; }); }; export { LEAD_FIELDS }; ``` - [ ] **Step 4: Add buildLeadPayload helper** ```typescript export const buildLeadPayload = ( row: CSVRow, mapping: LeadFieldMapping[], campaignId: string, patientId: string | null, platform: string | null, ) => { const getValue = (field: string): string => { const entry = mapping.find(m => m.leadField === field); return entry ? (row[entry.csvHeader] ?? '').trim() : ''; }; const firstName = getValue('contactName.firstName') || 'Unknown'; const lastName = getValue('contactName.lastName'); const phone = normalizePhone(getValue('contactPhone')); if (!phone || phone.length < 10) return null; const sourceMap: Record = { FACEBOOK: 'FACEBOOK_AD', GOOGLE: 'GOOGLE_AD', INSTAGRAM: 'INSTAGRAM', MANUAL: 'OTHER', }; return { name: `${firstName} ${lastName}`.trim(), contactName: { firstName, lastName }, contactPhone: { primaryPhoneNumber: `+91${phone}` }, ...(getValue('contactEmail') ? { contactEmail: { primaryEmail: getValue('contactEmail') } } : {}), ...(getValue('interestedService') ? { interestedService: getValue('interestedService') } : {}), ...(getValue('utmSource') ? { utmSource: getValue('utmSource') } : {}), ...(getValue('utmMedium') ? { utmMedium: getValue('utmMedium') } : {}), ...(getValue('utmCampaign') ? { utmCampaign: getValue('utmCampaign') } : {}), ...(getValue('utmTerm') ? { utmTerm: getValue('utmTerm') } : {}), ...(getValue('utmContent') ? { utmContent: getValue('utmContent') } : {}), source: sourceMap[platform ?? ''] ?? 'OTHER', status: 'NEW', campaignId, ...(patientId ? { patientId } : {}), }; }; ``` - [ ] **Step 5: Commit** ```bash git add src/lib/csv-utils.ts git commit -m "feat: CSV parsing, phone normalization, and fuzzy column matching utility" ``` --- ### Task 2: Lead Import Wizard Component **Files:** - Create: `src/components/campaigns/lead-import-wizard.tsx` - [ ] **Step 1: Create wizard component with campaign selection step** ```typescript // src/components/campaigns/lead-import-wizard.tsx import { useState, useMemo, useCallback } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faFileImport, faCheck, faSpinner, faTriangleExclamation, faCloudArrowUp } from '@fortawesome/pro-duotone-svg-icons'; import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal'; import { Button } from '@/components/base/buttons/button'; import { Badge } from '@/components/base/badges/badges'; import { Table } from '@/components/application/table/table'; import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon'; import { Select } from '@/components/base/select/select'; import { useData } from '@/providers/data-provider'; import { apiClient } from '@/lib/api-client'; import { parseCSV, fuzzyMatchColumns, buildLeadPayload, normalizePhone, LEAD_FIELDS, type LeadFieldMapping, type CSVRow } from '@/lib/csv-utils'; import { cx } from '@/utils/cx'; import type { Campaign } from '@/types/entities'; import type { FC } from 'react'; const FileImportIcon: FC<{ className?: string }> = ({ className }) => ( ); type ImportStep = 'select-campaign' | 'upload-preview' | 'importing' | 'done'; type ImportResult = { created: number; linkedToPatient: number; skippedDuplicate: number; skippedNoPhone: number; failed: number; total: number; }; interface LeadImportWizardProps { isOpen: boolean; onOpenChange: (open: boolean) => void; } export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps) => { const { campaigns, leads, patients, refresh } = useData(); const [step, setStep] = useState('select-campaign'); const [selectedCampaign, setSelectedCampaign] = useState(null); const [csvRows, setCsvRows] = useState([]); const [csvHeaders, setCsvHeaders] = useState([]); const [mapping, setMapping] = useState([]); const [result, setResult] = useState(null); const [importProgress, setImportProgress] = useState(0); const activeCampaigns = useMemo(() => campaigns.filter(c => c.campaignStatus === 'ACTIVE' || c.campaignStatus === 'PAUSED'), [campaigns], ); const handleClose = () => { onOpenChange(false); // Reset state after close animation setTimeout(() => { setStep('select-campaign'); setSelectedCampaign(null); setCsvRows([]); setCsvHeaders([]); setMapping([]); setResult(null); setImportProgress(0); }, 300); }; const handleCampaignSelect = (campaign: Campaign) => { setSelectedCampaign(campaign); setStep('upload-preview'); }; const handleFileUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = (event) => { const text = event.target?.result as string; const { headers, rows } = parseCSV(text); setCsvHeaders(headers); setCsvRows(rows); setMapping(fuzzyMatchColumns(headers)); }; reader.readAsText(file); }; const handleMappingChange = (csvHeader: string, leadField: string | null) => { setMapping(prev => prev.map(m => m.csvHeader === csvHeader ? { ...m, leadField, label: leadField ? LEAD_FIELDS.find(f => f.field === leadField)?.label ?? '' : '' } : m, )); }; // Patient matching for preview const rowsWithMatch = useMemo(() => { const phoneMapping = mapping.find(m => m.leadField === 'contactPhone'); if (!phoneMapping || csvRows.length === 0) return []; const existingLeadPhones = new Set( leads.map(l => normalizePhone(l.contactPhone?.[0]?.number ?? '')).filter(p => p.length === 10), ); const patientByPhone = new Map( patients .filter(p => p.phones?.primaryPhoneNumber) .map(p => [normalizePhone(p.phones!.primaryPhoneNumber), p]), ); return csvRows.map(row => { const rawPhone = row[phoneMapping.csvHeader] ?? ''; const phone = normalizePhone(rawPhone); const matchedPatient = phone.length === 10 ? patientByPhone.get(phone) : null; const isDuplicate = phone.length === 10 && existingLeadPhones.has(phone); const hasPhone = phone.length === 10; return { row, phone, matchedPatient, isDuplicate, hasPhone }; }); }, [csvRows, mapping, leads, patients]); const phoneIsMapped = mapping.some(m => m.leadField === 'contactPhone'); const validCount = rowsWithMatch.filter(r => r.hasPhone && !r.isDuplicate).length; const duplicateCount = rowsWithMatch.filter(r => r.isDuplicate).length; const noPhoneCount = rowsWithMatch.filter(r => !r.hasPhone).length; const patientMatchCount = rowsWithMatch.filter(r => r.matchedPatient).length; const handleImport = async () => { if (!selectedCampaign) return; setStep('importing'); const importResult: ImportResult = { created: 0, linkedToPatient: 0, skippedDuplicate: 0, skippedNoPhone: 0, failed: 0, total: rowsWithMatch.length }; for (let i = 0; i < rowsWithMatch.length; i++) { const { row, isDuplicate, hasPhone, matchedPatient } = rowsWithMatch[i]; if (!hasPhone) { importResult.skippedNoPhone++; setImportProgress(i + 1); continue; } if (isDuplicate) { importResult.skippedDuplicate++; setImportProgress(i + 1); continue; } const payload = buildLeadPayload(row, mapping, selectedCampaign.id, matchedPatient?.id ?? null, selectedCampaign.platform); if (!payload) { importResult.skippedNoPhone++; setImportProgress(i + 1); continue; } try { await apiClient.graphql( `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, { data: payload }, { silent: true }, ); importResult.created++; if (matchedPatient) importResult.linkedToPatient++; } catch { importResult.failed++; } setImportProgress(i + 1); } setResult(importResult); setStep('done'); refresh(); }; // Available lead fields for mapping dropdown (exclude already-mapped ones) const availableFields = useMemo(() => { const usedFields = new Set(mapping.filter(m => m.leadField).map(m => m.leadField)); return LEAD_FIELDS.map(f => ({ id: f.field, name: f.label, isDisabled: usedFields.has(f.field), })); }, [mapping]); return ( { if (!open) handleClose(); }}> {() => (
{/* Header */}

Import Leads

{step === 'select-campaign' && 'Select a campaign to import leads into'} {step === 'upload-preview' && `Importing into: ${selectedCampaign?.campaignName}`} {step === 'importing' && 'Importing leads...'} {step === 'done' && 'Import complete'}

{/* Content */}
{/* Step 1: Campaign Cards */} {step === 'select-campaign' && (
{activeCampaigns.length === 0 ? (

No active campaigns. Create a campaign first.

) : ( activeCampaigns.map(campaign => ( )) )}
)} {/* Step 2: Upload + Preview */} {step === 'upload-preview' && (
{/* File upload */} {csvRows.length === 0 ? ( ) : ( <> {/* Validation banner */} {!phoneIsMapped && (
Phone column must be mapped to proceed
)} {/* Summary */}
{csvRows.length} rows {validCount} ready {patientMatchCount > 0 && {patientMatchCount} existing patients} {duplicateCount > 0 && {duplicateCount} duplicates} {noPhoneCount > 0 && {noPhoneCount} no phone}
{/* Column mapping + preview table */}
{/* Mapping row */} {mapping.map(m => ( ))} {rowsWithMatch.slice(0, 20).map((item, i) => ( {mapping.map(m => ( ))} ))}
{m.csvHeader}
Patient Match
{item.row[m.csvHeader] ?? ''} {item.matchedPatient ? ( {item.matchedPatient.fullName?.firstName ?? 'Patient'} ) : item.isDuplicate ? ( Duplicate ) : !item.hasPhone ? ( No phone ) : ( New )}
{csvRows.length > 20 && (
Showing 20 of {csvRows.length} rows
)}
)}
)} {/* Step 3: Importing */} {step === 'importing' && (

Importing leads...

{importProgress} of {rowsWithMatch.length}

)} {/* Step 4: Done */} {step === 'done' && result && (
} color="success" theme="light" size="lg" />

Import Complete

{result.created}

Created

{result.linkedToPatient}

Linked to Patients

{result.skippedDuplicate > 0 && (

{result.skippedDuplicate}

Duplicates

)} {result.failed > 0 && (

{result.failed}

Failed

)}
)}
{/* Footer */}
{step === 'select-campaign' && ( )} {step === 'upload-preview' && ( <> )} {step === 'done' && ( )}
)}
); }; ``` - [ ] **Step 2: Commit** ```bash git add src/components/campaigns/lead-import-wizard.tsx git commit -m "feat: lead import wizard with campaign selection, CSV preview, and patient matching" ``` --- ### Task 3: Add Import Button to Campaigns Page **Files:** - Modify: `src/pages/campaigns.tsx` - [ ] **Step 1: Import LeadImportWizard and add state + button** Add import at top of `campaigns.tsx`: ```typescript import { LeadImportWizard } from '@/components/campaigns/lead-import-wizard'; ``` Add state inside `CampaignsPage` component: ```typescript const [importOpen, setImportOpen] = useState(false); ``` Add button next to the TopBar or in the header area. Replace the existing `TopBar` line: ```typescript ``` with: ```typescript ``` Add the import for `faFileImport`: ```typescript import { faPenToSquare, faFileImport } from '@fortawesome/pro-duotone-svg-icons'; ``` Add the wizard component before the closing `` of the return, after the CampaignEditSlideout: ```typescript ``` - [ ] **Step 2: Check if TopBar accepts children** Read `src/components/layout/top-bar.tsx` to verify it renders `children`. If not, place the button differently — inside the existing header div in campaigns.tsx. - [ ] **Step 3: Type check** Run: `npx tsc --noEmit --pretty` Expected: Clean (no errors) - [ ] **Step 4: Build** Run: `npm run build` Expected: Build succeeds - [ ] **Step 5: Commit** ```bash git add src/pages/campaigns.tsx git commit -m "feat: add Import Leads button to campaigns page" ``` --- ### Task 4: Integration Verification - [ ] **Step 1: Verify full build** ```bash npm run build ``` Expected: Build succeeds with no type errors. - [ ] **Step 2: Manual test checklist** 1. Navigate to Campaigns page as admin 2. "Import Leads" button visible 3. Click → modal opens with campaign cards 4. Select a campaign → proceeds to upload step 5. Upload a test CSV → column mapping appears with fuzzy matches 6. Phone column auto-detected 7. Patient match column shows "Existing" or "New" badges 8. Duplicate leads highlighted 9. Click Import → progress bar → summary 10. Close modal → campaign lead count updated - [ ] **Step 3: Create a test CSV file for verification** ```csv First Name,Last Name,Phone,Email,Service,Priority Ganesh,Bandi,8885540404,ganesh@email.com,Back Pain,HIGH Meghana,,7702055204,meghana@email.com,Hair Loss,NORMAL Priya,Sharma,9949879837,,Prenatal Care,NORMAL New,Patient,9876500001,,General Checkup,LOW ``` - [ ] **Step 4: Final commit with test data** ```bash git add -A git commit -m "feat: CSV lead import — complete wizard with campaign selection, mapping, and patient matching" ``` --- ## Execution Notes - The wizard uses the existing `ModalOverlay`/`Modal`/`Dialog` pattern from Untitled UI (same as disposition modal) - CSV parsing is native (no npm dependency) — handles quoted fields and commas - Patient matching uses DataProvider data already in memory — no additional API calls for matching - Lead creation uses existing GraphQL proxy — no new sidecar endpoint - The `useData().refresh()` call after import updates all DataProvider consumers (campaign lead counts, lead master, etc.)