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 { useState } from "react";
import { type FC, useState } from "react";
import { faPhone } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useSetAtom } from "jotai";
import { Button } from "@/components/base/buttons/button";
import { apiClient } from "@/lib/api-client";
import { notify } from "@/lib/toast";
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
const Phone01: FC<{ className?: string } & Record<string, any>> = ({ className, ...rest }) => (
@@ -23,33 +18,14 @@ interface ClickToCallButtonProps {
}
export const ClickToCallButton = ({ phoneNumber, label, size = "sm" }: ClickToCallButtonProps) => {
const { isRegistered, isInCall } = useSip();
const { isRegistered, isInCall, dialOutbound } = useSip();
const [dialing, setDialing] = useState(false);
const setCallState = useSetAtom(sipCallStateAtom);
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
const setCallUcid = useSetAtom(sipCallUcidAtom);
const handleDial = async () => {
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 {
const result = await apiClient.post<{ ucid?: string; status?: string }>("/api/ozonetel/dial", { phoneNumber });
if (result?.ucid) {
setCallUcid(result.ucid);
}
await dialOutbound(phoneNumber);
} catch {
clearTimeout(safetyTimer);
setCallState("idle");
setCallerNumber(null);
setOutboundPending(false);
setCallUcid(null);
notify.error("Dial Failed", "Could not place the call");
} finally {
setDialing(false);

View File

@@ -1,12 +1,8 @@
import { useEffect, useRef, useState } from "react";
import { faCommentDots, faEllipsisVertical, faMessageDots, faPhone } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useSetAtom } from "jotai";
import { apiClient } from "@/lib/api-client";
import { notify } from "@/lib/toast";
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";
type PhoneActionCellProps = {
@@ -16,10 +12,7 @@ type PhoneActionCellProps = {
};
export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId }: PhoneActionCellProps) => {
const { isRegistered, isInCall } = useSip();
const setCallState = useSetAtom(sipCallStateAtom);
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
const setCallUcid = useSetAtom(sipCallUcidAtom);
const { isRegistered, isInCall, dialOutbound } = useSip();
const [menuOpen, setMenuOpen] = useState(false);
const [dialing, setDialing] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
@@ -41,20 +34,9 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId }:
if (!isRegistered || isInCall || dialing) return;
setMenuOpen(false);
setDialing(true);
setCallState("ringing-out");
setCallerNumber(phoneNumber);
setOutboundPending(true);
const safetyTimer = setTimeout(() => setOutboundPending(false), 30000);
try {
const result = await apiClient.post<{ ucid?: string }>("/api/ozonetel/dial", { phoneNumber });
if (result?.ucid) setCallUcid(result.ucid);
await dialOutbound(phoneNumber);
} catch {
clearTimeout(safetyTimer);
setCallState("idle");
setCallerNumber(null);
setOutboundPending(false);
setCallUcid(null);
notify.error("Dial Failed", "Could not place the call");
} finally {
setDialing(false);

View File

@@ -143,9 +143,9 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
setLogoutOpen(true);
};
const confirmSignOut = () => {
const confirmSignOut = async () => {
setLogoutOpen(false);
logout();
await logout();
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 { AgentStatusToggle } from "@/components/call-desk/agent-status-toggle";
import { ContextPanel } from "@/components/call-desk/context-panel";
import { WorklistPanel } from "@/components/call-desk/worklist-panel";
import type { WorklistLead } from "@/components/call-desk/worklist-panel";
import { type WorklistLead, WorklistPanel } from "@/components/call-desk/worklist-panel";
import { useWorklist } from "@/hooks/use-worklist";
import { apiClient } from "@/lib/api-client";
import { notify } from "@/lib/toast";
import { useAuth } from "@/providers/auth-provider";
import { useData } from "@/providers/data-provider";
@@ -18,7 +16,7 @@ import { cx } from "@/utils/cx";
export const CallDeskPage = () => {
const { user } = useAuth();
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 [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
const [contextOpen, setContextOpen] = useState(true);
@@ -36,7 +34,7 @@ export const CallDeskPage = () => {
}
setDialling(true);
try {
await apiClient.post("/api/ozonetel/dial", { phoneNumber: num });
await dialOutbound(num);
setDiallerOpen(false);
setDialNumber("");
} catch {
@@ -96,9 +94,15 @@ export const CallDeskPage = () => {
</button>
</div>
<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">
{dialNumber || <span className="text-sm font-normal text-placeholder">Enter number</span>}
</span>
<input
type="tel"
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 && (
<button
onClick={() => setDialNumber(dialNumber.slice(0, -1))}

View File

@@ -1,5 +1,4 @@
import type { ReactNode } from "react";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { type ReactNode, createContext, useCallback, useContext, useEffect, useState } from "react";
export type Role = "executive" | "admin" | "cc-agent";
@@ -21,7 +20,7 @@ type AuthContextType = {
loading: boolean;
loginWithUser: (userData: User) => void;
login: () => void;
logout: () => void;
logout: () => Promise<void>;
setRole: (role: Role) => void;
};
@@ -78,7 +77,6 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
localStorage.removeItem("helix_access_token");
localStorage.removeItem("helix_refresh_token");
localStorage.removeItem(STORAGE_KEY);
// eslint-disable-next-line react-hooks/set-state-in-effect
setLoading(false);
}
}, [isAuthenticated, persisted]);
@@ -96,15 +94,28 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
setIsAuthenticated(true);
}, []);
const logout = useCallback(() => {
// Notify sidecar to unlock Redis session + Ozonetel logout
const logout = useCallback(async () => {
// 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");
if (token) {
const apiUrl = import.meta.env.VITE_API_URL ?? "http://localhost:4100";
fetch(`${apiUrl}/auth/logout`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
}).catch(() => {});
try {
await fetch(`${apiUrl}/auth/logout`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(5000),
});
} catch (err) {
console.warn("Logout cleanup failed:", err);
}
}
setUser(DEFAULT_USER);
@@ -124,7 +135,19 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
}, []);
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}
</AuthContext.Provider>
);

View File

@@ -1,6 +1,7 @@
import { type PropsWithChildren, useCallback, useEffect } from "react";
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 {
sipCallDurationAtom,
sipCallStartTimeAtom,
@@ -87,11 +88,14 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
// No auto-reset — the ActiveCallCard handles post-call flow (disposition → appointment → done)
// and resets to idle via the "Back to Worklist" button
// Cleanup on page unload
// Cleanup on unmount + page unload
useEffect(() => {
const handleUnload = () => disconnectSip();
window.addEventListener("beforeunload", handleUnload);
return () => window.removeEventListener("beforeunload", handleUnload);
return () => {
window.removeEventListener("beforeunload", handleUnload);
disconnectSip();
};
}, []);
return <>{children}</>;
@@ -101,7 +105,7 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
// eslint-disable-next-line react-refresh/only-export-components
export const useSip = () => {
const [connectionStatus] = useAtom(sipConnectionStatusAtom);
const [callState] = useAtom(sipCallStateAtom);
const [callState, setCallState] = useAtom(sipCallStateAtom);
const [callerNumber, setCallerNumber] = useAtom(sipCallerNumberAtom);
const [callUcid] = useAtom(sipCallUcidAtom);
const [isMuted, setIsMuted] = useAtom(sipIsMutedAtom);
@@ -116,6 +120,27 @@ export const useSip = () => {
[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 reject = useCallback(() => getSipClient()?.reject(), []);
const hangup = useCallback(() => getSipClient()?.hangup(), []);
@@ -153,6 +178,7 @@ export const useSip = () => {
connect: () => connectSip(getSipConfig()),
disconnect: disconnectSip,
makeCall,
dialOutbound,
answer,
reject,
hangup,