import { useState, useMemo } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faFileImport, faCheck, faSpinner, faTriangleExclamation, faCloudArrowUp, faArrowRight } 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 { Select } from '@/components/base/select/select'; import { DynamicTable } from '@/components/application/table/dynamic-table'; import type { DynamicColumn, DynamicRow } from '@/components/application/table/dynamic-table'; import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon'; import { useData } from '@/providers/data-provider'; import { apiClient } from '@/lib/api-client'; import { parseCSV, fuzzyMatchColumns, buildLeadPayload, normalizePhone, LEAD_FIELDS } from '@/lib/csv-utils'; import { cx } from '@/utils/cx'; import type { Campaign } from '@/types/entities'; import type { LeadFieldMapping, CSVRow } from '@/lib/csv-utils'; import type { FC } from 'react'; const FileImportIcon: FC<{ className?: string }> = ({ className }) => ( ); type ImportStep = 'select-campaign' | 'map-columns' | 'preview' | 'importing' | 'done'; const WIZARD_STEPS = [ { key: 'select-campaign', label: 'Select Campaign', number: 1 }, { key: 'map-columns', label: 'Upload & Map', number: 2 }, { key: 'preview', label: 'Preview', number: 3 }, { key: 'done', label: 'Import', number: 4 }, ] as const; const StepIndicator = ({ currentStep }: { currentStep: ImportStep }) => { const activeIndex = currentStep === 'importing' ? 3 : WIZARD_STEPS.findIndex(s => s.key === currentStep); return (
{WIZARD_STEPS.map((step, i) => { const isComplete = i < activeIndex; const isActive = i === activeIndex; const isLast = i === WIZARD_STEPS.length - 1; return (
{isComplete ? : step.number}
{step.label}
{!isLast && (
)}
); })}
); }; type ImportResult = { created: number; linkedToPatient: number; skippedDuplicate: number; skippedNoPhone: number; failed: number; total: number; }; interface LeadImportWizardProps { isOpen: boolean; onOpenChange: (open: boolean) => void; } const PAGE_SIZE = 15; 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 [mapping, setMapping] = useState([]); const [result, setResult] = useState(null); const [importProgress, setImportProgress] = useState(0); const [previewPage, setPreviewPage] = useState(1); const activeCampaigns = useMemo(() => campaigns.filter(c => c.campaignStatus === 'ACTIVE' || c.campaignStatus === 'PAUSED'), [campaigns], ); const handleClose = () => { onOpenChange(false); setTimeout(() => { setStep('select-campaign'); setSelectedCampaign(null); setCsvRows([]); setMapping([]); setResult(null); setImportProgress(0); setPreviewPage(1); }, 300); }; 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 { rows, headers } = parseCSV(text); 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 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 totalPreviewPages = Math.max(1, Math.ceil(rowsWithMatch.length / PAGE_SIZE)); 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(); }; // Select dropdown items for mapping const mappingOptions = [ { id: '__skip__', label: '— Skip —' }, ...LEAD_FIELDS.map(f => ({ id: f.field, label: f.label })), ]; return ( { if (!open) handleClose(); }}> {() => (
{/* Header */}

Import Leads

{step === 'select-campaign' && 'Select a campaign to import leads into'} {step === 'map-columns' && 'Upload CSV and map columns to lead fields'} {step === 'preview' && `Preview: ${selectedCampaign?.campaignName}`} {step === 'importing' && 'Importing leads...'} {step === 'done' && 'Import complete'}

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

No active campaigns.

) : activeCampaigns.map(campaign => ( ))}
)} {/* Step 2: Upload + Column Mapping */} {step === 'map-columns' && (
{csvRows.length === 0 ? ( ) : ( <>
{csvRows.length} rows detected — map columns to lead fields: {!phoneIsMapped && (
Phone column required
)}
{mapping.map(m => (
{m.csvHeader} CSV column
))}
)}
)} {/* Step 3: Preview Table */} {step === 'preview' && (
{/* Summary bar */}
{rowsWithMatch.length} rows {validCount} ready {patientMatchCount > 0 && {patientMatchCount} existing patients} {duplicateCount > 0 && {duplicateCount} duplicates} {noPhoneCount > 0 && {noPhoneCount} no phone}
{/* Table — fills remaining space, header pinned, body scrolls */}
columns={[ ...mapping.filter(m => m.leadField).map(m => ({ id: m.csvHeader, label: LEAD_FIELDS.find(f => f.field === m.leadField)?.label ?? m.csvHeader, }) as DynamicColumn), { id: '__match__', label: 'Patient Match' }, ]} rows={rowsWithMatch.slice((previewPage - 1) * PAGE_SIZE, previewPage * PAGE_SIZE).map((item, i) => ({ id: `row-${i}`, ...item }))} renderCell={(row, columnId) => { if (columnId === '__match__') { if (row.matchedPatient) return {row.matchedPatient.fullName?.firstName ?? 'Patient'}; if (row.isDuplicate) return Duplicate; if (!row.hasPhone) return No phone; return New; } return {row.row?.[columnId] ?? ''}; }} rowClassName={(row) => cx( row.isDuplicate && 'bg-warning-primary opacity-60', !row.hasPhone && 'bg-error-primary opacity-40', )} />
{/* Pagination — pinned at bottom */} {totalPreviewPages > 1 && (
Page {previewPage} of {totalPreviewPages}
)}
)} {/* Step 4a: Importing */} {step === 'importing' && (

Importing leads...

{importProgress} of {rowsWithMatch.length}

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

Import Complete

{result.created}

Created

{result.linkedToPatient}

Linked

{result.skippedDuplicate > 0 && (

{result.skippedDuplicate}

Duplicates

)} {result.failed > 0 && (

{result.failed}

Failed

)}
)}
{/* Footer */}
{step === 'select-campaign' && ( )} {step === 'map-columns' && ( <> {csvRows.length > 0 && ( )} )} {step === 'preview' && ( <> )} {step === 'done' && ( )}
)}
); };