mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
- Extended MaintAction with needsPreStep + clientSideHandler - MaintOtpModal supports pre-step content before OTP (campaign selection) - Removed standalone ClearCampaignLeadsModal — all maint actions go through one modal - 4-step import wizard with Untitled UI Select for mapping - DynamicTable className passthrough Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
452 lines
29 KiB
TypeScript
452 lines
29 KiB
TypeScript
import { useState, useMemo } from 'react';
|
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
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';
|
|
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' | 'map-columns' | 'preview' | 'importing' | 'done';
|
|
|
|
const WIZARD_STEPS = [
|
|
{ key: 'select-campaign', label: 'Select Campaign', number: 1 },
|
|
{ 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'
|
|
? 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">
|
|
{WIZARD_STEPS.map((step, i) => {
|
|
const isComplete = i < activeIndex;
|
|
const isActive = i === activeIndex;
|
|
const isLast = i === WIZARD_STEPS.length - 1;
|
|
|
|
return (
|
|
<div key={step.key} className="flex items-center">
|
|
<div className="flex items-center gap-2">
|
|
<div className={cx(
|
|
'flex size-7 items-center justify-center rounded-full text-xs font-semibold transition duration-100 ease-linear',
|
|
isComplete ? 'bg-brand-solid text-white' :
|
|
isActive ? 'bg-brand-solid text-white ring-4 ring-brand-100' :
|
|
'bg-secondary text-quaternary',
|
|
)}>
|
|
{isComplete ? <FontAwesomeIcon icon={faCheck} className="size-3" /> : step.number}
|
|
</div>
|
|
<span className={cx(
|
|
'text-xs font-medium whitespace-nowrap',
|
|
isActive ? 'text-brand-secondary' : isComplete ? 'text-primary' : 'text-quaternary',
|
|
)}>
|
|
{step.label}
|
|
</span>
|
|
</div>
|
|
{!isLast && (
|
|
<div className={cx(
|
|
'mx-3 h-px w-12',
|
|
i < activeIndex ? 'bg-brand-solid' : 'bg-secondary',
|
|
)} />
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
type ImportResult = {
|
|
created: number;
|
|
linkedToPatient: number;
|
|
skippedDuplicate: number;
|
|
skippedNoPhone: number;
|
|
failed: number;
|
|
total: number;
|
|
};
|
|
|
|
interface LeadImportWizardProps {
|
|
isOpen: boolean;
|
|
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');
|
|
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 [previewPage, setPreviewPage] = useState(1);
|
|
|
|
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);
|
|
setPreviewPage(1);
|
|
}, 300);
|
|
};
|
|
|
|
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,
|
|
));
|
|
};
|
|
|
|
// Patient matching
|
|
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 totalPreviewPages = Math.max(1, Math.ceil(rowsWithMatch.length / PAGE_SIZE));
|
|
|
|
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();
|
|
};
|
|
|
|
// 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">
|
|
<Dialog>
|
|
{() => (
|
|
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden" style={{ height: '80vh', minHeight: '500px' }}>
|
|
{/* 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 === 'map-columns' && 'Upload CSV and map columns to lead fields'}
|
|
{step === 'preview' && `Preview: ${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>
|
|
|
|
<StepIndicator currentStep={step} />
|
|
|
|
{/* Content */}
|
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
|
|
|
{/* Step 1: Campaign Cards */}
|
|
{step === 'select-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={() => { 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',
|
|
)}
|
|
>
|
|
<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">{leads.filter(l => l.campaignId === campaign.id).length} leads</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 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-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>
|
|
) : (
|
|
<>
|
|
<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: Preview Table */}
|
|
{step === 'preview' && (
|
|
<div className="flex flex-1 flex-col min-h-0">
|
|
{/* 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, header pinned, body scrolls */}
|
|
<div className="flex flex-1 flex-col min-h-0 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 4b: Done */}
|
|
{step === 'done' && result && (
|
|
<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>
|
|
<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>
|
|
)}
|
|
</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 === 'map-columns' && (
|
|
<>
|
|
<Button size="sm" color="secondary" onClick={() => { setStep('select-campaign'); setCsvRows([]); setMapping([]); }}>Back</Button>
|
|
{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>
|
|
</>
|
|
)}
|
|
{step === 'done' && (
|
|
<Button size="sm" color="primary" onClick={handleClose} className="ml-auto">Done</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Dialog>
|
|
</Modal>
|
|
</ModalOverlay>
|
|
);
|
|
};
|