From 64309d506b271e75faa4dd3c996ec6f39858f13e Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Tue, 31 Mar 2026 14:07:38 +0530 Subject: [PATCH] =?UTF-8?q?feat:=204-step=20import=20wizard=20=E2=80=94=20?= =?UTF-8?q?separate=20mapping=20step=20with=20Untitled=20UI=20Select?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Step 1: Campaign cards - Step 2: Upload CSV + column mapping grid with Untitled UI Select dropdowns - Step 3: Preview with DynamicTable, scrollable body, pagination - Step 4: Import progress + results - Fixed modal height, no jitter between steps - FontAwesome arrow icon for mapping visual Co-Authored-By: Claude Opus 4.6 (1M context) --- .../campaigns/lead-import-wizard.tsx | 336 ++++++++++-------- 1 file changed, 183 insertions(+), 153 deletions(-) diff --git a/src/components/campaigns/lead-import-wizard.tsx b/src/components/campaigns/lead-import-wizard.tsx index 08efaf1..36c6b07 100644 --- a/src/components/campaigns/lead-import-wizard.tsx +++ b/src/components/campaigns/lead-import-wizard.tsx @@ -1,9 +1,10 @@ import { useState, useMemo } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faFileImport, faCheck, faSpinner, faTriangleExclamation, faCloudArrowUp } from '@fortawesome/pro-duotone-svg-icons'; +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'; @@ -19,16 +20,19 @@ const FileImportIcon: FC<{ className?: string }> = ({ className }) => ( ); -type ImportStep = 'select-campaign' | 'upload-preview' | 'importing' | 'done'; +type ImportStep = 'select-campaign' | 'map-columns' | 'preview' | 'importing' | 'done'; const WIZARD_STEPS = [ { key: 'select-campaign', label: 'Select Campaign', number: 1 }, - { key: 'upload-preview', label: 'Upload & Map', number: 2 }, - { key: 'done', label: 'Import', number: 3 }, + { 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' ? 2 : WIZARD_STEPS.findIndex(s => s.key === currentStep); + const activeIndex = currentStep === 'importing' + ? 3 + : WIZARD_STEPS.findIndex(s => s.key === currentStep); return (
@@ -82,6 +86,8 @@ interface LeadImportWizardProps { 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'); @@ -90,6 +96,7 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps 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'), @@ -105,18 +112,13 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps setMapping([]); setResult(null); setImportProgress(0); + setPreviewPage(1); }, 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; @@ -133,6 +135,7 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps )); }; + // Patient matching const rowsWithMatch = useMemo(() => { const phoneMapping = mapping.find(m => m.leadField === 'contactPhone'); if (!phoneMapping || csvRows.length === 0) return []; @@ -140,11 +143,8 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps 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]), + patients.filter(p => p.phones?.primaryPhoneNumber).map(p => [normalizePhone(p.phones!.primaryPhoneNumber), p]), ); return csvRows.map(row => { @@ -153,7 +153,6 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps 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]); @@ -163,6 +162,7 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps 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; @@ -172,7 +172,6 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps 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; } @@ -180,17 +179,10 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps if (!payload) { importResult.skippedNoPhone++; setImportProgress(i + 1); continue; } try { - await apiClient.graphql( - `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, - { data: payload }, - { silent: true }, - ); + await apiClient.graphql(`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, { data: payload }, { silent: true }); importResult.created++; if (matchedPatient) importResult.linkedToPatient++; - } catch { - importResult.failed++; - } - + } catch { importResult.failed++; } setImportProgress(i + 1); } @@ -199,6 +191,12 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps 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(); }}> @@ -213,7 +211,8 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps

Import Leads

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

@@ -222,21 +221,21 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps
- {/* Step indicator */} {/* Content */} -
+
+ {/* Step 1: Campaign Cards */} {step === 'select-campaign' && ( -
- {activeCampaigns.length === 0 ? ( -

No active campaigns. Create a campaign first.

- ) : ( - activeCampaigns.map(campaign => ( +
+
+ {activeCampaigns.length === 0 ? ( +

No active campaigns.

+ ) : activeCampaigns.map(campaign => ( - )) - )} + ))} +
)} - {/* Step 2: Upload + Preview */} - {step === 'upload-preview' && ( -
+ {/* Step 2: Upload + Column Mapping */} + {step === 'map-columns' && ( +
{csvRows.length === 0 ? ( -