mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 10:23:27 +00:00
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:
@@ -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 }) => (
|
||||
<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 = [
|
||||
{ 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 (
|
||||
<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;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps) => {
|
||||
const { campaigns, leads, patients, refresh } = useData();
|
||||
const [step, setStep] = useState<ImportStep>('select-campaign');
|
||||
@@ -90,6 +96,7 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps
|
||||
const [mapping, setMapping] = useState<LeadFieldMapping[]>([]);
|
||||
const [result, setResult] = useState<ImportResult | null>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<ModalOverlay isOpen={isOpen} isDismissable onOpenChange={(open) => { if (!open) handleClose(); }}>
|
||||
<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>
|
||||
<p className="text-xs text-tertiary">
|
||||
{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'}
|
||||
</p>
|
||||
@@ -222,21 +221,21 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps
|
||||
<button onClick={handleClose} className="text-fg-quaternary hover:text-fg-secondary text-lg">×</button>
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<StepIndicator currentStep={step} />
|
||||
|
||||
{/* 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 === '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 => (
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<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.</p>
|
||||
) : activeCampaigns.map(campaign => (
|
||||
<button
|
||||
key={campaign.id}
|
||||
onClick={() => handleCampaignSelect(campaign)}
|
||||
onClick={() => { setSelectedCampaign(campaign); setStep('map-columns'); }}
|
||||
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',
|
||||
@@ -245,151 +244,172 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps
|
||||
<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>
|
||||
<Badge size="sm" color={campaign.campaignStatus === 'ACTIVE' ? 'success' : 'gray'} type="pill-color">{campaign.campaignStatus}</Badge>
|
||||
</div>
|
||||
<span className="mt-2 text-xs text-tertiary">{leads.filter(l => l.campaignId === campaign.id).length} leads</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Upload + Preview */}
|
||||
{step === 'upload-preview' && (
|
||||
<div className="space-y-4">
|
||||
{/* Step 2: Upload + Column Mapping */}
|
||||
{step === 'map-columns' && (
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
{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" />
|
||||
<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-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-xs text-tertiary mt-1">CSV files only, max 5000 rows</span>
|
||||
<input type="file" accept=".csv" onChange={handleFileUpload} className="hidden" />
|
||||
</label>
|
||||
) : (
|
||||
<>
|
||||
{!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>
|
||||
)}
|
||||
|
||||
<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 className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-primary">{csvRows.length} rows detected — map columns to lead fields:</span>
|
||||
{!phoneIsMapped && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-error-primary">
|
||||
<FontAwesomeIcon icon={faTriangleExclamation} className="size-3" />
|
||||
Phone column required
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 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}%` }}
|
||||
{/* Step 3: Preview Table */}
|
||||
{step === 'preview' && (
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||
{/* Summary bar */}
|
||||
<div className="flex shrink-0 items-center gap-4 px-6 py-2 border-b border-secondary text-xs text-tertiary">
|
||||
<span>{rowsWithMatch.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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Step 4: Done */}
|
||||
{/* Step 4b: 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 className="flex flex-1 items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<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>
|
||||
)}
|
||||
{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 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</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>
|
||||
)}
|
||||
@@ -400,10 +420,20 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps
|
||||
{step === 'select-campaign' && (
|
||||
<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="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' : ''}
|
||||
</Button>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user