feat: 4-step import wizard — separate mapping step with Untitled UI Select

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 14:07:38 +05:30
parent fdbce42213
commit 64309d506b

View File

@@ -1,9 +1,10 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 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 { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
import { Button } from '@/components/base/buttons/button'; import { Button } from '@/components/base/buttons/button';
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { Select } from '@/components/base/select/select';
import { DynamicTable } from '@/components/application/table/dynamic-table'; import { DynamicTable } from '@/components/application/table/dynamic-table';
import type { DynamicColumn, DynamicRow } 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 { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
@@ -19,16 +20,19 @@ const FileImportIcon: FC<{ className?: string }> = ({ className }) => (
<FontAwesomeIcon icon={faFileImport} className={className} /> <FontAwesomeIcon icon={faFileImport} className={className} />
); );
type ImportStep = 'select-campaign' | 'upload-preview' | 'importing' | 'done'; type ImportStep = 'select-campaign' | 'map-columns' | 'preview' | 'importing' | 'done';
const WIZARD_STEPS = [ const WIZARD_STEPS = [
{ key: 'select-campaign', label: 'Select Campaign', number: 1 }, { key: 'select-campaign', label: 'Select Campaign', number: 1 },
{ key: 'upload-preview', label: 'Upload & Map', number: 2 }, { key: 'map-columns', label: 'Upload & Map', number: 2 },
{ key: 'done', label: 'Import', number: 3 }, { key: 'preview', label: 'Preview', number: 3 },
{ key: 'done', label: 'Import', number: 4 },
] as const; ] as const;
const StepIndicator = ({ currentStep }: { currentStep: ImportStep }) => { 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 ( return (
<div className="flex items-center justify-center gap-0 px-6 py-3 border-b border-secondary shrink-0"> <div className="flex items-center justify-center gap-0 px-6 py-3 border-b border-secondary shrink-0">
@@ -82,6 +86,8 @@ interface LeadImportWizardProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
const PAGE_SIZE = 15;
export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps) => { export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps) => {
const { campaigns, leads, patients, refresh } = useData(); const { campaigns, leads, patients, refresh } = useData();
const [step, setStep] = useState<ImportStep>('select-campaign'); const [step, setStep] = useState<ImportStep>('select-campaign');
@@ -90,6 +96,7 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps
const [mapping, setMapping] = useState<LeadFieldMapping[]>([]); const [mapping, setMapping] = useState<LeadFieldMapping[]>([]);
const [result, setResult] = useState<ImportResult | null>(null); const [result, setResult] = useState<ImportResult | null>(null);
const [importProgress, setImportProgress] = useState(0); const [importProgress, setImportProgress] = useState(0);
const [previewPage, setPreviewPage] = useState(1);
const activeCampaigns = useMemo(() => const activeCampaigns = useMemo(() =>
campaigns.filter(c => c.campaignStatus === 'ACTIVE' || c.campaignStatus === 'PAUSED'), campaigns.filter(c => c.campaignStatus === 'ACTIVE' || c.campaignStatus === 'PAUSED'),
@@ -105,18 +112,13 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps
setMapping([]); setMapping([]);
setResult(null); setResult(null);
setImportProgress(0); setImportProgress(0);
setPreviewPage(1);
}, 300); }, 300);
}; };
const handleCampaignSelect = (campaign: Campaign) => {
setSelectedCampaign(campaign);
setStep('upload-preview');
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (event) => { reader.onload = (event) => {
const text = event.target?.result as string; const text = event.target?.result as string;
@@ -133,6 +135,7 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps
)); ));
}; };
// Patient matching
const rowsWithMatch = useMemo(() => { const rowsWithMatch = useMemo(() => {
const phoneMapping = mapping.find(m => m.leadField === 'contactPhone'); const phoneMapping = mapping.find(m => m.leadField === 'contactPhone');
if (!phoneMapping || csvRows.length === 0) return []; if (!phoneMapping || csvRows.length === 0) return [];
@@ -140,11 +143,8 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps
const existingLeadPhones = new Set( const existingLeadPhones = new Set(
leads.map(l => normalizePhone(l.contactPhone?.[0]?.number ?? '')).filter(p => p.length === 10), leads.map(l => normalizePhone(l.contactPhone?.[0]?.number ?? '')).filter(p => p.length === 10),
); );
const patientByPhone = new Map( const patientByPhone = new Map(
patients patients.filter(p => p.phones?.primaryPhoneNumber).map(p => [normalizePhone(p.phones!.primaryPhoneNumber), p]),
.filter(p => p.phones?.primaryPhoneNumber)
.map(p => [normalizePhone(p.phones!.primaryPhoneNumber), p]),
); );
return csvRows.map(row => { return csvRows.map(row => {
@@ -153,7 +153,6 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps
const matchedPatient = phone.length === 10 ? patientByPhone.get(phone) : null; const matchedPatient = phone.length === 10 ? patientByPhone.get(phone) : null;
const isDuplicate = phone.length === 10 && existingLeadPhones.has(phone); const isDuplicate = phone.length === 10 && existingLeadPhones.has(phone);
const hasPhone = phone.length === 10; const hasPhone = phone.length === 10;
return { row, phone, matchedPatient, isDuplicate, hasPhone }; return { row, phone, matchedPatient, isDuplicate, hasPhone };
}); });
}, [csvRows, mapping, leads, patients]); }, [csvRows, mapping, leads, patients]);
@@ -163,6 +162,7 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps
const duplicateCount = rowsWithMatch.filter(r => r.isDuplicate).length; const duplicateCount = rowsWithMatch.filter(r => r.isDuplicate).length;
const noPhoneCount = rowsWithMatch.filter(r => !r.hasPhone).length; const noPhoneCount = rowsWithMatch.filter(r => !r.hasPhone).length;
const patientMatchCount = rowsWithMatch.filter(r => r.matchedPatient).length; const patientMatchCount = rowsWithMatch.filter(r => r.matchedPatient).length;
const totalPreviewPages = Math.max(1, Math.ceil(rowsWithMatch.length / PAGE_SIZE));
const handleImport = async () => { const handleImport = async () => {
if (!selectedCampaign) return; if (!selectedCampaign) return;
@@ -172,7 +172,6 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps
for (let i = 0; i < rowsWithMatch.length; i++) { for (let i = 0; i < rowsWithMatch.length; i++) {
const { row, isDuplicate, hasPhone, matchedPatient } = rowsWithMatch[i]; const { row, isDuplicate, hasPhone, matchedPatient } = rowsWithMatch[i];
if (!hasPhone) { importResult.skippedNoPhone++; setImportProgress(i + 1); continue; } if (!hasPhone) { importResult.skippedNoPhone++; setImportProgress(i + 1); continue; }
if (isDuplicate) { importResult.skippedDuplicate++; 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; } if (!payload) { importResult.skippedNoPhone++; setImportProgress(i + 1); continue; }
try { try {
await apiClient.graphql( await apiClient.graphql(`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, { data: payload }, { silent: true });
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
{ data: payload },
{ silent: true },
);
importResult.created++; importResult.created++;
if (matchedPatient) importResult.linkedToPatient++; if (matchedPatient) importResult.linkedToPatient++;
} catch { } catch { importResult.failed++; }
importResult.failed++;
}
setImportProgress(i + 1); setImportProgress(i + 1);
} }
@@ -199,6 +191,12 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps
refresh(); refresh();
}; };
// Select dropdown items for mapping
const mappingOptions = [
{ id: '__skip__', label: '— Skip —' },
...LEAD_FIELDS.map(f => ({ id: f.field, label: f.label })),
];
return ( return (
<ModalOverlay isOpen={isOpen} isDismissable onOpenChange={(open) => { if (!open) handleClose(); }}> <ModalOverlay isOpen={isOpen} isDismissable onOpenChange={(open) => { if (!open) handleClose(); }}>
<Modal className="sm:max-w-5xl"> <Modal className="sm:max-w-5xl">
@@ -213,7 +211,8 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps
<h2 className="text-lg font-semibold text-primary">Import Leads</h2> <h2 className="text-lg font-semibold text-primary">Import Leads</h2>
<p className="text-xs text-tertiary"> <p className="text-xs text-tertiary">
{step === 'select-campaign' && 'Select a campaign to import leads into'} {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 === 'importing' && 'Importing leads...'}
{step === 'done' && 'Import complete'} {step === 'done' && 'Import complete'}
</p> </p>
@@ -222,21 +221,21 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps
<button onClick={handleClose} className="text-fg-quaternary hover:text-fg-secondary text-lg">&times;</button> <button onClick={handleClose} className="text-fg-quaternary hover:text-fg-secondary text-lg">&times;</button>
</div> </div>
{/* Step indicator */}
<StepIndicator currentStep={step} /> <StepIndicator currentStep={step} />
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0"> <div className="flex flex-1 flex-col min-h-0 overflow-hidden">
{/* Step 1: Campaign Cards */} {/* Step 1: Campaign Cards */}
{step === 'select-campaign' && ( {step === 'select-campaign' && (
<div className="grid grid-cols-2 gap-3"> <div className="flex-1 overflow-y-auto px-6 py-4">
{activeCampaigns.length === 0 ? ( <div className="grid grid-cols-2 gap-3">
<p className="col-span-2 py-12 text-center text-sm text-tertiary">No active campaigns. Create a campaign first.</p> {activeCampaigns.length === 0 ? (
) : ( <p className="col-span-2 py-12 text-center text-sm text-tertiary">No active campaigns.</p>
activeCampaigns.map(campaign => ( ) : activeCampaigns.map(campaign => (
<button <button
key={campaign.id} key={campaign.id}
onClick={() => handleCampaignSelect(campaign)} onClick={() => { setSelectedCampaign(campaign); setStep('map-columns'); }}
className={cx( className={cx(
'flex flex-col items-start rounded-xl border-2 p-4 text-left transition duration-100 ease-linear hover:border-brand', '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', selectedCampaign?.id === campaign.id ? 'border-brand bg-brand-primary' : 'border-secondary',
@@ -245,151 +244,172 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps
<span className="text-sm font-semibold text-primary">{campaign.campaignName ?? 'Untitled'}</span> <span className="text-sm font-semibold text-primary">{campaign.campaignName ?? 'Untitled'}</span>
<div className="mt-1 flex items-center gap-2"> <div className="mt-1 flex items-center gap-2">
{campaign.platform && <Badge size="sm" color="brand" type="pill-color">{campaign.platform}</Badge>} {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"> <Badge size="sm" color={campaign.campaignStatus === 'ACTIVE' ? 'success' : 'gray'} type="pill-color">{campaign.campaignStatus}</Badge>
{campaign.campaignStatus}
</Badge>
</div> </div>
<span className="mt-2 text-xs text-tertiary">{leads.filter(l => l.campaignId === campaign.id).length} leads</span> <span className="mt-2 text-xs text-tertiary">{leads.filter(l => l.campaignId === campaign.id).length} leads</span>
</button> </button>
)) ))}
)} </div>
</div> </div>
)} )}
{/* Step 2: Upload + Preview */} {/* Step 2: Upload + Column Mapping */}
{step === 'upload-preview' && ( {step === 'map-columns' && (
<div className="space-y-4"> <div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{csvRows.length === 0 ? ( {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"> <label className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-secondary py-16 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" /> <FontAwesomeIcon icon={faCloudArrowUp} className="size-10 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-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> <span className="text-xs text-tertiary mt-1">CSV files only, max 5000 rows</span>
<input type="file" accept=".csv" onChange={handleFileUpload} className="hidden" /> <input type="file" accept=".csv" onChange={handleFileUpload} className="hidden" />
</label> </label>
) : ( ) : (
<> <>
{!phoneIsMapped && ( <div className="flex items-center justify-between">
<div className="flex items-center gap-2 rounded-lg bg-error-primary px-4 py-3"> <span className="text-sm font-medium text-primary">{csvRows.length} rows detected map columns to lead fields:</span>
<FontAwesomeIcon icon={faTriangleExclamation} className="size-4 text-fg-error-primary" /> {!phoneIsMapped && (
<span className="text-sm font-medium text-error-primary">Phone column must be mapped to proceed</span> <div className="flex items-center gap-1.5 text-xs text-error-primary">
</div> <FontAwesomeIcon icon={faTriangleExclamation} className="size-3" />
)} Phone column required
<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>
{/* Mapping row — separate from table */}
<div className="flex items-end gap-0 rounded-t-lg border border-b-0 border-secondary bg-secondary px-1 py-2 overflow-x-auto">
{mapping.map(m => (
<div key={m.csvHeader} className="flex-1 min-w-[120px] px-1">
<span className="text-[10px] text-quaternary uppercase block mb-1 px-1">{m.csvHeader}</span>
<select
value={m.leadField ?? ''}
onChange={e => handleMappingChange(m.csvHeader, e.target.value || null)}
className={cx(
'w-full rounded-lg border px-2.5 py-1.5 text-xs font-medium transition duration-100 ease-linear appearance-none bg-no-repeat bg-[right_8px_center] bg-[length:12px] cursor-pointer',
m.leadField
? 'border-brand bg-brand-primary text-brand-secondary'
: 'border-secondary bg-primary text-quaternary',
)}
style={{ backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239CA3AF' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E")` }}
>
<option value=""> Skip </option>
{LEAD_FIELDS.map(f => (
<option key={f.field} value={f.field}>{f.label}</option>
))}
</select>
</div>
))}
<div className="flex-1 min-w-[100px] px-1">
<span className="text-[10px] text-quaternary uppercase block mb-1 px-1">Match</span>
<div className="px-2.5 py-1.5 text-xs font-medium text-quaternary">Auto</div>
</div>
</div>
{/* Data table */}
<div className="overflow-hidden rounded-b-lg ring-1 ring-secondary">
<DynamicTable<DynamicRow>
columns={[
...mapping.map(m => ({
id: m.csvHeader,
label: m.leadField ? LEAD_FIELDS.find(f => f.field === m.leadField)?.label ?? m.csvHeader : m.csvHeader,
}) as DynamicColumn),
{ id: '__match__', label: 'Patient Match' },
]}
rows={rowsWithMatch.map((item, i) => ({ id: `row-${i}`, ...item }))}
maxRows={20}
renderCell={(row, columnId) => {
if (columnId === '__match__') {
if (row.matchedPatient) return <Badge size="sm" color="success" type="pill-color">{row.matchedPatient.fullName?.firstName ?? 'Patient'}</Badge>;
if (row.isDuplicate) return <Badge size="sm" color="warning" type="pill-color">Duplicate</Badge>;
if (!row.hasPhone) return <Badge size="sm" color="error" type="pill-color">No phone</Badge>;
return <Badge size="sm" color="gray" type="pill-color">New</Badge>;
}
return <span className="text-tertiary truncate block max-w-[150px]">{row.row?.[columnId] ?? ''}</span>;
}}
rowClassName={(row) => cx(
row.isDuplicate && 'bg-warning-primary opacity-60',
!row.hasPhone && 'bg-error-primary opacity-40',
)}
/>
{csvRows.length > 20 && (
<div className="bg-secondary px-3 py-2 text-center text-xs text-tertiary border-t border-secondary">
Showing 20 of {csvRows.length} rows
</div> </div>
)} )}
</div> </div>
<div className="grid grid-cols-2 gap-3">
{mapping.map(m => (
<div key={m.csvHeader} className="flex items-center gap-3 rounded-lg border border-secondary p-3">
<div className="min-w-0 flex-1">
<span className="text-xs font-semibold text-primary block">{m.csvHeader}</span>
<span className="text-[10px] text-quaternary">CSV column</span>
</div>
<FontAwesomeIcon icon={faArrowRight} className="size-3 text-fg-quaternary shrink-0" />
<div className="w-44 shrink-0">
<Select
size="sm"
placeholder="Skip"
items={mappingOptions}
selectedKey={m.leadField ?? '__skip__'}
onSelectionChange={(key) => handleMappingChange(m.csvHeader, key === '__skip__' ? null : String(key))}
>
{(item) => <Select.Item id={item.id}>{item.label}</Select.Item>}
</Select>
</div>
</div>
))}
</div>
</> </>
)} )}
</div> </div>
)} )}
{/* Step 3: Importing */} {/* Step 3: Preview Table */}
{step === 'importing' && ( {step === 'preview' && (
<div className="flex flex-col items-center justify-center py-12"> <div className="flex flex-1 flex-col min-h-0 overflow-hidden">
<FontAwesomeIcon icon={faSpinner} className="size-8 animate-spin text-brand-secondary mb-4" /> {/* Summary bar */}
<p className="text-sm font-semibold text-primary">Importing leads...</p> <div className="flex shrink-0 items-center gap-4 px-6 py-2 border-b border-secondary text-xs text-tertiary">
<p className="text-xs text-tertiary mt-1">{importProgress} of {rowsWithMatch.length}</p> <span>{rowsWithMatch.length} rows</span>
<div className="mt-4 w-64 h-2 rounded-full bg-secondary overflow-hidden"> <span className="text-success-primary">{validCount} ready</span>
<div {patientMatchCount > 0 && <span className="text-brand-secondary">{patientMatchCount} existing patients</span>}
className="h-full rounded-full bg-brand-solid transition-all duration-200" {duplicateCount > 0 && <span className="text-warning-primary">{duplicateCount} duplicates</span>}
style={{ width: `${(importProgress / rowsWithMatch.length) * 100}%` }} {noPhoneCount > 0 && <span className="text-error-primary">{noPhoneCount} no phone</span>}
</div>
{/* Table — fills remaining space, scrolls internally */}
<div className="flex-1 min-h-0 overflow-hidden px-4 pt-2">
<DynamicTable<DynamicRow>
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 <Badge size="sm" color="success" type="pill-color">{row.matchedPatient.fullName?.firstName ?? 'Patient'}</Badge>;
if (row.isDuplicate) return <Badge size="sm" color="warning" type="pill-color">Duplicate</Badge>;
if (!row.hasPhone) return <Badge size="sm" color="error" type="pill-color">No phone</Badge>;
return <Badge size="sm" color="gray" type="pill-color">New</Badge>;
}
return <span className="text-tertiary truncate block max-w-[200px]">{row.row?.[columnId] ?? ''}</span>;
}}
rowClassName={(row) => cx(
row.isDuplicate && 'bg-warning-primary opacity-60',
!row.hasPhone && 'bg-error-primary opacity-40',
)}
/> />
</div> </div>
{/* Pagination — pinned at bottom */}
{totalPreviewPages > 1 && (
<div className="flex shrink-0 items-center justify-between border-t border-secondary px-6 py-2">
<span className="text-xs text-tertiary">
Page {previewPage} of {totalPreviewPages}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setPreviewPage(Math.max(1, previewPage - 1))}
disabled={previewPage === 1}
className="px-2 py-1 text-xs font-medium text-secondary rounded-md hover:bg-primary_hover disabled:text-disabled disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => setPreviewPage(Math.min(totalPreviewPages, previewPage + 1))}
disabled={previewPage === totalPreviewPages}
className="px-2 py-1 text-xs font-medium text-secondary rounded-md hover:bg-primary_hover disabled:text-disabled disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
)}
</div>
)}
{/* Step 4a: Importing */}
{step === 'importing' && (
<div className="flex flex-1 items-center justify-center">
<div className="flex flex-col items-center">
<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>
</div> </div>
)} )}
{/* Step 4: Done */} {/* Step 4b: Done */}
{step === 'done' && result && ( {step === 'done' && result && (
<div className="flex flex-col items-center justify-center py-12"> <div className="flex flex-1 items-center justify-center">
<FeaturedIcon icon={({ className }) => <FontAwesomeIcon icon={faCheck} className={className} />} color="success" theme="light" size="lg" /> <div className="flex flex-col items-center">
<p className="text-lg font-semibold text-primary mt-4">Import Complete</p> <FeaturedIcon icon={({ className }) => <FontAwesomeIcon icon={faCheck} className={className} />} color="success" theme="light" size="lg" />
<div className="mt-4 grid grid-cols-2 gap-3 w-64 text-center"> <p className="text-lg font-semibold text-primary mt-4">Import Complete</p>
<div className="rounded-lg bg-success-primary p-3"> <div className="mt-4 grid grid-cols-2 gap-3 w-64 text-center">
<p className="text-xl font-bold text-success-primary">{result.created}</p> <div className="rounded-lg bg-success-primary p-3">
<p className="text-xs text-tertiary">Created</p> <p className="text-xl font-bold text-success-primary">{result.created}</p>
</div> <p className="text-xs text-tertiary">Created</p>
<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> </div>
)} <div className="rounded-lg bg-brand-primary p-3">
{result.failed > 0 && ( <p className="text-xl font-bold text-brand-secondary">{result.linkedToPatient}</p>
<div className="rounded-lg bg-error-primary p-3"> <p className="text-xs text-tertiary">Linked</p>
<p className="text-xl font-bold text-error-primary">{result.failed}</p>
<p className="text-xs text-tertiary">Failed</p>
</div> </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>
</div> </div>
)} )}
@@ -400,10 +420,20 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps
{step === 'select-campaign' && ( {step === 'select-campaign' && (
<Button size="sm" color="secondary" onClick={handleClose}>Cancel</Button> <Button size="sm" color="secondary" onClick={handleClose}>Cancel</Button>
)} )}
{step === 'upload-preview' && ( {step === 'map-columns' && (
<> <>
<Button size="sm" color="secondary" onClick={() => { setStep('select-campaign'); setCsvRows([]); setMapping([]); }}>Back</Button> <Button size="sm" color="secondary" onClick={() => { setStep('select-campaign'); setCsvRows([]); setMapping([]); }}>Back</Button>
<Button size="sm" color="primary" onClick={handleImport} isDisabled={!phoneIsMapped || validCount === 0}> {csvRows.length > 0 && (
<Button size="sm" color="primary" onClick={() => { setPreviewPage(1); setStep('preview'); }} isDisabled={!phoneIsMapped}>
Preview {validCount} Lead{validCount !== 1 ? 's' : ''}
</Button>
)}
</>
)}
{step === 'preview' && (
<>
<Button size="sm" color="secondary" onClick={() => setStep('map-columns')}>Back to Mapping</Button>
<Button size="sm" color="primary" onClick={handleImport} isDisabled={validCount === 0}>
Import {validCount} Lead{validCount !== 1 ? 's' : ''} Import {validCount} Lead{validCount !== 1 ? 's' : ''}
</Button> </Button>
</> </>