mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 10:23:27 +00:00
feat: lead import wizard with campaign selection, CSV preview, and patient matching
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
369
src/components/campaigns/lead-import-wizard.tsx
Normal file
369
src/components/campaigns/lead-import-wizard.tsx
Normal file
@@ -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 }) => (
|
||||||
|
<FontAwesomeIcon icon={faFileImport} className={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<ImportStep>('select-campaign');
|
||||||
|
const [selectedCampaign, setSelectedCampaign] = useState<Campaign | null>(null);
|
||||||
|
const [csvRows, setCsvRows] = useState<CSVRow[]>([]);
|
||||||
|
const [mapping, setMapping] = useState<LeadFieldMapping[]>([]);
|
||||||
|
const [result, setResult] = useState<ImportResult | null>(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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<ModalOverlay isOpen={isOpen} isDismissable onOpenChange={(open) => { if (!open) handleClose(); }}>
|
||||||
|
<Modal className="sm:max-w-3xl">
|
||||||
|
<Dialog>
|
||||||
|
{() => (
|
||||||
|
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden max-h-[85vh]">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-secondary shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FeaturedIcon icon={FileImportIcon} color="brand" theme="light" size="sm" />
|
||||||
|
<div>
|
||||||
|
<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 === 'importing' && 'Importing leads...'}
|
||||||
|
{step === 'done' && 'Import complete'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleClose} className="text-fg-quaternary hover:text-fg-secondary text-lg">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">
|
||||||
|
{/* 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 => (
|
||||||
|
<button
|
||||||
|
key={campaign.id}
|
||||||
|
onClick={() => handleCampaignSelect(campaign)}
|
||||||
|
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',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<span className="mt-2 text-xs text-tertiary">{campaign.leadCount ?? 0} leads</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Upload + Preview */}
|
||||||
|
{step === 'upload-preview' && (
|
||||||
|
<div className="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" />
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-secondary">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-secondary">
|
||||||
|
{mapping.map(m => (
|
||||||
|
<th key={m.csvHeader} className="px-3 py-2 text-left font-normal">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-[10px] text-quaternary uppercase">{m.csvHeader}</span>
|
||||||
|
<select
|
||||||
|
value={m.leadField ?? ''}
|
||||||
|
onChange={e => handleMappingChange(m.csvHeader, e.target.value || null)}
|
||||||
|
className="w-full rounded border border-secondary bg-primary px-2 py-1 text-xs text-primary"
|
||||||
|
>
|
||||||
|
<option value="">Skip</option>
|
||||||
|
{LEAD_FIELDS.map(f => (
|
||||||
|
<option key={f.field} value={f.field}>{f.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th className="px-3 py-2 text-left font-normal">
|
||||||
|
<span className="text-[10px] text-quaternary uppercase">Patient Match</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rowsWithMatch.slice(0, 20).map((item, i) => (
|
||||||
|
<tr key={i} className={cx(
|
||||||
|
'border-t border-secondary',
|
||||||
|
item.isDuplicate && 'bg-warning-primary opacity-60',
|
||||||
|
!item.hasPhone && 'bg-error-primary opacity-40',
|
||||||
|
)}>
|
||||||
|
{mapping.map(m => (
|
||||||
|
<td key={m.csvHeader} className="px-3 py-2 text-tertiary truncate max-w-[150px]">
|
||||||
|
{item.row[m.csvHeader] ?? ''}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{item.matchedPatient ? (
|
||||||
|
<Badge size="sm" color="success" type="pill-color">
|
||||||
|
{item.matchedPatient.fullName?.firstName ?? 'Patient'}
|
||||||
|
</Badge>
|
||||||
|
) : item.isDuplicate ? (
|
||||||
|
<Badge size="sm" color="warning" type="pill-color">Duplicate</Badge>
|
||||||
|
) : !item.hasPhone ? (
|
||||||
|
<Badge size="sm" color="error" type="pill-color">No phone</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge size="sm" color="gray" type="pill-color">New</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{csvRows.length > 20 && (
|
||||||
|
<div className="bg-secondary px-3 py-2 text-center text-xs text-tertiary">
|
||||||
|
Showing 20 of {csvRows.length} rows
|
||||||
|
</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}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 4: 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>
|
||||||
|
)}
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-t border-secondary shrink-0">
|
||||||
|
{step === 'select-campaign' && (
|
||||||
|
<Button size="sm" color="secondary" onClick={handleClose}>Cancel</Button>
|
||||||
|
)}
|
||||||
|
{step === 'upload-preview' && (
|
||||||
|
<>
|
||||||
|
<Button size="sm" color="secondary" onClick={() => { setStep('select-campaign'); setCsvRows([]); setMapping([]); }}>Back</Button>
|
||||||
|
<Button size="sm" color="primary" onClick={handleImport} isDisabled={!phoneIsMapped || validCount === 0}>
|
||||||
|
Import {validCount} Lead{validCount !== 1 ? 's' : ''}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{step === 'done' && (
|
||||||
|
<Button size="sm" color="primary" onClick={handleClose} className="ml-auto">Done</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
</Modal>
|
||||||
|
</ModalOverlay>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user