mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
feat(maint+ai): OTP modal agent picker + supervisor AI quick actions
Maint shortcuts (Unlock Agent / Force Ready) used to read agentId from the CC-agent's localStorage config — supervisors had no such config and the endpoint 400'd. New flow: after OTP passes, modal calls /api/maint/session-status and renders a two-bucket picker (Locked selectable / Free informational 'Already free'). Orphan locks surface with an explicit label. - use-maint-shortcuts: agentPickerEndpoint flag on forceReady + unlockAgent - maint-otp-modal: two-phase — OTP gate, then picker, then submit; OTP held in state across phases so the operator doesn't re-enter it AI chat panel: supervisor context now shows supervisor-appropriate quick actions (Agent performance / Call summary / Campaign stats / Who needs attention?) that map 1:1 to the supervisor tool set on the sidecar. Agent flow keeps the theme-token quick actions (doctors/clinics/packages).
This commit is contained in:
@@ -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<HTMLDivElement>(null);
|
||||
const chatStartedRef = useRef(false);
|
||||
|
||||
@@ -94,7 +110,7 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<FontAwesomeIcon icon={faSparkles} className="mb-2 size-6 text-fg-brand-primary" />
|
||||
<p className="text-xs text-tertiary">
|
||||
Ask me about doctors, clinics, packages, or patient info.
|
||||
{introText}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap justify-center gap-1.5">
|
||||
{quickActions.map((action) => (
|
||||
|
||||
@@ -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 }) => (
|
||||
<FontAwesomeIcon icon={faShieldKeyhole} className={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<string | null>(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<SessionStatus | null>(null);
|
||||
const [pickedAgentId, setPickedAgentId] = useState<string | null>(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<string | null>(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<string, any>, 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 (
|
||||
<ModalOverlay isOpen={isOpen} onOpenChange={handleClose} isDismissable>
|
||||
<Modal className="sm:max-w-[400px]">
|
||||
<Modal className="sm:max-w-[440px]">
|
||||
<Dialog>
|
||||
{() => (
|
||||
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden">
|
||||
@@ -120,13 +186,12 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
|
||||
</div>
|
||||
|
||||
{/* Pre-step content (e.g., campaign selection) */}
|
||||
{action.needsPreStep && preStepContent && (
|
||||
{action.needsPreStep && preStepContent && !showPicker && (
|
||||
<div className="px-6 pb-4">
|
||||
{preStepContent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pin Input — shown when pre-step is ready (or no pre-step needed) */}
|
||||
{showOtp && (
|
||||
<div className="flex flex-col items-center gap-2 px-6 pb-5">
|
||||
<PinInput size="sm">
|
||||
@@ -154,6 +219,87 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPicker && sessionStatus && (
|
||||
<div className="px-6 pb-5 space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FontAwesomeIcon icon={faLock} className="size-3.5 text-fg-error-primary" />
|
||||
<p className="text-xs font-semibold uppercase text-secondary">
|
||||
Locked ({sessionStatus.locked.length})
|
||||
</p>
|
||||
</div>
|
||||
{sessionStatus.locked.length === 0 ? (
|
||||
<p className="text-sm text-tertiary pl-5">No active session locks.</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{sessionStatus.locked.map((row) => {
|
||||
const selected = pickedAgentId === row.agentId;
|
||||
return (
|
||||
<button
|
||||
key={row.agentId}
|
||||
type="button"
|
||||
onClick={() => setPickedAgentId(row.agentId)}
|
||||
className={cx(
|
||||
'w-full flex items-start justify-between gap-3 rounded-lg border p-3 text-left transition duration-100 ease-linear',
|
||||
selected
|
||||
? 'border-brand bg-brand-primary_alt'
|
||||
: 'border-secondary hover:border-brand hover:bg-secondary',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-primary truncate">{row.displayName}</p>
|
||||
<p className="text-xs text-tertiary truncate">
|
||||
<code className="font-mono">{row.agentId}</code> — held by {row.heldByIp}
|
||||
</p>
|
||||
<p className="text-xs text-quaternary">
|
||||
since {new Date(row.lockedAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
{selected && (
|
||||
<span className="shrink-0 text-xs font-semibold text-brand-secondary">Selected</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FontAwesomeIcon icon={faLockOpen} className="size-3.5 text-fg-success-primary" />
|
||||
<p className="text-xs font-semibold uppercase text-secondary">
|
||||
Free ({sessionStatus.free.length})
|
||||
</p>
|
||||
</div>
|
||||
{sessionStatus.free.length === 0 ? (
|
||||
<p className="text-sm text-tertiary pl-5">No free agents.</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{sessionStatus.free.map((row) => (
|
||||
<div
|
||||
key={row.agentId}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-secondary bg-disabled_subtle p-3 opacity-70"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-secondary truncate">{row.displayName}</p>
|
||||
<p className="text-xs text-quaternary truncate">
|
||||
<code className="font-mono">{row.agentId}</code>
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-medium text-success-primary">Already free</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-error-primary">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<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">
|
||||
@@ -162,9 +308,9 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
|
||||
<Button
|
||||
size="md"
|
||||
color="primary"
|
||||
isDisabled={otp.length < 6 || loading || (action.needsPreStep && !preStepReady)}
|
||||
isDisabled={confirmDisabled}
|
||||
isLoading={loading}
|
||||
onClick={handleSubmit}
|
||||
onClick={handleConfirm}
|
||||
className="flex-1"
|
||||
>
|
||||
Confirm
|
||||
|
||||
@@ -5,6 +5,10 @@ export type MaintAction = {
|
||||
label: string;
|
||||
description: string;
|
||||
needsPreStep?: boolean;
|
||||
// When set, after OTP passes the modal calls this endpoint to fetch
|
||||
// `{ locked, free }` agent buckets and shows a picker. Confirm then
|
||||
// POSTs to `endpoint` with { agentId } from the selection.
|
||||
agentPickerEndpoint?: string;
|
||||
clientSideHandler?: (payload: any) => Promise<{ status: string; message: string }>;
|
||||
};
|
||||
|
||||
@@ -13,11 +17,13 @@ const MAINT_ACTIONS: Record<string, MaintAction> = {
|
||||
endpoint: 'force-ready',
|
||||
label: 'Force Ready',
|
||||
description: 'Logout and re-login the agent to force Ready state on Ozonetel.',
|
||||
agentPickerEndpoint: 'session-status',
|
||||
},
|
||||
unlockAgent: {
|
||||
endpoint: 'unlock-agent',
|
||||
label: 'Unlock Agent',
|
||||
description: 'Release the Redis session lock so the agent can log in again.',
|
||||
agentPickerEndpoint: 'session-status',
|
||||
},
|
||||
backfill: {
|
||||
endpoint: 'backfill-missed-calls',
|
||||
|
||||
Reference in New Issue
Block a user