From 7af1ccb71300b06993267b4c8595791df1d61cef Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Tue, 31 Mar 2026 12:45:05 +0530 Subject: [PATCH] feat: wizard step indicator, wider dialog, campaigns in admin sidebar, clear leads shortcut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../plans/2026-03-31-csv-lead-import.md | 735 ++++++++++++++++++ .../campaigns/lead-import-wizard.tsx | 52 +- src/components/layout/app-shell.tsx | 4 +- src/components/layout/sidebar.tsx | 3 + .../modals/clear-campaign-leads-modal.tsx | 122 +++ src/hooks/use-maint-shortcuts.ts | 9 + test-data/cervical-cancer-screening.csv | 13 + test-data/ivf-free-consultation.csv | 11 + test-data/womens-day-health-checkup.csv | 16 + 9 files changed, 963 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-31-csv-lead-import.md create mode 100644 src/components/modals/clear-campaign-leads-modal.tsx create mode 100644 test-data/cervical-cancer-screening.csv create mode 100644 test-data/ivf-free-consultation.csv create mode 100644 test-data/womens-day-health-checkup.csv diff --git a/docs/superpowers/plans/2026-03-31-csv-lead-import.md b/docs/superpowers/plans/2026-03-31-csv-lead-import.md new file mode 100644 index 0000000..151b74e --- /dev/null +++ b/docs/superpowers/plans/2026-03-31-csv-lead-import.md @@ -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; + +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(); + + 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 = { + 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 }) => ( + +); + +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('select-campaign'); + const [selectedCampaign, setSelectedCampaign] = useState(null); + const [csvRows, setCsvRows] = useState([]); + const [csvHeaders, setCsvHeaders] = useState([]); + const [mapping, setMapping] = useState([]); + const [result, setResult] = useState(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) => { + 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 ( + { if (!open) handleClose(); }}> + + + {() => ( +
+ {/* Header */} +
+
+ +
+

Import Leads

+

+ {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'} +

+
+
+ +
+ + {/* Content */} +
+ + {/* Step 1: Campaign Cards */} + {step === 'select-campaign' && ( +
+ {activeCampaigns.length === 0 ? ( +

No active campaigns. Create a campaign first.

+ ) : ( + activeCampaigns.map(campaign => ( + + )) + )} +
+ )} + + {/* Step 2: Upload + Preview */} + {step === 'upload-preview' && ( +
+ {/* File upload */} + {csvRows.length === 0 ? ( + + ) : ( + <> + {/* Validation banner */} + {!phoneIsMapped && ( +
+ + Phone column must be mapped to proceed +
+ )} + + {/* Summary */} +
+ {csvRows.length} rows + {validCount} ready + {patientMatchCount > 0 && {patientMatchCount} existing patients} + {duplicateCount > 0 && {duplicateCount} duplicates} + {noPhoneCount > 0 && {noPhoneCount} no phone} +
+ + {/* Column mapping + preview table */} +
+ + {/* Mapping row */} + + + {mapping.map(m => ( + + ))} + + + + + {rowsWithMatch.slice(0, 20).map((item, i) => ( + + {mapping.map(m => ( + + ))} + + + ))} + +
+
+ {m.csvHeader} + +
+
+ Patient Match +
+ {item.row[m.csvHeader] ?? ''} + + {item.matchedPatient ? ( + + {item.matchedPatient.fullName?.firstName ?? 'Patient'} + + ) : item.isDuplicate ? ( + Duplicate + ) : !item.hasPhone ? ( + No phone + ) : ( + New + )} +
+ {csvRows.length > 20 && ( +
+ Showing 20 of {csvRows.length} rows +
+ )} +
+ + )} +
+ )} + + {/* Step 3: Importing */} + {step === 'importing' && ( +
+ +

Importing leads...

+

{importProgress} of {rowsWithMatch.length}

+
+
+
+
+ )} + + {/* Step 4: Done */} + {step === 'done' && result && ( +
+ } color="success" theme="light" size="lg" /> +

Import Complete

+
+
+

{result.created}

+

Created

+
+
+

{result.linkedToPatient}

+

Linked to Patients

+
+ {result.skippedDuplicate > 0 && ( +
+

{result.skippedDuplicate}

+

Duplicates

+
+ )} + {result.failed > 0 && ( +
+

{result.failed}

+

Failed

+
+ )} +
+
+ )} +
+ + {/* Footer */} +
+ {step === 'select-campaign' && ( + + )} + {step === 'upload-preview' && ( + <> + + + + )} + {step === 'done' && ( + + )} +
+
+ )} +
+
+
+ ); +}; +``` + +- [ ] **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 + +``` + +with: + +```typescript + + + +``` + +Add the import for `faFileImport`: + +```typescript +import { faPenToSquare, faFileImport } from '@fortawesome/pro-duotone-svg-icons'; +``` + +Add the wizard component before the closing `` of the return, after the CampaignEditSlideout: + +```typescript + +``` + +- [ ] **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.) diff --git a/src/components/campaigns/lead-import-wizard.tsx b/src/components/campaigns/lead-import-wizard.tsx index 827d144..c0539c7 100644 --- a/src/components/campaigns/lead-import-wizard.tsx +++ b/src/components/campaigns/lead-import-wizard.tsx @@ -19,6 +19,53 @@ const FileImportIcon: FC<{ className?: string }> = ({ className }) => ( 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 ( +
+ {WIZARD_STEPS.map((step, i) => { + const isComplete = i < activeIndex; + const isActive = i === activeIndex; + const isLast = i === WIZARD_STEPS.length - 1; + + return ( +
+
+
+ {isComplete ? : step.number} +
+ + {step.label} + +
+ {!isLast && ( +
+ )} +
+ ); + })} +
+ ); +}; + type ImportResult = { created: number; linkedToPatient: number; @@ -152,7 +199,7 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps return ( { if (!open) handleClose(); }}> - + {() => (
@@ -173,6 +220,9 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps
+ {/* Step indicator */} + + {/* Content */}
{/* Step 1: Campaign Cards */} diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index 8a9931d..ac52534 100644 --- a/src/components/layout/app-shell.tsx +++ b/src/components/layout/app-shell.tsx @@ -7,6 +7,7 @@ import { SipProvider } from '@/providers/sip-provider'; import { useSip } from '@/providers/sip-provider'; import { CallWidget } from '@/components/call-desk/call-widget'; 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 { NotificationBell } from './notification-bell'; import { useAuth } from '@/providers/auth-provider'; @@ -79,7 +80,8 @@ export const AppShell = ({ children }: AppShellProps) => {
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && }
- !open && close()} action={activeAction} /> + !open && close()} action={activeAction} /> + !open && close()} /> ); }; diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 95d1b2d..41706ed 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -73,6 +73,9 @@ const getNavSections = (role: string): NavSection[] => { { label: 'Call Recordings', href: '/call-recordings', icon: IconFileAudio }, { label: 'Missed Calls', href: '/missed-calls', icon: IconPhoneMissed }, ]}, + { label: 'Marketing', items: [ + { label: 'Campaigns', href: '/campaigns', icon: IconBullhorn }, + ]}, { label: 'Admin', items: [ { label: 'Settings', href: '/settings', icon: IconGear }, ]}, diff --git a/src/components/modals/clear-campaign-leads-modal.tsx b/src/components/modals/clear-campaign-leads-modal.tsx new file mode 100644 index 0000000..a0208fb --- /dev/null +++ b/src/components/modals/clear-campaign-leads-modal.tsx @@ -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 }) => ( + +); + +interface ClearCampaignLeadsModalProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; +} + +export const ClearCampaignLeadsModal = ({ isOpen, onOpenChange }: ClearCampaignLeadsModalProps) => { + const { campaigns, leads, refresh } = useData(); + const [selectedId, setSelectedId] = useState(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 ( + + + + {() => ( +
+
+ +

Clear Campaign Leads

+

Select a campaign to delete all its imported leads. This cannot be undone.

+
+ +
+ {campaigns.map(c => { + const count = leadsPerCampaign(c.id); + return ( + + ); + })} +
+ +
+ + +
+
+ )} +
+
+
+ ); +}; diff --git a/src/hooks/use-maint-shortcuts.ts b/src/hooks/use-maint-shortcuts.ts index 7c8cb97..2e60814 100644 --- a/src/hooks/use-maint-shortcuts.ts +++ b/src/hooks/use-maint-shortcuts.ts @@ -27,6 +27,11 @@ const MAINT_ACTIONS: Record = { label: 'Fix Timestamps', 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 = () => { @@ -61,6 +66,10 @@ export const useMaintShortcuts = () => { e.preventDefault(); openAction(MAINT_ACTIONS.fixTimestamps); } + if (e.ctrlKey && e.shiftKey && e.key === 'C') { + e.preventDefault(); + openAction(MAINT_ACTIONS.clearCampaignLeads); + } }; window.addEventListener('keydown', handler); diff --git a/test-data/cervical-cancer-screening.csv b/test-data/cervical-cancer-screening.csv new file mode 100644 index 0000000..1ad0d2e --- /dev/null +++ b/test-data/cervical-cancer-screening.csv @@ -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 diff --git a/test-data/ivf-free-consultation.csv b/test-data/ivf-free-consultation.csv new file mode 100644 index 0000000..612afcc --- /dev/null +++ b/test-data/ivf-free-consultation.csv @@ -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 diff --git a/test-data/womens-day-health-checkup.csv b/test-data/womens-day-health-checkup.csv new file mode 100644 index 0000000..3ad1575 --- /dev/null +++ b/test-data/womens-day-health-checkup.csv @@ -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