mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
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:
@@ -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 (
|
||||
<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;
|
||||
@@ -152,7 +199,7 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps
|
||||
|
||||
return (
|
||||
<ModalOverlay isOpen={isOpen} isDismissable onOpenChange={(open) => { if (!open) handleClose(); }}>
|
||||
<Modal className="sm:max-w-3xl">
|
||||
<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 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">×</button>
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<StepIndicator currentStep={step} />
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">
|
||||
{/* Step 1: Campaign Cards */}
|
||||
|
||||
@@ -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) => {
|
||||
</div>
|
||||
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
]},
|
||||
|
||||
122
src/components/modals/clear-campaign-leads-modal.tsx
Normal file
122
src/components/modals/clear-campaign-leads-modal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -27,6 +27,11 @@ const MAINT_ACTIONS: Record<string, MaintAction> = {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user