diff --git a/src/components/call-desk/ai-chat-panel.tsx b/src/components/call-desk/ai-chat-panel.tsx index 07b3344..ce497a8 100644 --- a/src/components/call-desk/ai-chat-panel.tsx +++ b/src/components/call-desk/ai-chat-panel.tsx @@ -19,9 +19,25 @@ interface AiChatPanelProps { onChatStart?: () => void; } +// Supervisor has different quick-action prompts than the CC agent — they +// ask about team metrics, not patient / doctor info. Hardcoded here rather +// than in theme tokens because the prompts map 1:1 to the supervisor tool +// set in ai-chat.controller.ts (get_agent_performance, get_call_summary, +// get_campaign_stats) — changing the tools means changing these prompts. +const SUPERVISOR_QUICK_ACTIONS = [ + { label: 'Agent performance', prompt: 'Show me agent performance this week.' }, + { label: 'Call summary', prompt: 'Summarize call activity this week.' }, + { label: 'Campaign stats', prompt: 'How are the campaigns performing?' }, + { label: 'Who needs attention?', prompt: 'Which agents are underperforming or need attention?' }, +]; + +const SUPERVISOR_INTRO = 'Ask me about agent performance, call trends, or campaign stats.'; + export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) => { const { tokens } = useThemeTokens(); - const quickActions = tokens.ai.quickActions; + const isSupervisor = callerContext?.type === 'supervisor'; + const quickActions = isSupervisor ? SUPERVISOR_QUICK_ACTIONS : tokens.ai.quickActions; + const introText = isSupervisor ? SUPERVISOR_INTRO : 'Ask me about doctors, clinics, packages, or patient info.'; const messagesEndRef = useRef(null); const chatStartedRef = useRef(false); @@ -94,7 +110,7 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>

- Ask me about doctors, clinics, packages, or patient info. + {introText}

{quickActions.map((action) => ( diff --git a/src/components/modals/maint-otp-modal.tsx b/src/components/modals/maint-otp-modal.tsx index 51cf0d7..ffdcd28 100644 --- a/src/components/modals/maint-otp-modal.tsx +++ b/src/components/modals/maint-otp-modal.tsx @@ -5,9 +5,10 @@ import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/mod import { PinInput } from '@/components/base/pin-input/pin-input'; import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faShieldKeyhole } from '@fortawesome/pro-duotone-svg-icons'; +import { faShieldKeyhole, faLock, faLockOpen } from '@fortawesome/pro-duotone-svg-icons'; import type { FC } from 'react'; import { notify } from '@/lib/toast'; +import { cx } from '@/utils/cx'; const ShieldIcon: FC<{ className?: string }> = ({ className }) => ( @@ -20,9 +21,14 @@ type MaintAction = { label: string; description: string; needsPreStep?: boolean; + agentPickerEndpoint?: string; clientSideHandler?: (payload: any) => Promise<{ status: string; message: string }>; }; +type LockedRow = { agentId: string; displayName: string; heldByIp: string; lockedAt: string }; +type FreeRow = { agentId: string; displayName: string }; +type SessionStatus = { locked: LockedRow[]; free: FreeRow[] }; + type MaintOtpModalProps = { isOpen: boolean; onOpenChange: (open: boolean) => void; @@ -36,6 +42,55 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr const [otp, setOtp] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + // Phase-2 state: once the OTP passes and the action uses an agent + // picker, we swap the PIN input for a two-bucket list (Locked / Free) + // fetched from `agentPickerEndpoint`. The operator picks a locked + // agent, then Confirm posts to the real `endpoint`. + const [sessionStatus, setSessionStatus] = useState(null); + const [pickedAgentId, setPickedAgentId] = useState(null); + // OTP is held across the two-phase flow so we don't force the user + // to re-enter it after the picker loads. + const [verifiedOtp, setVerifiedOtp] = useState(null); + + const reset = () => { + setOtp(''); + setError(null); + setSessionStatus(null); + setPickedAgentId(null); + setVerifiedOtp(null); + setLoading(false); + }; + + const handleClose = () => { + onOpenChange(false); + reset(); + }; + + const postMaint = async (endpoint: string, body: Record, otpHeader: string) => { + const res = await fetch(`${API_URL}/api/maint/${endpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-maint-otp': otpHeader }, + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + return { ok: res.ok, data }; + }; + + const runPickerAction = async (pickedId: string, otpHeader: string) => { + if (!action) return; + setLoading(true); + setError(null); + const payload = { ...preStepPayload, agentId: pickedId }; + const { ok, data } = await postMaint(action.endpoint, payload, otpHeader); + setLoading(false); + if (ok) { + notify.success(action.label, data.message ?? 'Completed successfully'); + onOpenChange(false); + reset(); + } else { + setError(data.message ?? 'Failed'); + } + }; const handleSubmit = async () => { if (!action || otp.length < 6) return; @@ -43,44 +98,49 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr setError(null); try { + // Two-phase agent-picker flow — OTP first, then fetch list, + // then the operator picks which agent to act on. + if (action.agentPickerEndpoint) { + const { ok, data } = await postMaint(action.agentPickerEndpoint, {}, otp); + if (!ok) { + setError(data.message ?? 'Invalid maintenance code'); + setLoading(false); + return; + } + setSessionStatus(data as SessionStatus); + setVerifiedOtp(otp); + setLoading(false); + return; + } + 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'); + const { ok, data } = await postMaint('force-ready', {}, otp); + if (!ok) { + setError(data.message ?? 'Invalid maintenance code'); setLoading(false); return; } const result = await action.clientSideHandler(preStepPayload); notify.success(action.label, result.message ?? 'Completed'); onOpenChange(false); - setOtp(''); - } else { - // Standard sidecar endpoint — include agentId from agent config - const agentCfg = localStorage.getItem('helix_agent_config'); - const agentId = agentCfg ? JSON.parse(agentCfg).ozonetelAgentId : undefined; - const payload = { ...preStepPayload, ...(agentId ? { agentId } : {}) }; + reset(); + return; + } - const res = await fetch(`${API_URL}/api/maint/${action.endpoint}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-maint-otp': otp, - }, - body: JSON.stringify(payload), - }); - 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'); - } + // Default: single-shot endpoint with agentId from the CC agent's + // own local config (cc-agent context). Supervisors hitting this + // path without agent config used to get 400 — the agent-picker + // branch above is the fix. + const agentCfg = localStorage.getItem('helix_agent_config'); + const agentId = agentCfg ? JSON.parse(agentCfg).ozonetelAgentId : undefined; + const payload = { ...preStepPayload, ...(agentId ? { agentId } : {}) }; + const { ok, data } = await postMaint(action.endpoint, payload, otp); + if (ok) { + notify.success(action.label, data.message ?? 'Completed successfully'); + onOpenChange(false); + reset(); + } else { + setError(data.message ?? 'Failed'); } } catch { setError('Request failed'); @@ -94,19 +154,25 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr setError(null); }; - const handleClose = () => { - onOpenChange(false); - setOtp(''); - setError(null); - }; - if (!action) return null; - const showOtp = !action.needsPreStep || preStepReady; + const showPicker = Boolean(action.agentPickerEndpoint && sessionStatus && verifiedOtp); + const showOtp = (!action.needsPreStep || preStepReady) && !showPicker; + const confirmDisabled = showPicker + ? !pickedAgentId || loading + : otp.length < 6 || loading || (action.needsPreStep && !preStepReady); + + const handleConfirm = async () => { + if (showPicker && pickedAgentId && verifiedOtp) { + await runPickerAction(pickedAgentId, verifiedOtp); + } else { + await handleSubmit(); + } + }; return ( - + {() => (
@@ -120,13 +186,12 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
{/* Pre-step content (e.g., campaign selection) */} - {action.needsPreStep && preStepContent && ( + {action.needsPreStep && preStepContent && !showPicker && (
{preStepContent}
)} - {/* Pin Input — shown when pre-step is ready (or no pre-step needed) */} {showOtp && (
@@ -154,6 +219,87 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
)} + {showPicker && sessionStatus && ( +
+
+
+ +

+ Locked ({sessionStatus.locked.length}) +

+
+ {sessionStatus.locked.length === 0 ? ( +

No active session locks.

+ ) : ( +
+ {sessionStatus.locked.map((row) => { + const selected = pickedAgentId === row.agentId; + return ( + + ); + })} +
+ )} +
+ +
+
+ +

+ Free ({sessionStatus.free.length}) +

+
+ {sessionStatus.free.length === 0 ? ( +

No free agents.

+ ) : ( +
+ {sessionStatus.free.map((row) => ( +
+
+

{row.displayName}

+

+ {row.agentId} +

+
+ Already free +
+ ))} +
+ )} +
+ + {error && ( +

{error}

+ )} +
+ )} + {/* Footer */}