feat: wizard step indicator, wider dialog, campaigns in admin sidebar, clear leads shortcut

- Import wizard: added step indicator (numbered circles), widened to max-w-5xl
- Admin sidebar: added Marketing → Campaigns nav link
- Clear campaign leads: Ctrl+Shift+C shortcut with campaign picker modal (test-only)
- Test CSV data for all 3 campaigns
- Defect fixing plan + CSV import spec docs
- Session memory update

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 12:45:05 +05:30
parent d9e2bedc1b
commit 7af1ccb713
9 changed files with 963 additions and 2 deletions

View File

@@ -0,0 +1,735 @@
# CSV Lead Import — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Allow supervisors to import leads from CSV into an existing campaign via a modal wizard with column mapping and patient matching.
**Architecture:** Client-side CSV parsing with a 3-step modal wizard (select campaign → upload/map/preview → import). Leads created via existing GraphQL proxy. No new sidecar endpoints needed.
**Tech Stack:** React modal (Untitled UI), native FileReader + string split for CSV parsing, existing DataProvider for patient/lead matching, platform GraphQL mutations for lead creation.
---
## File Map
| File | Action | Responsibility |
|---|---|---|
| `src/lib/csv-utils.ts` | Create | CSV parsing, phone normalization, fuzzy column matching |
| `src/components/campaigns/lead-import-wizard.tsx` | Create | Modal wizard: campaign select → upload/preview → import |
| `src/pages/campaigns.tsx` | Modify | Add "Import Leads" button |
---
### Task 1: CSV Parsing & Column Matching Utility
**Files:**
- Create: `src/lib/csv-utils.ts`
- [ ] **Step 1: Create csv-utils.ts with parseCSV function**
```typescript
// src/lib/csv-utils.ts
export type CSVRow = Record<string, string>;
export type CSVParseResult = {
headers: string[];
rows: CSVRow[];
};
export const parseCSV = (text: string): CSVParseResult => {
const lines = text.split(/\r?\n/).filter(line => line.trim());
if (lines.length === 0) return { headers: [], rows: [] };
const parseLine = (line: string): string[] => {
const result: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
if (inQuotes && line[i + 1] === '"') {
current += '"';
i++;
} else {
inQuotes = !inQuotes;
}
} else if (char === ',' && !inQuotes) {
result.push(current.trim());
current = '';
} else {
current += char;
}
}
result.push(current.trim());
return result;
};
const headers = parseLine(lines[0]);
const rows = lines.slice(1).map(line => {
const values = parseLine(line);
const row: CSVRow = {};
headers.forEach((header, i) => {
row[header] = values[i] ?? '';
});
return row;
});
return { headers, rows };
};
```
- [ ] **Step 2: Add normalizePhone function**
```typescript
export const normalizePhone = (raw: string): string => {
const digits = raw.replace(/\D/g, '');
const stripped = digits.length >= 12 && digits.startsWith('91') ? digits.slice(2) : digits;
return stripped.slice(-10);
};
```
- [ ] **Step 3: Add fuzzy column matching**
```typescript
export type LeadFieldMapping = {
csvHeader: string;
leadField: string | null;
label: string;
};
const LEAD_FIELDS = [
{ field: 'contactName.firstName', label: 'First Name', patterns: ['first name', 'firstname', 'name', 'patient name', 'patient'] },
{ field: 'contactName.lastName', label: 'Last Name', patterns: ['last name', 'lastname', 'surname'] },
{ field: 'contactPhone', label: 'Phone', patterns: ['phone', 'mobile', 'contact number', 'cell', 'phone number', 'mobile number'] },
{ field: 'contactEmail', label: 'Email', patterns: ['email', 'email address', 'mail'] },
{ field: 'interestedService', label: 'Interested Service', patterns: ['service', 'interested in', 'department', 'specialty', 'interest'] },
{ field: 'priority', label: 'Priority', patterns: ['priority', 'urgency'] },
{ field: 'utmSource', label: 'UTM Source', patterns: ['utm_source', 'utmsource', 'source'] },
{ field: 'utmMedium', label: 'UTM Medium', patterns: ['utm_medium', 'utmmedium', 'medium'] },
{ field: 'utmCampaign', label: 'UTM Campaign', patterns: ['utm_campaign', 'utmcampaign'] },
{ field: 'utmTerm', label: 'UTM Term', patterns: ['utm_term', 'utmterm', 'term'] },
{ field: 'utmContent', label: 'UTM Content', patterns: ['utm_content', 'utmcontent'] },
];
export const fuzzyMatchColumns = (csvHeaders: string[]): LeadFieldMapping[] => {
const used = new Set<string>();
return csvHeaders.map(header => {
const normalized = header.toLowerCase().trim().replace(/[^a-z0-9 ]/g, '');
let bestMatch: string | null = null;
for (const field of LEAD_FIELDS) {
if (used.has(field.field)) continue;
if (field.patterns.some(p => normalized === p || normalized.includes(p))) {
bestMatch = field.field;
used.add(field.field);
break;
}
}
return {
csvHeader: header,
leadField: bestMatch,
label: bestMatch ? LEAD_FIELDS.find(f => f.field === bestMatch)!.label : '',
};
});
};
export { LEAD_FIELDS };
```
- [ ] **Step 4: Add buildLeadPayload helper**
```typescript
export const buildLeadPayload = (
row: CSVRow,
mapping: LeadFieldMapping[],
campaignId: string,
patientId: string | null,
platform: string | null,
) => {
const getValue = (field: string): string => {
const entry = mapping.find(m => m.leadField === field);
return entry ? (row[entry.csvHeader] ?? '').trim() : '';
};
const firstName = getValue('contactName.firstName') || 'Unknown';
const lastName = getValue('contactName.lastName');
const phone = normalizePhone(getValue('contactPhone'));
if (!phone || phone.length < 10) return null;
const sourceMap: Record<string, string> = {
FACEBOOK: 'FACEBOOK_AD',
GOOGLE: 'GOOGLE_AD',
INSTAGRAM: 'INSTAGRAM',
MANUAL: 'OTHER',
};
return {
name: `${firstName} ${lastName}`.trim(),
contactName: { firstName, lastName },
contactPhone: { primaryPhoneNumber: `+91${phone}` },
...(getValue('contactEmail') ? { contactEmail: { primaryEmail: getValue('contactEmail') } } : {}),
...(getValue('interestedService') ? { interestedService: getValue('interestedService') } : {}),
...(getValue('utmSource') ? { utmSource: getValue('utmSource') } : {}),
...(getValue('utmMedium') ? { utmMedium: getValue('utmMedium') } : {}),
...(getValue('utmCampaign') ? { utmCampaign: getValue('utmCampaign') } : {}),
...(getValue('utmTerm') ? { utmTerm: getValue('utmTerm') } : {}),
...(getValue('utmContent') ? { utmContent: getValue('utmContent') } : {}),
source: sourceMap[platform ?? ''] ?? 'OTHER',
status: 'NEW',
campaignId,
...(patientId ? { patientId } : {}),
};
};
```
- [ ] **Step 5: Commit**
```bash
git add src/lib/csv-utils.ts
git commit -m "feat: CSV parsing, phone normalization, and fuzzy column matching utility"
```
---
### Task 2: Lead Import Wizard Component
**Files:**
- Create: `src/components/campaigns/lead-import-wizard.tsx`
- [ ] **Step 1: Create wizard component with campaign selection step**
```typescript
// src/components/campaigns/lead-import-wizard.tsx
import { useState, useMemo, useCallback } 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 { Table } from '@/components/application/table/table';
import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
import { Select } from '@/components/base/select/select';
import { useData } from '@/providers/data-provider';
import { apiClient } from '@/lib/api-client';
import { parseCSV, fuzzyMatchColumns, buildLeadPayload, normalizePhone, LEAD_FIELDS, type LeadFieldMapping, type CSVRow } from '@/lib/csv-utils';
import { cx } from '@/utils/cx';
import type { Campaign } from '@/types/entities';
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 [csvHeaders, setCsvHeaders] = useState<string[]>([]);
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);
// Reset state after close animation
setTimeout(() => {
setStep('select-campaign');
setSelectedCampaign(null);
setCsvRows([]);
setCsvHeaders([]);
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 { headers, rows } = parseCSV(text);
setCsvHeaders(headers);
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 for preview
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();
};
// Available lead fields for mapping dropdown (exclude already-mapped ones)
const availableFields = useMemo(() => {
const usedFields = new Set(mapping.filter(m => m.leadField).map(m => m.leadField));
return LEAD_FIELDS.map(f => ({
id: f.field,
name: f.label,
isDisabled: usedFields.has(f.field),
}));
}, [mapping]);
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">&times;</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">
{/* File upload */}
{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>
) : (
<>
{/* Validation banner */}
{!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>
)}
{/* Summary */}
<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>
{/* Column mapping + preview table */}
<div className="overflow-x-auto rounded-lg border border-secondary">
<table className="w-full text-xs">
{/* Mapping row */}
<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>
);
};
```
- [ ] **Step 2: Commit**
```bash
git add src/components/campaigns/lead-import-wizard.tsx
git commit -m "feat: lead import wizard with campaign selection, CSV preview, and patient matching"
```
---
### Task 3: Add Import Button to Campaigns Page
**Files:**
- Modify: `src/pages/campaigns.tsx`
- [ ] **Step 1: Import LeadImportWizard and add state + button**
Add import at top of `campaigns.tsx`:
```typescript
import { LeadImportWizard } from '@/components/campaigns/lead-import-wizard';
```
Add state inside `CampaignsPage` component:
```typescript
const [importOpen, setImportOpen] = useState(false);
```
Add button next to the TopBar or in the header area. Replace the existing `TopBar` line:
```typescript
<TopBar title="Campaigns" subtitle={subtitle} />
```
with:
```typescript
<TopBar title="Campaigns" subtitle={subtitle}>
<Button
size="sm"
color="primary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faFileImport} className={className} />
)}
onClick={() => setImportOpen(true)}
>
Import Leads
</Button>
</TopBar>
```
Add the import for `faFileImport`:
```typescript
import { faPenToSquare, faFileImport } from '@fortawesome/pro-duotone-svg-icons';
```
Add the wizard component before the closing `</div>` of the return, after the CampaignEditSlideout:
```typescript
<LeadImportWizard isOpen={importOpen} onOpenChange={setImportOpen} />
```
- [ ] **Step 2: Check if TopBar accepts children**
Read `src/components/layout/top-bar.tsx` to verify it renders `children`. If not, place the button differently — inside the existing header div in campaigns.tsx.
- [ ] **Step 3: Type check**
Run: `npx tsc --noEmit --pretty`
Expected: Clean (no errors)
- [ ] **Step 4: Build**
Run: `npm run build`
Expected: Build succeeds
- [ ] **Step 5: Commit**
```bash
git add src/pages/campaigns.tsx
git commit -m "feat: add Import Leads button to campaigns page"
```
---
### Task 4: Integration Verification
- [ ] **Step 1: Verify full build**
```bash
npm run build
```
Expected: Build succeeds with no type errors.
- [ ] **Step 2: Manual test checklist**
1. Navigate to Campaigns page as admin
2. "Import Leads" button visible
3. Click → modal opens with campaign cards
4. Select a campaign → proceeds to upload step
5. Upload a test CSV → column mapping appears with fuzzy matches
6. Phone column auto-detected
7. Patient match column shows "Existing" or "New" badges
8. Duplicate leads highlighted
9. Click Import → progress bar → summary
10. Close modal → campaign lead count updated
- [ ] **Step 3: Create a test CSV file for verification**
```csv
First Name,Last Name,Phone,Email,Service,Priority
Ganesh,Bandi,8885540404,ganesh@email.com,Back Pain,HIGH
Meghana,,7702055204,meghana@email.com,Hair Loss,NORMAL
Priya,Sharma,9949879837,,Prenatal Care,NORMAL
New,Patient,9876500001,,General Checkup,LOW
```
- [ ] **Step 4: Final commit with test data**
```bash
git add -A
git commit -m "feat: CSV lead import — complete wizard with campaign selection, mapping, and patient matching"
```
---
## Execution Notes
- The wizard uses the existing `ModalOverlay`/`Modal`/`Dialog` pattern from Untitled UI (same as disposition modal)
- CSV parsing is native (no npm dependency) — handles quoted fields and commas
- Patient matching uses DataProvider data already in memory — no additional API calls for matching
- Lead creation uses existing GraphQL proxy — no new sidecar endpoint
- The `useData().refresh()` call after import updates all DataProvider consumers (campaign lead counts, lead master, etc.)

View File

@@ -19,6 +19,53 @@ const FileImportIcon: FC<{ className?: string }> = ({ className }) => (
type ImportStep = 'select-campaign' | 'upload-preview' | 'importing' | 'done'; type ImportStep = 'select-campaign' | 'upload-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 },
] as const;
const StepIndicator = ({ currentStep }: { currentStep: ImportStep }) => {
const activeIndex = currentStep === 'importing' ? 2 : 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 = { type ImportResult = {
created: number; created: number;
linkedToPatient: number; linkedToPatient: number;
@@ -152,7 +199,7 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps
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-3xl"> <Modal className="sm:max-w-5xl">
<Dialog> <Dialog>
{() => ( {() => (
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden max-h-[85vh]"> <div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden max-h-[85vh]">
@@ -173,6 +220,9 @@ 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} />
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0"> <div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">
{/* Step 1: Campaign Cards */} {/* Step 1: Campaign Cards */}

View File

@@ -7,6 +7,7 @@ import { SipProvider } from '@/providers/sip-provider';
import { useSip } from '@/providers/sip-provider'; import { useSip } from '@/providers/sip-provider';
import { CallWidget } from '@/components/call-desk/call-widget'; import { CallWidget } from '@/components/call-desk/call-widget';
import { MaintOtpModal } from '@/components/modals/maint-otp-modal'; import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
import { ClearCampaignLeadsModal } from '@/components/modals/clear-campaign-leads-modal';
import { AgentStatusToggle } from '@/components/call-desk/agent-status-toggle'; import { AgentStatusToggle } from '@/components/call-desk/agent-status-toggle';
import { NotificationBell } from './notification-bell'; import { NotificationBell } from './notification-bell';
import { useAuth } from '@/providers/auth-provider'; import { useAuth } from '@/providers/auth-provider';
@@ -79,7 +80,8 @@ export const AppShell = ({ children }: AppShellProps) => {
</div> </div>
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />} {isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />}
</div> </div>
<MaintOtpModal isOpen={isOpen} onOpenChange={(open) => !open && close()} action={activeAction} /> <MaintOtpModal isOpen={isOpen && activeAction?.endpoint !== '__client__clear-campaign-leads'} onOpenChange={(open) => !open && close()} action={activeAction} />
<ClearCampaignLeadsModal isOpen={isOpen && activeAction?.endpoint === '__client__clear-campaign-leads'} onOpenChange={(open) => !open && close()} />
</SipProvider> </SipProvider>
); );
}; };

View File

@@ -73,6 +73,9 @@ const getNavSections = (role: string): NavSection[] => {
{ label: 'Call Recordings', href: '/call-recordings', icon: IconFileAudio }, { label: 'Call Recordings', href: '/call-recordings', icon: IconFileAudio },
{ label: 'Missed Calls', href: '/missed-calls', icon: IconPhoneMissed }, { label: 'Missed Calls', href: '/missed-calls', icon: IconPhoneMissed },
]}, ]},
{ label: 'Marketing', items: [
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
]},
{ label: 'Admin', items: [ { label: 'Admin', items: [
{ label: 'Settings', href: '/settings', icon: IconGear }, { label: 'Settings', href: '/settings', icon: IconGear },
]}, ]},

View File

@@ -0,0 +1,122 @@
import { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTrash } 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 { notify } from '@/lib/toast';
import { cx } from '@/utils/cx';
import type { FC } from 'react';
const TrashIcon: FC<{ className?: string }> = ({ className }) => (
<FontAwesomeIcon icon={faTrash} className={className} />
);
interface ClearCampaignLeadsModalProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
}
export const ClearCampaignLeadsModal = ({ isOpen, onOpenChange }: ClearCampaignLeadsModalProps) => {
const { campaigns, leads, refresh } = useData();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [clearing, setClearing] = useState(false);
const leadsPerCampaign = (campaignId: string) =>
leads.filter(l => l.campaignId === campaignId).length;
const handleClear = async () => {
if (!selectedId) return;
const campaignLeads = leads.filter(l => l.campaignId === selectedId);
if (campaignLeads.length === 0) {
notify.info('No Leads', 'No leads to clear for this campaign');
return;
}
setClearing(true);
let deleted = 0;
for (const lead of campaignLeads) {
try {
await apiClient.graphql(
`mutation($id: UUID!) { deleteLead(id: $id) { id } }`,
{ id: lead.id },
{ silent: true },
);
deleted++;
} catch {
// continue
}
}
notify.success('Leads Cleared', `${deleted} leads deleted from campaign`);
setClearing(false);
setSelectedId(null);
onOpenChange(false);
refresh();
};
const handleClose = () => {
if (clearing) return;
onOpenChange(false);
setSelectedId(null);
};
const selectedLeadCount = selectedId ? leadsPerCampaign(selectedId) : 0;
return (
<ModalOverlay isOpen={isOpen} onOpenChange={handleClose} isDismissable>
<Modal className="sm:max-w-md">
<Dialog>
{() => (
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden">
<div className="flex flex-col items-center gap-3 px-6 pt-6 pb-4">
<FeaturedIcon icon={TrashIcon} color="error" theme="light" size="md" />
<h2 className="text-lg font-semibold text-primary">Clear Campaign Leads</h2>
<p className="text-xs text-tertiary text-center">Select a campaign to delete all its imported leads. This cannot be undone.</p>
</div>
<div className="px-6 pb-4 space-y-2 max-h-60 overflow-y-auto">
{campaigns.map(c => {
const count = leadsPerCampaign(c.id);
return (
<button
key={c.id}
onClick={() => setSelectedId(c.id)}
className={cx(
'flex w-full items-center justify-between rounded-lg border-2 px-3 py-2.5 text-left transition duration-100 ease-linear',
selectedId === c.id ? 'border-error bg-error-primary' : 'border-secondary hover:border-error',
)}
>
<span className="text-sm font-medium text-primary">{c.campaignName ?? 'Untitled'}</span>
<Badge size="sm" color={count > 0 ? 'error' : 'gray'} type="pill-color">{count} leads</Badge>
</button>
);
})}
</div>
<div className="flex items-center gap-3 border-t border-secondary px-6 py-4">
<Button size="md" color="secondary" onClick={handleClose} className="flex-1" isDisabled={clearing}>
Cancel
</Button>
<Button
size="md"
color="primary-destructive"
onClick={handleClear}
isDisabled={!selectedId || selectedLeadCount === 0 || clearing}
isLoading={clearing}
className="flex-1"
>
{clearing ? 'Clearing...' : `Delete ${selectedLeadCount} Leads`}
</Button>
</div>
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
);
};

View File

@@ -27,6 +27,11 @@ const MAINT_ACTIONS: Record<string, MaintAction> = {
label: 'Fix Timestamps', label: 'Fix Timestamps',
description: 'Correct call timestamps that were stored with IST double-offset.', description: 'Correct call timestamps that were stored with IST double-offset.',
}, },
clearCampaignLeads: {
endpoint: '__client__clear-campaign-leads',
label: 'Clear Campaign Leads',
description: 'Delete all imported leads from a selected campaign. For testing only.',
},
}; };
export const useMaintShortcuts = () => { export const useMaintShortcuts = () => {
@@ -61,6 +66,10 @@ export const useMaintShortcuts = () => {
e.preventDefault(); e.preventDefault();
openAction(MAINT_ACTIONS.fixTimestamps); openAction(MAINT_ACTIONS.fixTimestamps);
} }
if (e.ctrlKey && e.shiftKey && e.key === 'C') {
e.preventDefault();
openAction(MAINT_ACTIONS.clearCampaignLeads);
}
}; };
window.addEventListener('keydown', handler); window.addEventListener('keydown', handler);

View File

@@ -0,0 +1,13 @@
First Name,Last Name,Phone,Email,Service,Priority
Divya,Hegde,9876502001,divya.h@gmail.com,Cervical Cancer Screening,HIGH
Nandini,Kulkarni,9876502002,,Pap Smear Test,NORMAL
Rashmi,Patil,9876502003,rashmi.p@yahoo.com,HPV Vaccination,HIGH
Shobha,Deshmukh,9876502004,,Cervical Cancer Screening,NORMAL
Vijaya,Laxmi,9876502005,vijaya.l@gmail.com,Pap Smear Test,NORMAL
Saroja,Rao,9876502006,,HPV Vaccination,HIGH
Usha,Kiran,9876502007,usha.k@outlook.com,Cervical Cancer Screening,NORMAL
Asha,Deshpande,9876502008,,Gynecology Consultation,LOW
Smitha,Joshi,9876502009,smitha.j@gmail.com,Pap Smear Test,NORMAL
Geetha,Shetty,9876502010,,Cervical Cancer Screening,HIGH
Vanitha,Naidu,9876502011,vanitha.n@gmail.com,HPV Vaccination,NORMAL
Prema,Reddy,9876502012,,Pap Smear Test,NORMAL
1 First Name Last Name Phone Email Service Priority
2 Divya Hegde 9876502001 divya.h@gmail.com Cervical Cancer Screening HIGH
3 Nandini Kulkarni 9876502002 Pap Smear Test NORMAL
4 Rashmi Patil 9876502003 rashmi.p@yahoo.com HPV Vaccination HIGH
5 Shobha Deshmukh 9876502004 Cervical Cancer Screening NORMAL
6 Vijaya Laxmi 9876502005 vijaya.l@gmail.com Pap Smear Test NORMAL
7 Saroja Rao 9876502006 HPV Vaccination HIGH
8 Usha Kiran 9876502007 usha.k@outlook.com Cervical Cancer Screening NORMAL
9 Asha Deshpande 9876502008 Gynecology Consultation LOW
10 Smitha Joshi 9876502009 smitha.j@gmail.com Pap Smear Test NORMAL
11 Geetha Shetty 9876502010 Cervical Cancer Screening HIGH
12 Vanitha Naidu 9876502011 vanitha.n@gmail.com HPV Vaccination NORMAL
13 Prema Reddy 9876502012 Pap Smear Test NORMAL

View File

@@ -0,0 +1,11 @@
First Name,Last Name,Phone,Email,Service,Priority
Sneha,Kapoor,9876503001,sneha.k@gmail.com,IVF Consultation,HIGH
Pooja,Agarwal,9876503002,,Fertility Assessment,HIGH
Ritika,Mehta,9876503003,ritika.m@yahoo.com,IVF Consultation,NORMAL
Neha,Gupta,9876503004,,Egg Freezing Consultation,NORMAL
Pallavi,Singh,9876503005,pallavi.s@gmail.com,IUI Treatment,HIGH
Tanvi,Malhotra,9876503006,,IVF Consultation,NORMAL
Shruti,Jain,9876503007,shruti.j@outlook.com,Fertility Assessment,HIGH
Kirti,Verma,9876503008,,IVF Consultation,NORMAL
Manisha,Thakur,9876503009,manisha.t@gmail.com,Egg Freezing Consultation,LOW
Ruchika,Sinha,9876503010,,IUI Treatment,NORMAL
1 First Name Last Name Phone Email Service Priority
2 Sneha Kapoor 9876503001 sneha.k@gmail.com IVF Consultation HIGH
3 Pooja Agarwal 9876503002 Fertility Assessment HIGH
4 Ritika Mehta 9876503003 ritika.m@yahoo.com IVF Consultation NORMAL
5 Neha Gupta 9876503004 Egg Freezing Consultation NORMAL
6 Pallavi Singh 9876503005 pallavi.s@gmail.com IUI Treatment HIGH
7 Tanvi Malhotra 9876503006 IVF Consultation NORMAL
8 Shruti Jain 9876503007 shruti.j@outlook.com Fertility Assessment HIGH
9 Kirti Verma 9876503008 IVF Consultation NORMAL
10 Manisha Thakur 9876503009 manisha.t@gmail.com Egg Freezing Consultation LOW
11 Ruchika Sinha 9876503010 IUI Treatment NORMAL

View File

@@ -0,0 +1,16 @@
First Name,Last Name,Phone,Email,Service,Priority
Anitha,Reddy,9876501001,anitha.r@gmail.com,General Health Checkup,NORMAL
Kavitha,Sharma,9876501002,kavitha.s@yahoo.com,Gynecology Consultation,HIGH
Deepika,Nair,9876501003,,Women's Health Package,NORMAL
Sunitha,Rao,9876501004,sunitha.rao@gmail.com,Mammography Screening,HIGH
Lakshmi,Devi,9876501005,,General Health Checkup,NORMAL
Prathima,Goud,9876501006,prathima.g@outlook.com,Thyroid Check,NORMAL
Swathi,Kumar,9876501007,,Bone Density Test,LOW
Padmaja,Iyer,9876501008,padmaja.i@gmail.com,Women's Health Package,NORMAL
Rekha,Srinivas,9876501009,,Gynecology Consultation,HIGH
Meenakshi,Venkat,9876501010,meenakshi.v@gmail.com,General Health Checkup,NORMAL
Jyothi,Prasad,9876501011,,Mammography Screening,NORMAL
Anusha,Bhat,9876501012,anusha.b@yahoo.com,Thyroid Check,LOW
Srilatha,Reddy,9876501013,,Women's Health Package,NORMAL
Bhavani,Murthy,9876501014,bhavani.m@gmail.com,General Health Checkup,NORMAL
Radha,Krishnan,9876501015,,Gynecology Consultation,HIGH
1 First Name Last Name Phone Email Service Priority
2 Anitha Reddy 9876501001 anitha.r@gmail.com General Health Checkup NORMAL
3 Kavitha Sharma 9876501002 kavitha.s@yahoo.com Gynecology Consultation HIGH
4 Deepika Nair 9876501003 Women's Health Package NORMAL
5 Sunitha Rao 9876501004 sunitha.rao@gmail.com Mammography Screening HIGH
6 Lakshmi Devi 9876501005 General Health Checkup NORMAL
7 Prathima Goud 9876501006 prathima.g@outlook.com Thyroid Check NORMAL
8 Swathi Kumar 9876501007 Bone Density Test LOW
9 Padmaja Iyer 9876501008 padmaja.i@gmail.com Women's Health Package NORMAL
10 Rekha Srinivas 9876501009 Gynecology Consultation HIGH
11 Meenakshi Venkat 9876501010 meenakshi.v@gmail.com General Health Checkup NORMAL
12 Jyothi Prasad 9876501011 Mammography Screening NORMAL
13 Anusha Bhat 9876501012 anusha.b@yahoo.com Thyroid Check LOW
14 Srilatha Reddy 9876501013 Women's Health Package NORMAL
15 Bhavani Murthy 9876501014 bhavani.m@gmail.com General Health Checkup NORMAL
16 Radha Krishnan 9876501015 Gynecology Consultation HIGH