mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
Compare commits
7 Commits
85976803a1
...
hardening/
| Author | SHA1 | Date | |
|---|---|---|---|
| d36086f6da | |||
| cfe9e0bb77 | |||
| 923c99bf17 | |||
| a306311f08 | |||
| d0e34fa9dd | |||
| 7e5d910197 | |||
| dd4240ee7f |
@@ -159,19 +159,33 @@ REDIS_URL=redis://localhost:6379
|
|||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
|
Each tenant has its own frontend directory on EC2. The `VITE_API_URL` is baked at build time so each build points to the correct sidecar.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Helper — reuse in all commands below
|
# Helper — reuse in all commands below
|
||||||
EC2="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194"
|
EC2="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194"
|
||||||
EC2_RSYNC="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no"
|
EC2_RSYNC="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no"
|
||||||
|
|
||||||
cd helix-engage && npm run build
|
cd helix-engage
|
||||||
|
|
||||||
|
# ── Ramaiah (production pilot — deploy stable builds only) ──
|
||||||
|
VITE_API_URL=https://ramaiah.engage.healix360.net npm run build
|
||||||
rsync -avz -e "$EC2_RSYNC" \
|
rsync -avz -e "$EC2_RSYNC" \
|
||||||
dist/ ubuntu@13.234.31.194:/opt/fortytwo/helix-engage-frontend/
|
dist/ ubuntu@13.234.31.194:/opt/fortytwo/frontend-ramaiah/
|
||||||
|
|
||||||
eval $EC2 "cd /opt/fortytwo && sudo docker compose restart caddy"
|
# ── Global (staging — new features land here first) ──
|
||||||
|
VITE_API_URL=https://global.engage.healix360.net npm run build
|
||||||
|
rsync -avz -e "$EC2_RSYNC" \
|
||||||
|
dist/ ubuntu@13.234.31.194:/opt/fortytwo/frontend-global/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
| Tenant | Frontend Dir | API URL (baked) | Caddy Root |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Ramaiah | `/opt/fortytwo/frontend-ramaiah/` | `https://ramaiah.engage.healix360.net` | `/srv/engage-ramaiah` |
|
||||||
|
| Global | `/opt/fortytwo/frontend-global/` | `https://global.engage.healix360.net` | `/srv/engage-global` |
|
||||||
|
|
||||||
|
**Important:** Always build with the correct `VITE_API_URL` for the target tenant. A build without it (or with `localhost`) will break login and API calls.
|
||||||
|
|
||||||
### Sidecar
|
### Sidecar
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { formatPhone, formatShortDate } from '@/lib/format';
|
|||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { useAuth } from '@/providers/auth-provider';
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
import { useAgentState } from '@/hooks/use-agent-state';
|
import { useAgentState } from '@/hooks/use-agent-state';
|
||||||
|
import { useNetworkStatus } from '@/hooks/use-network-status';
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from '@/utils/cx';
|
||||||
import { notify } from '@/lib/toast';
|
import { notify } from '@/lib/toast';
|
||||||
import type { Lead, CallDisposition } from '@/types/entities';
|
import type { Lead, CallDisposition } from '@/types/entities';
|
||||||
@@ -42,6 +43,7 @@ const formatDuration = (seconds: number): string => {
|
|||||||
export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete }: ActiveCallCardProps) => {
|
export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete }: ActiveCallCardProps) => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { callState, callDuration, callUcid, isMuted, isOnHold, answer, hangup, toggleMute, toggleHold } = useSip();
|
const { callState, callDuration, callUcid, isMuted, isOnHold, answer, hangup, toggleMute, toggleHold } = useSip();
|
||||||
|
const networkQuality = useNetworkStatus();
|
||||||
const setCallState = useSetAtom(sipCallStateAtom);
|
const setCallState = useSetAtom(sipCallStateAtom);
|
||||||
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
||||||
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
||||||
@@ -103,10 +105,44 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
|
|
||||||
const agentConfig = localStorage.getItem('helix_agent_config');
|
const agentConfig = localStorage.getItem('helix_agent_config');
|
||||||
const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
|
const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
|
||||||
const { supervisorPresence } = useAgentState(agentIdForState);
|
const { state: ozonetelState, supervisorPresence } = useAgentState(agentIdForState);
|
||||||
|
|
||||||
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
|
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
|
||||||
const wasAnsweredRef = useRef(callState === 'active');
|
const isOutbound = callDirectionRef.current === 'OUTBOUND';
|
||||||
|
|
||||||
|
// customerAnswered — live signal (is customer on the line RIGHT NOW?)
|
||||||
|
const customerAnswered = callState === 'active' && (!isOutbound || ozonetelState === 'in-call');
|
||||||
|
|
||||||
|
// confirmedAnswered — latched state (did a real conversation happen?)
|
||||||
|
// Inbound: set true on active (immediate). Outbound: set true after
|
||||||
|
// in-call holds 5+ seconds (filters voicemail). Never resets — survives
|
||||||
|
// the acw→ended timing gap. Used for disposition routing AND outbound
|
||||||
|
// button gating.
|
||||||
|
const [confirmedAnswered, setConfirmedAnswered] = useState(false);
|
||||||
|
const unansweredDisposeFired = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOutbound && callState === 'active') {
|
||||||
|
setConfirmedAnswered(true);
|
||||||
|
}
|
||||||
|
}, [callState, isOutbound]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOutbound && customerAnswered && !confirmedAnswered) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
console.log(`[CALL-DBG] ▶ Outbound debounce passed — customer confirmed answered`);
|
||||||
|
setConfirmedAnswered(true);
|
||||||
|
}, 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [customerAnswered, isOutbound, confirmedAnswered]);
|
||||||
|
|
||||||
|
// Button gating: inbound uses live signal, outbound uses debounced latch
|
||||||
|
const buttonsEnabled = isOutbound ? confirmedAnswered : customerAnswered;
|
||||||
|
|
||||||
|
// ── DEBUG: trace every state change ──
|
||||||
|
useEffect(() => {
|
||||||
|
console.log(`[CALL-DBG] callState=${callState} ozonetel=${ozonetelState} direction=${callDirectionRef.current} isOutbound=${isOutbound} customerAnswered=${customerAnswered} confirmedAnswered=${confirmedAnswered} buttonsEnabled=${buttonsEnabled}`);
|
||||||
|
}, [callState, ozonetelState, isOutbound, customerAnswered, confirmedAnswered, buttonsEnabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log(`[ACTIVE-CALL-CARD] Mounted: state=${callState} direction=${callDirectionRef.current} ucid=${callUcid ?? 'none'} lead=${lead?.id ?? 'none'} phone=${callerPhone}`);
|
console.log(`[ACTIVE-CALL-CARD] Mounted: state=${callState} direction=${callDirectionRef.current} ucid=${callUcid ?? 'none'} lead=${lead?.id ?? 'none'} phone=${callerPhone}`);
|
||||||
@@ -124,13 +160,16 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
};
|
};
|
||||||
}, [callUcid]);
|
}, [callUcid]);
|
||||||
|
|
||||||
// Detect caller disconnect: call was active and ended without agent pressing End
|
// Detect caller disconnect: call ended without agent pressing End.
|
||||||
|
// Uses confirmedAnsweredRef (stable latch) — not the live customerAnswered.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (wasAnsweredRef.current && !dispositionOpen && (callState === 'ended' || callState === 'failed')) {
|
if (!(callState === 'ended' || callState === 'failed') || dispositionOpen) return;
|
||||||
|
console.log(`[CALL-DBG] ▶ CALL ENDED: confirmedAnswered=${confirmedAnswered} isOutbound=${isOutbound} customerAnswered=${customerAnswered} callState=${callState}`);
|
||||||
|
if (confirmedAnswered) {
|
||||||
setCallerDisconnected(true);
|
setCallerDisconnected(true);
|
||||||
setDispositionOpen(true);
|
setDispositionOpen(true);
|
||||||
}
|
}
|
||||||
}, [callState, dispositionOpen]);
|
}, [callState, dispositionOpen]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const firstName = lead?.contactName?.firstName ?? '';
|
const firstName = lead?.contactName?.firstName ?? '';
|
||||||
const lastName = lead?.contactName?.lastName ?? '';
|
const lastName = lead?.contactName?.lastName ?? '';
|
||||||
@@ -200,6 +239,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setDispositionOpen(false);
|
setDispositionOpen(false);
|
||||||
setCallerDisconnected(false);
|
setCallerDisconnected(false);
|
||||||
|
setConfirmedAnswered(false);
|
||||||
setActionsTaken([]);
|
setActionsTaken([]);
|
||||||
setCallState('idle');
|
setCallState('idle');
|
||||||
setCallerNumber(null);
|
setCallerNumber(null);
|
||||||
@@ -208,6 +248,26 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
onCallComplete?.();
|
onCallComplete?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Auto-dispose unanswered outbound calls to release agent from ACW immediately
|
||||||
|
useEffect(() => {
|
||||||
|
if (!confirmedAnswered && isOutbound && (callState === 'ended' || callState === 'failed') && callUcid && !unansweredDisposeFired.current) {
|
||||||
|
unansweredDisposeFired.current = true;
|
||||||
|
const agentCfg = JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}');
|
||||||
|
console.log(`[CALL-DBG] ▶ Auto-disposing unanswered outbound: ucid=${callUcid} agent=${agentCfg.ozonetelAgentId}`);
|
||||||
|
apiClient.post('/api/ozonetel/dispose', {
|
||||||
|
ucid: callUcid,
|
||||||
|
disposition: 'NO_ANSWER',
|
||||||
|
agentId: agentCfg.ozonetelAgentId,
|
||||||
|
callerPhone,
|
||||||
|
direction: 'OUTBOUND',
|
||||||
|
durationSec: 0,
|
||||||
|
leadId: lead?.id ?? null,
|
||||||
|
leadName: fullName || null,
|
||||||
|
notes: 'Auto-disposed — customer did not answer',
|
||||||
|
}).catch((err) => console.error('[CALL-DBG] Auto-dispose failed:', err));
|
||||||
|
}
|
||||||
|
}, [callState, callUcid]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Outbound ringing
|
// Outbound ringing
|
||||||
if (callState === 'ringing-out') {
|
if (callState === 'ringing-out') {
|
||||||
return (
|
return (
|
||||||
@@ -225,11 +285,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
{fullName && <p className="text-sm text-secondary">{phoneDisplay}</p>}
|
{fullName && <p className="text-sm text-secondary">{phoneDisplay}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex gap-2">
|
{/* Cancel button removed per product — risk: agent can't abort
|
||||||
<Button size="sm" color="primary-destructive" onClick={() => { hangup(); handleReset(); }}>
|
a misdialled outbound call before the customer answers.
|
||||||
Cancel
|
<Button size="sm" color="primary-destructive" onClick={() => { hangup(); handleReset(); }}>Cancel</Button> */}
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -259,8 +317,8 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unanswered call (ringing → ended without ever reaching active)
|
if (!confirmedAnswered && (callState === 'ended' || callState === 'failed')) {
|
||||||
if (!wasAnsweredRef.current && (callState === 'ended' || callState === 'failed')) {
|
console.log(`[CALL-DBG] ▶ BACK-TO-WORKLIST PATH: confirmedAnswered=${confirmedAnswered} isOutbound=${isOutbound}`);
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-secondary bg-primary p-4 text-center">
|
<div className="rounded-xl border border-secondary bg-primary p-4 text-center">
|
||||||
<FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" />
|
<FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" />
|
||||||
@@ -275,10 +333,23 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
|
|
||||||
// Active call
|
// Active call
|
||||||
if (callState === 'active' || dispositionOpen) {
|
if (callState === 'active' || dispositionOpen) {
|
||||||
wasAnsweredRef.current = true;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={cx('flex flex-col rounded-xl border border-brand bg-primary overflow-hidden', (appointmentOpen || enquiryOpen || transferOpen) && 'flex-1 min-h-0')}>
|
<div className={cx('flex flex-col rounded-xl border border-brand bg-primary overflow-hidden', (appointmentOpen || enquiryOpen || transferOpen) && 'flex-1 min-h-0')}>
|
||||||
|
{/* Network loss alert — prominent banner during active call */}
|
||||||
|
{networkQuality !== 'good' && (
|
||||||
|
<div className={cx(
|
||||||
|
'shrink-0 px-4 py-2 text-xs font-medium text-center',
|
||||||
|
networkQuality === 'offline'
|
||||||
|
? 'bg-error-solid text-white'
|
||||||
|
: 'bg-warning-secondary text-warning-primary',
|
||||||
|
)}>
|
||||||
|
{networkQuality === 'offline'
|
||||||
|
? 'Network connection lost — call may have dropped'
|
||||||
|
: 'Network unstable — call quality may be affected'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Pinned: caller info + controls */}
|
{/* Pinned: caller info + controls */}
|
||||||
<div className="shrink-0 p-4">
|
<div className="shrink-0 p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -343,17 +414,17 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
|
|
||||||
<Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'}
|
<Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'}
|
||||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
|
||||||
isDisabled={!wasAnsweredRef.current}
|
isDisabled={!buttonsEnabled}
|
||||||
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>
|
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>
|
||||||
{leadAppointments.length > 0 ? 'New / Reschedule Appt' : 'New Appt'}
|
{leadAppointments.length > 0 ? 'New / Reschedule Appt' : 'New Appt'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'}
|
<Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'}
|
||||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
|
||||||
isDisabled={!wasAnsweredRef.current}
|
isDisabled={!buttonsEnabled}
|
||||||
onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); setTransferOpen(false); }}>Enquiry</Button>
|
onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); setTransferOpen(false); }}>Enquiry</Button>
|
||||||
<Button size="sm" color={transferOpen ? 'primary' : 'secondary'}
|
<Button size="sm" color={transferOpen ? 'primary' : 'secondary'}
|
||||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
|
||||||
isDisabled={!wasAnsweredRef.current}
|
isDisabled={!buttonsEnabled}
|
||||||
onClick={() => { setTransferOpen(!transferOpen); setAppointmentOpen(false); setEnquiryOpen(false); }}>Transfer</Button>
|
onClick={() => { setTransferOpen(!transferOpen); setAppointmentOpen(false); setEnquiryOpen(false); }}>Transfer</Button>
|
||||||
|
|
||||||
<Button size="sm" color="primary-destructive" className="ml-auto"
|
<Button size="sm" color="primary-destructive" className="ml-auto"
|
||||||
@@ -535,12 +606,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
isOpen={dispositionOpen}
|
isOpen={dispositionOpen}
|
||||||
callerName={fullName || phoneDisplay}
|
callerName={fullName || phoneDisplay}
|
||||||
callerDisconnected={callerDisconnected}
|
callerDisconnected={callerDisconnected}
|
||||||
// wasAnsweredRef only flips true once callState reaches
|
callAnswered={confirmedAnswered}
|
||||||
// 'active'. Outbound callbacks that never connect keep
|
|
||||||
// this false, which narrows the disposition options to
|
|
||||||
// no-answer outcomes and prevents SLA-gaming dispositions
|
|
||||||
// like Info Provided on a call the customer never took.
|
|
||||||
callAnswered={wasAnsweredRef.current}
|
|
||||||
actionsTaken={actionsTaken}
|
actionsTaken={actionsTaken}
|
||||||
onSubmit={handleDisposition}
|
onSubmit={handleDisposition}
|
||||||
onDismiss={() => {
|
onDismiss={() => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useData } from '@/providers/data-provider';
|
|||||||
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
|
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
|
||||||
import { useNetworkStatus } from '@/hooks/use-network-status';
|
import { useNetworkStatus } from '@/hooks/use-network-status';
|
||||||
// import { GlobalSearch } from '@/components/shared/global-search';
|
// import { GlobalSearch } from '@/components/shared/global-search';
|
||||||
|
import { AiFloatingButton } from '@/components/shared/ai-floating-button';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ interface AppShellProps {
|
|||||||
|
|
||||||
export const AppShell = ({ children }: AppShellProps) => {
|
export const AppShell = ({ children }: AppShellProps) => {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const { isCCAgent } = useAuth();
|
const { isCCAgent, isAdmin } = useAuth();
|
||||||
const { isOpen, activeAction, close } = useMaintShortcuts();
|
const { isOpen, activeAction, close } = useMaintShortcuts();
|
||||||
const { connectionStatus, isRegistered } = useSip();
|
const { connectionStatus, isRegistered } = useSip();
|
||||||
const networkQuality = useNetworkStatus();
|
const networkQuality = useNetworkStatus();
|
||||||
@@ -143,6 +144,7 @@ export const AppShell = ({ children }: AppShellProps) => {
|
|||||||
<main className="flex flex-1 flex-col overflow-hidden">{children}</main>
|
<main className="flex flex-1 flex-col overflow-hidden">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />}
|
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />}
|
||||||
|
{isAdmin && !isCCAgent && <AiFloatingButton />}
|
||||||
</div>
|
</div>
|
||||||
<MaintOtpModal
|
<MaintOtpModal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
|
|||||||
50
src/components/shared/ai-floating-button.tsx
Normal file
50
src/components/shared/ai-floating-button.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faSparkles, faXmark } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { AiChatPanel } from '@/components/call-desk/ai-chat-panel';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
|
export const AiFloatingButton = () => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* FAB — bottom right, hidden when drawer is open */}
|
||||||
|
{!open && (
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="fixed bottom-6 right-6 z-50 flex size-12 items-center justify-center rounded-full bg-brand-solid text-white shadow-lg hover:bg-brand-solid_hover transition duration-100 ease-linear"
|
||||||
|
title="AI Assistant"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faSparkles} className="size-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Drawer — slides in from right */}
|
||||||
|
<div className={cx(
|
||||||
|
'fixed top-0 right-0 z-50 h-full bg-primary border-l border-secondary shadow-xl transition-all duration-200 ease-linear flex flex-col',
|
||||||
|
open ? 'w-[400px]' : 'w-0 overflow-hidden border-l-0',
|
||||||
|
)}>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FontAwesomeIcon icon={faSparkles} className="size-3.5 text-fg-brand-primary" />
|
||||||
|
<span className="text-sm font-semibold text-primary">AI Assistant</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="flex size-7 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faXmark} className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
|
<AiChatPanel callerContext={{ type: 'supervisor' }} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { notify } from './toast';
|
import { notify } from './toast';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
// In production, use the current origin — Caddy routes /api/* to the
|
||||||
|
// correct per-tenant sidecar based on hostname. Only use VITE_API_URL
|
||||||
|
// for local dev (pointing to a specific sidecar).
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || window.location.origin;
|
||||||
|
|
||||||
class AuthError extends Error {
|
class AuthError extends Error {
|
||||||
constructor(message = 'Authentication required') {
|
constructor(message = 'Authentication required') {
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
|
|
||||||
import { PageHeader } from '@/components/layout/page-header';
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
import { AiChatPanel } from '@/components/call-desk/ai-chat-panel';
|
|
||||||
import { DashboardKpi } from '@/components/dashboard/kpi-cards';
|
import { DashboardKpi } from '@/components/dashboard/kpi-cards';
|
||||||
import { MissedQueue } from '@/components/dashboard/missed-queue';
|
import { MissedQueue } from '@/components/dashboard/missed-queue';
|
||||||
import {
|
import {
|
||||||
@@ -29,7 +26,6 @@ const getDateRangeStart = (range: DateRange): Date => {
|
|||||||
export const TeamDashboardPage = () => {
|
export const TeamDashboardPage = () => {
|
||||||
const { calls, leads, campaigns, loading } = useData();
|
const { calls, leads, campaigns, loading } = useData();
|
||||||
const [dateRange, setDateRange] = useState<DateRange>('week');
|
const [dateRange, setDateRange] = useState<DateRange>('week');
|
||||||
const [aiOpen, setAiOpen] = useState(true);
|
|
||||||
|
|
||||||
// Pull the richer supervisor rollup (NPS, idle, time breakdown, alerts)
|
// Pull the richer supervisor rollup (NPS, idle, time breakdown, alerts)
|
||||||
// from the sidecar. Only `today`/`week`/`month` overlap with the rollup's
|
// from the sidecar. Only `today`/`week`/`month` overlap with the rollup's
|
||||||
@@ -61,29 +57,20 @@ export const TeamDashboardPage = () => {
|
|||||||
subtitle={dateRangeLabel}
|
subtitle={dateRangeLabel}
|
||||||
infoText="Aggregated call metrics, agent performance, and operational alerts."
|
infoText="Aggregated call metrics, agent performance, and operational alerts."
|
||||||
controls={
|
controls={
|
||||||
<>
|
<div className="flex rounded-lg border border-secondary overflow-hidden">
|
||||||
<div className="flex rounded-lg border border-secondary overflow-hidden">
|
{(['today', 'week', 'month'] as const).map((range) => (
|
||||||
{(['today', 'week', 'month'] as const).map((range) => (
|
<button
|
||||||
<button
|
key={range}
|
||||||
key={range}
|
onClick={() => setDateRange(range)}
|
||||||
onClick={() => setDateRange(range)}
|
className={cx(
|
||||||
className={cx(
|
"px-3 py-1 text-xs font-medium transition duration-100 ease-linear",
|
||||||
"px-3 py-1 text-xs font-medium transition duration-100 ease-linear",
|
dateRange === range ? 'bg-active text-brand-secondary' : 'text-tertiary hover:bg-primary_hover',
|
||||||
dateRange === range ? 'bg-active text-brand-secondary' : 'text-tertiary hover:bg-primary_hover',
|
)}
|
||||||
)}
|
>
|
||||||
>
|
{range === 'today' ? 'Today' : range === 'week' ? 'Week' : 'Month'}
|
||||||
{range === 'today' ? 'Today' : range === 'week' ? 'Week' : 'Month'}
|
</button>
|
||||||
</button>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setAiOpen(!aiOpen)}
|
|
||||||
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
|
||||||
title={aiOpen ? 'Hide AI panel' : 'Show AI panel'}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={aiOpen ? faSidebarFlip : faSidebar} className="size-4" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -154,17 +141,6 @@ export const TeamDashboardPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI panel — collapsible */}
|
|
||||||
<div className={cx(
|
|
||||||
"shrink-0 border-l border-secondary bg-primary flex flex-col overflow-hidden transition-all duration-200 ease-linear",
|
|
||||||
aiOpen ? "w-[380px]" : "w-0 border-l-0",
|
|
||||||
)}>
|
|
||||||
{aiOpen && (
|
|
||||||
<div className="flex h-full flex-col p-4">
|
|
||||||
<AiChatPanel callerContext={{ type: 'supervisor' }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user