From 33fedf70827b2de77244136b8bf717502ee791ba Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Tue, 31 Mar 2026 14:24:44 +0530 Subject: [PATCH] refactor: unified maint modal with pre-step support, OTP-gated campaign clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../campaigns/lead-import-wizard.tsx | 4 +- src/components/layout/app-shell.tsx | 79 +++++++++++- .../modals/clear-campaign-leads-modal.tsx | 122 ------------------ src/components/modals/maint-otp-modal.tsx | 112 ++++++++++------ src/hooks/use-maint-shortcuts.ts | 5 +- 5 files changed, 156 insertions(+), 166 deletions(-) delete mode 100644 src/components/modals/clear-campaign-leads-modal.tsx diff --git a/src/components/campaigns/lead-import-wizard.tsx b/src/components/campaigns/lead-import-wizard.tsx index 620da93..e9d3781 100644 --- a/src/components/campaigns/lead-import-wizard.tsx +++ b/src/components/campaigns/lead-import-wizard.tsx @@ -314,8 +314,8 @@ export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps {noPhoneCount > 0 && {noPhoneCount} no phone} - {/* Table — fills remaining space, body scrolls */} -
+ {/* Table — fills remaining space, header pinned, body scrolls */} +
columns={[ ...mapping.filter(m => m.leadField).map(m => ({ diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index ac52534..186dcd8 100644 --- a/src/components/layout/app-shell.tsx +++ b/src/components/layout/app-shell.tsx @@ -1,4 +1,4 @@ -import { useEffect, type ReactNode } from 'react'; +import { useEffect, useState, useCallback, type ReactNode } from 'react'; import { useLocation } from 'react-router'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faWifi, faWifiSlash } from '@fortawesome/pro-duotone-svg-icons'; @@ -7,12 +7,14 @@ 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 { Badge } from '@/components/base/badges/badges'; import { useAuth } from '@/providers/auth-provider'; +import { useData } from '@/providers/data-provider'; import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts'; import { useNetworkStatus } from '@/hooks/use-network-status'; +import { apiClient } from '@/lib/api-client'; import { cx } from '@/utils/cx'; interface AppShellProps { @@ -27,6 +29,69 @@ export const AppShell = ({ children }: AppShellProps) => { const networkQuality = useNetworkStatus(); const hasAgentConfig = !!localStorage.getItem('helix_agent_config'); + // Pre-step state for actions that need user input before OTP + const [preStepPayload, setPreStepPayload] = useState | undefined>(undefined); + const { campaigns, leads, refresh } = useData(); + + const leadsPerCampaign = useCallback((campaignId: string) => + leads.filter(l => l.campaignId === campaignId).length, + [leads], + ); + + // Client-side handler for clearing campaign leads + const clearCampaignLeadsHandler = useCallback(async (payload: any) => { + const campaignId = payload?.campaignId; + if (!campaignId) return { status: 'error', message: 'No campaign selected' }; + + const campaignLeads = leads.filter(l => l.campaignId === campaignId); + if (campaignLeads.length === 0) return { status: 'ok', message: 'No leads to clear' }; + + 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 */ } + } + + refresh(); + return { status: 'ok', message: `${deleted} leads deleted` }; + }, [leads, refresh]); + + // Attach client-side handler to the action when it's clear-campaign-leads + const enrichedAction = activeAction ? { + ...activeAction, + ...(activeAction.endpoint === 'clear-campaign-leads' ? { clientSideHandler: clearCampaignLeadsHandler } : {}), + } : null; + + // Reset pre-step when modal closes + useEffect(() => { + if (!isOpen) setPreStepPayload(undefined); + }, [isOpen]); + + // Pre-step content for campaign selection + const campaignPreStep = activeAction?.needsPreStep && activeAction.endpoint === 'clear-campaign-leads' ? ( +
+ {campaigns.map(c => { + const count = leadsPerCampaign(c.id); + const isSelected = preStepPayload?.campaignId === c.id; + return ( + + ); + })} +
+ ) : undefined; + // Heartbeat: keep agent session alive in Redis (CC agents only) useEffect(() => { if (!isCCAgent) return; @@ -80,8 +145,14 @@ export const AppShell = ({ children }: AppShellProps) => {
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && }
- !open && close()} action={activeAction} /> - !open && close()} /> + !open && close()} + action={enrichedAction} + preStepContent={campaignPreStep} + preStepPayload={preStepPayload} + preStepReady={!activeAction?.needsPreStep || !!preStepPayload} + /> ); }; diff --git a/src/components/modals/clear-campaign-leads-modal.tsx b/src/components/modals/clear-campaign-leads-modal.tsx deleted file mode 100644 index a0208fb..0000000 --- a/src/components/modals/clear-campaign-leads-modal.tsx +++ /dev/null @@ -1,122 +0,0 @@ -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/components/modals/maint-otp-modal.tsx b/src/components/modals/maint-otp-modal.tsx index 4c23f7d..545dc2b 100644 --- a/src/components/modals/maint-otp-modal.tsx +++ b/src/components/modals/maint-otp-modal.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, type ReactNode } from 'react'; import { REGEXP_ONLY_DIGITS } from 'input-otp'; import { Button } from '@/components/base/buttons/button'; import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal'; @@ -19,15 +19,20 @@ type MaintAction = { endpoint: string; label: string; description: string; + needsPreStep?: boolean; + clientSideHandler?: (payload: any) => Promise<{ status: string; message: string }>; }; type MaintOtpModalProps = { isOpen: boolean; onOpenChange: (open: boolean) => void; action: MaintAction | null; + preStepContent?: ReactNode; + preStepPayload?: Record; + preStepReady?: boolean; }; -export const MaintOtpModal = ({ isOpen, onOpenChange, action }: MaintOtpModalProps) => { +export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, preStepPayload, preStepReady = true }: MaintOtpModalProps) => { const [otp, setOtp] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -38,18 +43,40 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action }: MaintOtpModalPro setError(null); try { - const res = await fetch(`${API_URL}/api/maint/${action.endpoint}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'x-maint-otp': otp }, - }); - const data = await res.json(); - if (res.ok) { - console.log(`[MAINT] ${action.label}:`, data); - notify.success(action.label, data.message ?? 'Completed successfully'); + if (action.clientSideHandler) { + // Client-side action — OTP verified by calling a dummy maint endpoint first + const otpRes = await fetch(`${API_URL}/api/maint/force-ready`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-maint-otp': otp }, + }); + if (!otpRes.ok) { + setError('Invalid maintenance code'); + setLoading(false); + return; + } + const result = await action.clientSideHandler(preStepPayload); + notify.success(action.label, result.message ?? 'Completed'); onOpenChange(false); setOtp(''); } else { - setError(data.message ?? 'Failed'); + // Standard sidecar endpoint + const res = await fetch(`${API_URL}/api/maint/${action.endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-maint-otp': otp, + }, + ...(preStepPayload ? { body: JSON.stringify(preStepPayload) } : {}), + }); + const data = await res.json(); + if (res.ok) { + console.log(`[MAINT] ${action.label}:`, data); + notify.success(action.label, data.message ?? 'Completed successfully'); + onOpenChange(false); + setOtp(''); + } else { + setError(data.message ?? 'Failed'); + } } } catch { setError('Request failed'); @@ -71,6 +98,8 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action }: MaintOtpModalPro if (!action) return null; + const showOtp = !action.needsPreStep || preStepReady; + return ( @@ -86,31 +115,40 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action }: MaintOtpModalPro - {/* Pin Input */} -
- - Enter maintenance code - - - - - - - - - - - {error && ( -

{error}

- )} -
+ {/* Pre-step content (e.g., campaign selection) */} + {action.needsPreStep && preStepContent && ( +
+ {preStepContent} +
+ )} + + {/* Pin Input — shown when pre-step is ready (or no pre-step needed) */} + {showOtp && ( +
+ + Enter maintenance code + + + + + + + + + + + {error && ( +

{error}

+ )} +
+ )} {/* Footer */}
@@ -120,7 +158,7 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action }: MaintOtpModalPro