Files
helix-engage/docs/superpowers/plans/2026-03-31-csv-lead-import.md
saridsa2 7af1ccb713 feat: wizard step indicator, wider dialog, campaigns in admin sidebar, clear leads shortcut
- Import wizard: added step indicator (numbered circles), widened to max-w-5xl
- Admin sidebar: added Marketing → Campaigns nav link
- Clear campaign leads: Ctrl+Shift+C shortcut with campaign picker modal (test-only)
- Test CSV data for all 3 campaigns
- Defect fixing plan + CSV import spec docs
- Session memory update

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:45:05 +05:30

36 KiB

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

// src/lib/csv-utils.ts

export type CSVRow = Record<string, string>;

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
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
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<string>();

    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
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<string, string> = {
        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
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

// 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 }) => (
    <FontAwesomeIcon icon={faFileImport} className={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<ImportStep>('select-campaign');
    const [selectedCampaign, setSelectedCampaign] = useState<Campaign | null>(null);
    const [csvRows, setCsvRows] = useState<CSVRow[]>([]);
    const [csvHeaders, setCsvHeaders] = useState<string[]>([]);
    const [mapping, setMapping] = useState<LeadFieldMapping[]>([]);
    const [result, setResult] = useState<ImportResult | null>(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<HTMLInputElement>) => {
        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 (
        <ModalOverlay isOpen={isOpen} isDismissable onOpenChange={(open) => { if (!open) handleClose(); }}>
            <Modal className="sm:max-w-3xl">
                <Dialog>
                    {() => (
                        <div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden max-h-[85vh]">
                            {/* Header */}
                            <div className="flex items-center justify-between px-6 py-4 border-b border-secondary shrink-0">
                                <div className="flex items-center gap-3">
                                    <FeaturedIcon icon={FileImportIcon} color="brand" theme="light" size="sm" />
                                    <div>
                                        <h2 className="text-lg font-semibold text-primary">Import Leads</h2>
                                        <p className="text-xs text-tertiary">
                                            {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'}
                                        </p>
                                    </div>
                                </div>
                                <button onClick={handleClose} className="text-fg-quaternary hover:text-fg-secondary text-lg">&times;</button>
                            </div>

                            {/* Content */}
                            <div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">

                                {/* Step 1: Campaign Cards */}
                                {step === 'select-campaign' && (
                                    <div className="grid grid-cols-2 gap-3">
                                        {activeCampaigns.length === 0 ? (
                                            <p className="col-span-2 py-12 text-center text-sm text-tertiary">No active campaigns. Create a campaign first.</p>
                                        ) : (
                                            activeCampaigns.map(campaign => (
                                                <button
                                                    key={campaign.id}
                                                    onClick={() => handleCampaignSelect(campaign)}
                                                    className={cx(
                                                        'flex flex-col items-start rounded-xl border-2 p-4 text-left transition duration-100 ease-linear hover:border-brand',
                                                        selectedCampaign?.id === campaign.id ? 'border-brand bg-brand-primary' : 'border-secondary',
                                                    )}
                                                >
                                                    <span className="text-sm font-semibold text-primary">{campaign.campaignName ?? 'Untitled'}</span>
                                                    <div className="mt-1 flex items-center gap-2">
                                                        {campaign.platform && <Badge size="sm" color="brand" type="pill-color">{campaign.platform}</Badge>}
                                                        <Badge size="sm" color={campaign.campaignStatus === 'ACTIVE' ? 'success' : 'gray'} type="pill-color">
                                                            {campaign.campaignStatus}
                                                        </Badge>
                                                    </div>
                                                    <span className="mt-2 text-xs text-tertiary">{campaign.leadCount ?? 0} leads</span>
                                                </button>
                                            ))
                                        )}
                                    </div>
                                )}

                                {/* Step 2: Upload + Preview */}
                                {step === 'upload-preview' && (
                                    <div className="space-y-4">
                                        {/* File upload */}
                                        {csvRows.length === 0 ? (
                                            <label className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-secondary py-12 cursor-pointer hover:border-brand hover:bg-brand-primary transition duration-100 ease-linear">
                                                <FontAwesomeIcon icon={faCloudArrowUp} className="size-8 text-fg-quaternary mb-3" />
                                                <span className="text-sm font-medium text-secondary">Drop CSV file here or click to browse</span>
                                                <span className="text-xs text-tertiary mt-1">CSV files only, max 5000 rows</span>
                                                <input type="file" accept=".csv" onChange={handleFileUpload} className="hidden" />
                                            </label>
                                        ) : (
                                            <>
                                                {/* Validation banner */}
                                                {!phoneIsMapped && (
                                                    <div className="flex items-center gap-2 rounded-lg bg-error-primary px-4 py-3">
                                                        <FontAwesomeIcon icon={faTriangleExclamation} className="size-4 text-fg-error-primary" />
                                                        <span className="text-sm font-medium text-error-primary">Phone column must be mapped to proceed</span>
                                                    </div>
                                                )}

                                                {/* Summary */}
                                                <div className="flex items-center gap-4 text-xs text-tertiary">
                                                    <span>{csvRows.length} rows</span>
                                                    <span className="text-success-primary">{validCount} ready</span>
                                                    {patientMatchCount > 0 && <span className="text-brand-secondary">{patientMatchCount} existing patients</span>}
                                                    {duplicateCount > 0 && <span className="text-warning-primary">{duplicateCount} duplicates</span>}
                                                    {noPhoneCount > 0 && <span className="text-error-primary">{noPhoneCount} no phone</span>}
                                                </div>

                                                {/* Column mapping + preview table */}
                                                <div className="overflow-x-auto rounded-lg border border-secondary">
                                                    <table className="w-full text-xs">
                                                        {/* Mapping row */}
                                                        <thead>
                                                            <tr className="bg-secondary">
                                                                {mapping.map(m => (
                                                                    <th key={m.csvHeader} className="px-3 py-2 text-left font-normal">
                                                                        <div className="space-y-1">
                                                                            <span className="text-[10px] text-quaternary uppercase">{m.csvHeader}</span>
                                                                            <select
                                                                                value={m.leadField ?? ''}
                                                                                onChange={e => handleMappingChange(m.csvHeader, e.target.value || null)}
                                                                                className="w-full rounded border border-secondary bg-primary px-2 py-1 text-xs text-primary"
                                                                            >
                                                                                <option value="">Skip</option>
                                                                                {LEAD_FIELDS.map(f => (
                                                                                    <option key={f.field} value={f.field}>{f.label}</option>
                                                                                ))}
                                                                            </select>
                                                                        </div>
                                                                    </th>
                                                                ))}
                                                                <th className="px-3 py-2 text-left font-normal">
                                                                    <span className="text-[10px] text-quaternary uppercase">Patient Match</span>
                                                                </th>
                                                            </tr>
                                                        </thead>
                                                        <tbody>
                                                            {rowsWithMatch.slice(0, 20).map((item, i) => (
                                                                <tr key={i} className={cx(
                                                                    'border-t border-secondary',
                                                                    item.isDuplicate && 'bg-warning-primary opacity-60',
                                                                    !item.hasPhone && 'bg-error-primary opacity-40',
                                                                )}>
                                                                    {mapping.map(m => (
                                                                        <td key={m.csvHeader} className="px-3 py-2 text-tertiary truncate max-w-[150px]">
                                                                            {item.row[m.csvHeader] ?? ''}
                                                                        </td>
                                                                    ))}
                                                                    <td className="px-3 py-2">
                                                                        {item.matchedPatient ? (
                                                                            <Badge size="sm" color="success" type="pill-color">
                                                                                {item.matchedPatient.fullName?.firstName ?? 'Patient'}
                                                                            </Badge>
                                                                        ) : item.isDuplicate ? (
                                                                            <Badge size="sm" color="warning" type="pill-color">Duplicate</Badge>
                                                                        ) : !item.hasPhone ? (
                                                                            <Badge size="sm" color="error" type="pill-color">No phone</Badge>
                                                                        ) : (
                                                                            <Badge size="sm" color="gray" type="pill-color">New</Badge>
                                                                        )}
                                                                    </td>
                                                                </tr>
                                                            ))}
                                                        </tbody>
                                                    </table>
                                                    {csvRows.length > 20 && (
                                                        <div className="bg-secondary px-3 py-2 text-center text-xs text-tertiary">
                                                            Showing 20 of {csvRows.length} rows
                                                        </div>
                                                    )}
                                                </div>
                                            </>
                                        )}
                                    </div>
                                )}

                                {/* Step 3: Importing */}
                                {step === 'importing' && (
                                    <div className="flex flex-col items-center justify-center py-12">
                                        <FontAwesomeIcon icon={faSpinner} className="size-8 animate-spin text-brand-secondary mb-4" />
                                        <p className="text-sm font-semibold text-primary">Importing leads...</p>
                                        <p className="text-xs text-tertiary mt-1">{importProgress} of {rowsWithMatch.length}</p>
                                        <div className="mt-4 w-64 h-2 rounded-full bg-secondary overflow-hidden">
                                            <div
                                                className="h-full rounded-full bg-brand-solid transition-all duration-200"
                                                style={{ width: `${(importProgress / rowsWithMatch.length) * 100}%` }}
                                            />
                                        </div>
                                    </div>
                                )}

                                {/* Step 4: Done */}
                                {step === 'done' && result && (
                                    <div className="flex flex-col items-center justify-center py-12">
                                        <FeaturedIcon icon={({ className }) => <FontAwesomeIcon icon={faCheck} className={className} />} color="success" theme="light" size="lg" />
                                        <p className="text-lg font-semibold text-primary mt-4">Import Complete</p>
                                        <div className="mt-4 grid grid-cols-2 gap-3 w-64 text-center">
                                            <div className="rounded-lg bg-success-primary p-3">
                                                <p className="text-xl font-bold text-success-primary">{result.created}</p>
                                                <p className="text-xs text-tertiary">Created</p>
                                            </div>
                                            <div className="rounded-lg bg-brand-primary p-3">
                                                <p className="text-xl font-bold text-brand-secondary">{result.linkedToPatient}</p>
                                                <p className="text-xs text-tertiary">Linked to Patients</p>
                                            </div>
                                            {result.skippedDuplicate > 0 && (
                                                <div className="rounded-lg bg-warning-primary p-3">
                                                    <p className="text-xl font-bold text-warning-primary">{result.skippedDuplicate}</p>
                                                    <p className="text-xs text-tertiary">Duplicates</p>
                                                </div>
                                            )}
                                            {result.failed > 0 && (
                                                <div className="rounded-lg bg-error-primary p-3">
                                                    <p className="text-xl font-bold text-error-primary">{result.failed}</p>
                                                    <p className="text-xs text-tertiary">Failed</p>
                                                </div>
                                            )}
                                        </div>
                                    </div>
                                )}
                            </div>

                            {/* Footer */}
                            <div className="flex items-center justify-between px-6 py-4 border-t border-secondary shrink-0">
                                {step === 'select-campaign' && (
                                    <Button size="sm" color="secondary" onClick={handleClose}>Cancel</Button>
                                )}
                                {step === 'upload-preview' && (
                                    <>
                                        <Button size="sm" color="secondary" onClick={() => { setStep('select-campaign'); setCsvRows([]); setMapping([]); }}>Back</Button>
                                        <Button size="sm" color="primary" onClick={handleImport} isDisabled={!phoneIsMapped || validCount === 0}>
                                            Import {validCount} Lead{validCount !== 1 ? 's' : ''}
                                        </Button>
                                    </>
                                )}
                                {step === 'done' && (
                                    <Button size="sm" color="primary" onClick={handleClose} className="ml-auto">Done</Button>
                                )}
                            </div>
                        </div>
                    )}
                </Dialog>
            </Modal>
        </ModalOverlay>
    );
};
  • Step 2: Commit
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:

import { LeadImportWizard } from '@/components/campaigns/lead-import-wizard';

Add state inside CampaignsPage component:

const [importOpen, setImportOpen] = useState(false);

Add button next to the TopBar or in the header area. Replace the existing TopBar line:

<TopBar title="Campaigns" subtitle={subtitle} />

with:

<TopBar title="Campaigns" subtitle={subtitle}>
    <Button
        size="sm"
        color="primary"
        iconLeading={({ className }: { className?: string }) => (
            <FontAwesomeIcon icon={faFileImport} className={className} />
        )}
        onClick={() => setImportOpen(true)}
    >
        Import Leads
    </Button>
</TopBar>

Add the import for faFileImport:

import { faPenToSquare, faFileImport } from '@fortawesome/pro-duotone-svg-icons';

Add the wizard component before the closing </div> of the return, after the CampaignEditSlideout:

<LeadImportWizard isOpen={importOpen} onOpenChange={setImportOpen} />
  • 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
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
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
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
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.)