Merge branch 'dev-main' into dev-kartik

This commit is contained in:
Kartik Datrika
2026-03-25 11:48:00 +05:30
6 changed files with 83 additions and 72 deletions

View File

@@ -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);

View File

@@ -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);

View File

@@ -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");
}; };

View File

@@ -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))}

View File

@@ -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 {
method: "POST", await fetch(`${apiUrl}/auth/logout`, {
headers: { Authorization: `Bearer ${token}` }, method: "POST",
}).catch(() => {}); headers: { Authorization: `Bearer ${token}` },
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>
); );

View File

@@ -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,