mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
Merge branch 'dev-main' into dev-kartik
This commit is contained in:
@@ -1,14 +1,9 @@
|
|||||||
import type { FC } from "react";
|
import { type FC, useState } from "react";
|
||||||
import { useState } from "react";
|
|
||||||
import { faPhone } from "@fortawesome/pro-duotone-svg-icons";
|
import { faPhone } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { useSetAtom } from "jotai";
|
|
||||||
import { Button } from "@/components/base/buttons/button";
|
import { Button } from "@/components/base/buttons/button";
|
||||||
import { apiClient } from "@/lib/api-client";
|
|
||||||
import { notify } from "@/lib/toast";
|
import { notify } from "@/lib/toast";
|
||||||
import { useSip } from "@/providers/sip-provider";
|
import { useSip } from "@/providers/sip-provider";
|
||||||
import { setOutboundPending } from "@/state/sip-manager";
|
|
||||||
import { sipCallStateAtom, sipCallUcidAtom, sipCallerNumberAtom } from "@/state/sip-state";
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const Phone01: FC<{ className?: string } & Record<string, any>> = ({ className, ...rest }) => (
|
const Phone01: FC<{ className?: string } & Record<string, any>> = ({ className, ...rest }) => (
|
||||||
@@ -23,33 +18,14 @@ interface ClickToCallButtonProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ClickToCallButton = ({ phoneNumber, label, size = "sm" }: ClickToCallButtonProps) => {
|
export const ClickToCallButton = ({ phoneNumber, label, size = "sm" }: ClickToCallButtonProps) => {
|
||||||
const { isRegistered, isInCall } = useSip();
|
const { isRegistered, isInCall, dialOutbound } = useSip();
|
||||||
const [dialing, setDialing] = useState(false);
|
const [dialing, setDialing] = useState(false);
|
||||||
const setCallState = useSetAtom(sipCallStateAtom);
|
|
||||||
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
|
||||||
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
|
||||||
|
|
||||||
const handleDial = async () => {
|
const handleDial = async () => {
|
||||||
setDialing(true);
|
setDialing(true);
|
||||||
|
|
||||||
// Show call UI immediately
|
|
||||||
setCallState("ringing-out");
|
|
||||||
setCallerNumber(phoneNumber);
|
|
||||||
setOutboundPending(true);
|
|
||||||
// Safety: reset flag if SIP INVITE doesn't arrive within 30s
|
|
||||||
const safetyTimer = setTimeout(() => setOutboundPending(false), 30000);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await apiClient.post<{ ucid?: string; status?: string }>("/api/ozonetel/dial", { phoneNumber });
|
await dialOutbound(phoneNumber);
|
||||||
if (result?.ucid) {
|
|
||||||
setCallUcid(result.ucid);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
clearTimeout(safetyTimer);
|
|
||||||
setCallState("idle");
|
|
||||||
setCallerNumber(null);
|
|
||||||
setOutboundPending(false);
|
|
||||||
setCallUcid(null);
|
|
||||||
notify.error("Dial Failed", "Could not place the call");
|
notify.error("Dial Failed", "Could not place the call");
|
||||||
} finally {
|
} finally {
|
||||||
setDialing(false);
|
setDialing(false);
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { faCommentDots, faEllipsisVertical, faMessageDots, faPhone } from "@fortawesome/pro-duotone-svg-icons";
|
import { faCommentDots, faEllipsisVertical, faMessageDots, faPhone } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { useSetAtom } from "jotai";
|
|
||||||
import { apiClient } from "@/lib/api-client";
|
|
||||||
import { notify } from "@/lib/toast";
|
import { notify } from "@/lib/toast";
|
||||||
import { useSip } from "@/providers/sip-provider";
|
import { useSip } from "@/providers/sip-provider";
|
||||||
import { setOutboundPending } from "@/state/sip-manager";
|
|
||||||
import { sipCallStateAtom, sipCallUcidAtom, sipCallerNumberAtom } from "@/state/sip-state";
|
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
type PhoneActionCellProps = {
|
type PhoneActionCellProps = {
|
||||||
@@ -16,10 +12,7 @@ type PhoneActionCellProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId }: PhoneActionCellProps) => {
|
export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId }: PhoneActionCellProps) => {
|
||||||
const { isRegistered, isInCall } = useSip();
|
const { isRegistered, isInCall, dialOutbound } = useSip();
|
||||||
const setCallState = useSetAtom(sipCallStateAtom);
|
|
||||||
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
|
||||||
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [dialing, setDialing] = useState(false);
|
const [dialing, setDialing] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -41,20 +34,9 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId }:
|
|||||||
if (!isRegistered || isInCall || dialing) return;
|
if (!isRegistered || isInCall || dialing) return;
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
setDialing(true);
|
setDialing(true);
|
||||||
setCallState("ringing-out");
|
|
||||||
setCallerNumber(phoneNumber);
|
|
||||||
setOutboundPending(true);
|
|
||||||
const safetyTimer = setTimeout(() => setOutboundPending(false), 30000);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await apiClient.post<{ ucid?: string }>("/api/ozonetel/dial", { phoneNumber });
|
await dialOutbound(phoneNumber);
|
||||||
if (result?.ucid) setCallUcid(result.ucid);
|
|
||||||
} catch {
|
} catch {
|
||||||
clearTimeout(safetyTimer);
|
|
||||||
setCallState("idle");
|
|
||||||
setCallerNumber(null);
|
|
||||||
setOutboundPending(false);
|
|
||||||
setCallUcid(null);
|
|
||||||
notify.error("Dial Failed", "Could not place the call");
|
notify.error("Dial Failed", "Could not place the call");
|
||||||
} finally {
|
} finally {
|
||||||
setDialing(false);
|
setDialing(false);
|
||||||
|
|||||||
@@ -143,9 +143,9 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
|||||||
setLogoutOpen(true);
|
setLogoutOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmSignOut = () => {
|
const confirmSignOut = async () => {
|
||||||
setLogoutOpen(false);
|
setLogoutOpen(false);
|
||||||
logout();
|
await logout();
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ import { Badge } from "@/components/base/badges/badges";
|
|||||||
import { ActiveCallCard } from "@/components/call-desk/active-call-card";
|
import { ActiveCallCard } from "@/components/call-desk/active-call-card";
|
||||||
import { AgentStatusToggle } from "@/components/call-desk/agent-status-toggle";
|
import { AgentStatusToggle } from "@/components/call-desk/agent-status-toggle";
|
||||||
import { ContextPanel } from "@/components/call-desk/context-panel";
|
import { ContextPanel } from "@/components/call-desk/context-panel";
|
||||||
import { WorklistPanel } from "@/components/call-desk/worklist-panel";
|
import { type WorklistLead, WorklistPanel } from "@/components/call-desk/worklist-panel";
|
||||||
import type { WorklistLead } from "@/components/call-desk/worklist-panel";
|
|
||||||
import { useWorklist } from "@/hooks/use-worklist";
|
import { useWorklist } from "@/hooks/use-worklist";
|
||||||
import { apiClient } from "@/lib/api-client";
|
|
||||||
import { notify } from "@/lib/toast";
|
import { notify } from "@/lib/toast";
|
||||||
import { useAuth } from "@/providers/auth-provider";
|
import { useAuth } from "@/providers/auth-provider";
|
||||||
import { useData } from "@/providers/data-provider";
|
import { useData } from "@/providers/data-provider";
|
||||||
@@ -18,7 +16,7 @@ import { cx } from "@/utils/cx";
|
|||||||
export const CallDeskPage = () => {
|
export const CallDeskPage = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { leadActivities } = useData();
|
const { leadActivities } = useData();
|
||||||
const { connectionStatus, isRegistered, callState, callerNumber, callUcid } = useSip();
|
const { connectionStatus, isRegistered, callState, callerNumber, callUcid, dialOutbound } = useSip();
|
||||||
const { missedCalls, followUps, marketingLeads, totalPending, loading } = useWorklist();
|
const { missedCalls, followUps, marketingLeads, totalPending, loading } = useWorklist();
|
||||||
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
|
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
|
||||||
const [contextOpen, setContextOpen] = useState(true);
|
const [contextOpen, setContextOpen] = useState(true);
|
||||||
@@ -36,7 +34,7 @@ export const CallDeskPage = () => {
|
|||||||
}
|
}
|
||||||
setDialling(true);
|
setDialling(true);
|
||||||
try {
|
try {
|
||||||
await apiClient.post("/api/ozonetel/dial", { phoneNumber: num });
|
await dialOutbound(num);
|
||||||
setDiallerOpen(false);
|
setDiallerOpen(false);
|
||||||
setDialNumber("");
|
setDialNumber("");
|
||||||
} catch {
|
} catch {
|
||||||
@@ -96,9 +94,15 @@ export const CallDeskPage = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-3 flex min-h-[40px] items-center gap-2 rounded-lg bg-secondary px-3 py-2.5">
|
<div className="mb-3 flex min-h-[40px] items-center gap-2 rounded-lg bg-secondary px-3 py-2.5">
|
||||||
<span className="flex-1 text-center text-lg font-semibold tracking-wider text-primary">
|
<input
|
||||||
{dialNumber || <span className="text-sm font-normal text-placeholder">Enter number</span>}
|
type="tel"
|
||||||
</span>
|
value={dialNumber}
|
||||||
|
onChange={(e) => setDialNumber(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleDial()}
|
||||||
|
placeholder="Enter number"
|
||||||
|
autoFocus
|
||||||
|
className="flex-1 bg-transparent text-center text-lg font-semibold tracking-wider text-primary outline-none placeholder:text-sm placeholder:font-normal placeholder:text-placeholder"
|
||||||
|
/>
|
||||||
{dialNumber && (
|
{dialNumber && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setDialNumber(dialNumber.slice(0, -1))}
|
onClick={() => setDialNumber(dialNumber.slice(0, -1))}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { ReactNode } from "react";
|
import { type ReactNode, createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
|
||||||
|
|
||||||
export type Role = "executive" | "admin" | "cc-agent";
|
export type Role = "executive" | "admin" | "cc-agent";
|
||||||
|
|
||||||
@@ -21,7 +20,7 @@ type AuthContextType = {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
loginWithUser: (userData: User) => void;
|
loginWithUser: (userData: User) => void;
|
||||||
login: () => void;
|
login: () => void;
|
||||||
logout: () => void;
|
logout: () => Promise<void>;
|
||||||
setRole: (role: Role) => void;
|
setRole: (role: Role) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,7 +77,6 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
|
|||||||
localStorage.removeItem("helix_access_token");
|
localStorage.removeItem("helix_access_token");
|
||||||
localStorage.removeItem("helix_refresh_token");
|
localStorage.removeItem("helix_refresh_token");
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, persisted]);
|
}, [isAuthenticated, persisted]);
|
||||||
@@ -96,15 +94,28 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
|
|||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(async () => {
|
||||||
// Notify sidecar to unlock Redis session + Ozonetel logout
|
// Disconnect SIP before logout
|
||||||
|
try {
|
||||||
|
const { disconnectSip } = await import("@/state/sip-manager");
|
||||||
|
disconnectSip();
|
||||||
|
} catch {
|
||||||
|
// SIP disconnect failed or manager not found — ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify sidecar to unlock Redis session + Ozonetel logout — await before clearing tokens
|
||||||
const token = localStorage.getItem("helix_access_token");
|
const token = localStorage.getItem("helix_access_token");
|
||||||
if (token) {
|
if (token) {
|
||||||
const apiUrl = import.meta.env.VITE_API_URL ?? "http://localhost:4100";
|
const apiUrl = import.meta.env.VITE_API_URL ?? "http://localhost:4100";
|
||||||
fetch(`${apiUrl}/auth/logout`, {
|
try {
|
||||||
|
await fetch(`${apiUrl}/auth/logout`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
}).catch(() => {});
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Logout cleanup failed:", err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setUser(DEFAULT_USER);
|
setUser(DEFAULT_USER);
|
||||||
@@ -124,7 +135,19 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, isAdmin, isCCAgent, isAuthenticated, loading, loginWithUser, login, logout, setRole }}>
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
user,
|
||||||
|
isAdmin,
|
||||||
|
isCCAgent,
|
||||||
|
isAuthenticated,
|
||||||
|
loading,
|
||||||
|
loginWithUser,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
setRole,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type PropsWithChildren, useCallback, useEffect } from "react";
|
import { type PropsWithChildren, useCallback, useEffect } from "react";
|
||||||
import { useAtom, useSetAtom } from "jotai";
|
import { useAtom, useSetAtom } from "jotai";
|
||||||
import { connectSip, disconnectSip, getSipClient, registerSipStateUpdater } from "@/state/sip-manager";
|
import { apiClient } from "@/lib/api-client";
|
||||||
|
import { connectSip, disconnectSip, getSipClient, registerSipStateUpdater, setOutboundPending } from "@/state/sip-manager";
|
||||||
import {
|
import {
|
||||||
sipCallDurationAtom,
|
sipCallDurationAtom,
|
||||||
sipCallStartTimeAtom,
|
sipCallStartTimeAtom,
|
||||||
@@ -87,11 +88,14 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
|
|||||||
// No auto-reset — the ActiveCallCard handles post-call flow (disposition → appointment → done)
|
// No auto-reset — the ActiveCallCard handles post-call flow (disposition → appointment → done)
|
||||||
// and resets to idle via the "Back to Worklist" button
|
// and resets to idle via the "Back to Worklist" button
|
||||||
|
|
||||||
// Cleanup on page unload
|
// Cleanup on unmount + page unload
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleUnload = () => disconnectSip();
|
const handleUnload = () => disconnectSip();
|
||||||
window.addEventListener("beforeunload", handleUnload);
|
window.addEventListener("beforeunload", handleUnload);
|
||||||
return () => window.removeEventListener("beforeunload", handleUnload);
|
return () => {
|
||||||
|
window.removeEventListener("beforeunload", handleUnload);
|
||||||
|
disconnectSip();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
@@ -101,7 +105,7 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
|
|||||||
// eslint-disable-next-line react-refresh/only-export-components
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const useSip = () => {
|
export const useSip = () => {
|
||||||
const [connectionStatus] = useAtom(sipConnectionStatusAtom);
|
const [connectionStatus] = useAtom(sipConnectionStatusAtom);
|
||||||
const [callState] = useAtom(sipCallStateAtom);
|
const [callState, setCallState] = useAtom(sipCallStateAtom);
|
||||||
const [callerNumber, setCallerNumber] = useAtom(sipCallerNumberAtom);
|
const [callerNumber, setCallerNumber] = useAtom(sipCallerNumberAtom);
|
||||||
const [callUcid] = useAtom(sipCallUcidAtom);
|
const [callUcid] = useAtom(sipCallUcidAtom);
|
||||||
const [isMuted, setIsMuted] = useAtom(sipIsMutedAtom);
|
const [isMuted, setIsMuted] = useAtom(sipIsMutedAtom);
|
||||||
@@ -116,6 +120,27 @@ export const useSip = () => {
|
|||||||
[setCallerNumber],
|
[setCallerNumber],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Ozonetel outbound dial — single path for all outbound calls
|
||||||
|
const dialOutbound = useCallback(
|
||||||
|
async (phoneNumber: string): Promise<void> => {
|
||||||
|
setCallState("ringing-out");
|
||||||
|
setCallerNumber(phoneNumber);
|
||||||
|
setOutboundPending(true);
|
||||||
|
const safetyTimeout = setTimeout(() => setOutboundPending(false), 30000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.post("/api/ozonetel/dial", { phoneNumber });
|
||||||
|
} catch {
|
||||||
|
clearTimeout(safetyTimeout);
|
||||||
|
setOutboundPending(false);
|
||||||
|
setCallState("idle");
|
||||||
|
setCallerNumber(null);
|
||||||
|
throw new Error("Dial failed");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setCallState, setCallerNumber],
|
||||||
|
);
|
||||||
|
|
||||||
const answer = useCallback(() => getSipClient()?.answer(), []);
|
const answer = useCallback(() => getSipClient()?.answer(), []);
|
||||||
const reject = useCallback(() => getSipClient()?.reject(), []);
|
const reject = useCallback(() => getSipClient()?.reject(), []);
|
||||||
const hangup = useCallback(() => getSipClient()?.hangup(), []);
|
const hangup = useCallback(() => getSipClient()?.hangup(), []);
|
||||||
@@ -153,6 +178,7 @@ export const useSip = () => {
|
|||||||
connect: () => connectSip(getSipConfig()),
|
connect: () => connectSip(getSipConfig()),
|
||||||
disconnect: disconnectSip,
|
disconnect: disconnectSip,
|
||||||
makeCall,
|
makeCall,
|
||||||
|
dialOutbound,
|
||||||
answer,
|
answer,
|
||||||
reject,
|
reject,
|
||||||
hangup,
|
hangup,
|
||||||
|
|||||||
Reference in New Issue
Block a user