diff --git a/src/components/campaigns/lead-import-wizard.tsx b/src/components/campaigns/lead-import-wizard.tsx new file mode 100644 index 0000000..827d144 --- /dev/null +++ b/src/components/campaigns/lead-import-wizard.tsx @@ -0,0 +1,369 @@ +import { useState, useMemo } 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 { 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' | '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 [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); + setTimeout(() => { + setStep('select-campaign'); + setSelectedCampaign(null); + setCsvRows([]); + 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 { 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, + )); + }; + + 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(); + }; + + 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' && ( +
+ {csvRows.length === 0 ? ( + + ) : ( + <> + {!phoneIsMapped && ( +
+ + Phone column must be mapped to proceed +
+ )} + +
+ {csvRows.length} rows + {validCount} ready + {patientMatchCount > 0 && {patientMatchCount} existing patients} + {duplicateCount > 0 && {duplicateCount} duplicates} + {noPhoneCount > 0 && {noPhoneCount} no phone} +
+ +
+ + + + {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' && ( + + )} +
+
+ )} +
+
+
+ ); +};