Merge branch 'dev-main' into dev-kartik

This commit is contained in:
Kartik Datrika
2026-04-06 10:48:09 +05:30
95 changed files with 20807 additions and 10621 deletions

View File

@@ -1,16 +1,28 @@
import { type ReactNode, createContext, useCallback, useContext, useEffect, useState } from "react";
import { apiClient } from "@/lib/api-client";
import { ADS_QUERY, CALLS_QUERY, CAMPAIGNS_QUERY, FOLLOW_UPS_QUERY, LEADS_QUERY, LEAD_ACTIVITIES_QUERY, PATIENTS_QUERY } from "@/lib/queries";
import type { ReactNode } from 'react';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { apiClient } from '@/lib/api-client';
import {
transformAds,
transformCalls,
LEADS_QUERY,
CAMPAIGNS_QUERY,
ADS_QUERY,
FOLLOW_UPS_QUERY,
LEAD_ACTIVITIES_QUERY,
CALLS_QUERY,
APPOINTMENTS_QUERY,
PATIENTS_QUERY,
} from '@/lib/queries';
import {
transformLeads,
transformCampaigns,
transformAds,
transformFollowUps,
transformLeadActivities,
transformLeads,
transformCalls,
transformAppointments,
transformPatients,
} from "@/lib/transforms";
import type { Ad, Agent, Call, Campaign, FollowUp, Lead, LeadActivity, LeadIngestionSource, Patient, WhatsAppTemplate } from "@/types/entities";
} from '@/lib/transforms';
import type { Lead, Campaign, Ad, LeadActivity, FollowUp, WhatsAppTemplate, Agent, Call, LeadIngestionSource, Patient, Appointment } from '@/types/entities';
type DataContextType = {
leads: Lead[];
@@ -21,6 +33,7 @@ type DataContextType = {
templates: WhatsAppTemplate[];
agents: Agent[];
calls: Call[];
appointments: Appointment[];
patients: Patient[];
ingestionSources: LeadIngestionSource[];
loading: boolean;
@@ -32,12 +45,11 @@ type DataContextType = {
const DataContext = createContext<DataContextType | undefined>(undefined);
// eslint-disable-next-line react-refresh/only-export-components
export const useData = (): DataContextType => {
const context = useContext(DataContext);
if (context === undefined) {
throw new Error("useData must be used within a DataProvider");
throw new Error('useData must be used within a DataProvider');
}
return context;
@@ -54,6 +66,7 @@ export const DataProvider = ({ children }: DataProviderProps) => {
const [followUps, setFollowUps] = useState<FollowUp[]>([]);
const [leadActivities, setLeadActivities] = useState<LeadActivity[]>([]);
const [calls, setCalls] = useState<Call[]>([]);
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [patients, setPatients] = useState<Patient[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -75,20 +88,14 @@ export const DataProvider = ({ children }: DataProviderProps) => {
try {
const gql = <T,>(query: string) => apiClient.graphql<T>(query, undefined, { silent: true }).catch(() => null);
const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData, patientsData] = await Promise.all([
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData, appointmentsData, patientsData] = await Promise.all([
gql<any>(LEADS_QUERY),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
gql<any>(CAMPAIGNS_QUERY),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
gql<any>(ADS_QUERY),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
gql<any>(FOLLOW_UPS_QUERY),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
gql<any>(LEAD_ACTIVITIES_QUERY),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
gql<any>(CALLS_QUERY),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
gql<any>(APPOINTMENTS_QUERY),
gql<any>(PATIENTS_QUERY),
]);
@@ -98,10 +105,10 @@ export const DataProvider = ({ children }: DataProviderProps) => {
if (followUpsData) setFollowUps(transformFollowUps(followUpsData));
if (activitiesData) setLeadActivities(transformLeadActivities(activitiesData));
if (callsData) setCalls(transformCalls(callsData));
if (appointmentsData) setAppointments(transformAppointments(appointmentsData));
if (patientsData) setPatients(transformPatients(patientsData));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
setError(err.message ?? "Failed to load data");
setError(err.message ?? 'Failed to load data');
} finally {
setLoading(false);
}
@@ -120,25 +127,11 @@ export const DataProvider = ({ children }: DataProviderProps) => {
};
return (
<DataContext.Provider
value={{
leads,
campaigns,
ads,
followUps,
leadActivities,
templates,
agents,
calls,
patients,
ingestionSources,
loading,
error,
updateLead,
addCall,
refresh: fetchData,
}}
>
<DataContext.Provider value={{
leads, campaigns, ads, followUps, leadActivities, templates, agents, calls, appointments, patients, ingestionSources,
loading, error,
updateLead, addCall, refresh: fetchData,
}}>
{children}
</DataContext.Provider>
);

View File

@@ -1,42 +1,38 @@
import { type PropsWithChildren, useCallback, useEffect } from "react";
import { useAtom, useSetAtom } from "jotai";
import { apiClient } from "@/lib/api-client";
import { connectSip, disconnectSip, getSipClient, registerSipStateUpdater, setOutboundPending } from "@/state/sip-manager";
import { useEffect, useCallback, type PropsWithChildren } from 'react';
import { useAtom, useSetAtom } from 'jotai';
import {
sipCallDurationAtom,
sipCallStartTimeAtom,
sipCallStateAtom,
sipCallUcidAtom,
sipCallerNumberAtom,
sipConnectionStatusAtom,
sipCallStateAtom,
sipCallerNumberAtom,
sipIsMutedAtom,
sipIsOnHoldAtom,
} from "@/state/sip-state";
import type { SIPConfig } from "@/types/sip";
sipCallDurationAtom,
sipCallStartTimeAtom,
sipCallUcidAtom,
} from '@/state/sip-state';
import { registerSipStateUpdater, connectSip, disconnectSip, getSipClient, setOutboundPending } from '@/state/sip-manager';
import { apiClient } from '@/lib/api-client';
import type { SIPConfig } from '@/types/sip';
const getSipConfig = (): SIPConfig => {
// SIP config comes exclusively from the Agent entity (stored on login).
// No env var fallback — users without an Agent entity don't connect SIP.
const getSipConfig = (): SIPConfig | null => {
try {
const stored = localStorage.getItem("helix_agent_config");
const stored = localStorage.getItem('helix_agent_config');
if (stored) {
const config = JSON.parse(stored);
return {
displayName: "Helix Agent",
uri: config.sipUri,
password: config.sipPassword,
wsServer: config.sipWsServer,
stunServers: "stun:stun.l.google.com:19302",
};
if (config.sipUri && config.sipWsServer) {
return {
displayName: 'Helix Agent',
uri: config.sipUri,
password: config.sipPassword,
wsServer: config.sipWsServer,
stunServers: 'stun:stun.l.google.com:19302',
};
}
}
} catch {
/* intentional */
}
return {
displayName: import.meta.env.VITE_SIP_DISPLAY_NAME ?? "Helix Agent",
uri: import.meta.env.VITE_SIP_URI ?? "",
password: import.meta.env.VITE_SIP_PASSWORD ?? "",
wsServer: import.meta.env.VITE_SIP_WS_SERVER ?? "",
stunServers: "stun:stun.l.google.com:19302",
};
} catch {}
return null;
};
export const SipProvider = ({ children }: PropsWithChildren) => {
@@ -57,14 +53,19 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
});
}, [setConnectionStatus, setCallState, setCallerNumber, setCallUcid]);
// Auto-connect SIP on mount
// Auto-connect SIP on mount — only if Agent entity has SIP config
useEffect(() => {
connectSip(getSipConfig());
const config = getSipConfig();
if (config) {
connectSip(config);
} else {
console.log('[SIP] No agent SIP config — skipping connection');
}
}, []);
// Call duration timer
useEffect(() => {
if (callState === "active") {
if (callState === 'active') {
const start = new Date();
setCallStartTime(start);
const interval = window.setInterval(() => {
@@ -78,10 +79,10 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
// Ringtone on incoming call
useEffect(() => {
if (callState === "ringing-in") {
import("@/lib/ringtone").then(({ startRingtone }) => startRingtone());
if (callState === 'ringing-in') {
import('@/lib/ringtone').then(({ startRingtone }) => startRingtone());
} else {
import("@/lib/ringtone").then(({ stopRingtone }) => stopRingtone());
import('@/lib/ringtone').then(({ stopRingtone }) => stopRingtone());
}
}, [callState]);
@@ -91,9 +92,9 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
// Cleanup on unmount + page unload
useEffect(() => {
const handleUnload = () => disconnectSip();
window.addEventListener("beforeunload", handleUnload);
window.addEventListener('beforeunload', handleUnload);
return () => {
window.removeEventListener("beforeunload", handleUnload);
window.removeEventListener('beforeunload', handleUnload);
disconnectSip();
};
}, []);
@@ -102,44 +103,49 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
};
// Hook for components to access SIP actions + state
// eslint-disable-next-line react-refresh/only-export-components
export const useSip = () => {
const [connectionStatus] = useAtom(sipConnectionStatusAtom);
const [callState, setCallState] = useAtom(sipCallStateAtom);
const [callerNumber, setCallerNumber] = useAtom(sipCallerNumberAtom);
const [callUcid] = useAtom(sipCallUcidAtom);
const [callUcid, setCallUcid] = useAtom(sipCallUcidAtom);
const [isMuted, setIsMuted] = useAtom(sipIsMutedAtom);
const [isOnHold, setIsOnHold] = useAtom(sipIsOnHoldAtom);
const [callDuration] = useAtom(sipCallDurationAtom);
const makeCall = useCallback(
(phoneNumber: string) => {
getSipClient()?.call(phoneNumber);
setCallerNumber(phoneNumber);
},
[setCallerNumber],
);
const makeCall = useCallback((phoneNumber: string) => {
getSipClient()?.call(phoneNumber);
setCallerNumber(phoneNumber);
}, [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);
const dialOutbound = useCallback(async (phoneNumber: string): Promise<void> => {
console.log(`[DIAL] Outbound dial started: phone=${phoneNumber}`);
setCallState('ringing-out');
setCallerNumber(phoneNumber);
setOutboundPending(true);
const safetyTimeout = setTimeout(() => {
console.warn('[DIAL] Safety timeout fired (30s) — clearing outboundPending');
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");
try {
const result = await apiClient.post<{ status: string; ucid?: string }>('/api/ozonetel/dial', { phoneNumber });
console.log('[DIAL] Dial API response:', result);
clearTimeout(safetyTimeout);
// Store UCID from dial response — SIP bridge doesn't carry X-UCID for outbound
if (result?.ucid) {
console.log(`[DIAL] Storing UCID from dial response: ${result.ucid}`);
setCallUcid(result.ucid);
}
},
[setCallState, setCallerNumber],
);
} catch (err) {
console.error('[DIAL] Dial API failed:', err);
clearTimeout(safetyTimeout);
setOutboundPending(false);
setCallState('idle');
setCallerNumber(null);
throw new Error('Dial failed');
}
}, [setCallState, setCallerNumber, setCallUcid]);
const answer = useCallback(() => getSipClient()?.answer(), []);
const reject = useCallback(() => getSipClient()?.reject(), []);
@@ -171,11 +177,11 @@ export const useSip = () => {
isMuted,
isOnHold,
callDuration,
isRegistered: connectionStatus === "registered",
isInCall: ["ringing-in", "ringing-out", "active"].includes(callState),
ozonetelStatus: "logged-in" as const,
isRegistered: connectionStatus === 'registered',
isInCall: ['ringing-in', 'ringing-out', 'active'].includes(callState),
ozonetelStatus: 'logged-in' as const,
ozonetelError: null as string | null,
connect: () => connectSip(getSipConfig()),
connect: () => { const c = getSipConfig(); if (c) connectSip(c); },
disconnect: disconnectSip,
makeCall,
dialOutbound,

View File

@@ -0,0 +1,87 @@
import type { ReactNode } from 'react';
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
const THEME_API_URL = import.meta.env.VITE_THEME_API_URL ?? import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
export type ThemeTokens = {
brand: { name: string; hospitalName: string; logo: string; favicon: string };
colors: { brand: Record<string, string> };
typography: { body: string; display: string };
login: { title: string; subtitle: string; showGoogleSignIn: boolean; showForgotPassword: boolean; poweredBy: { label: string; url: string } };
sidebar: { title: string; subtitle: string };
ai: { quickActions: Array<{ label: string; prompt: string }> };
};
const DEFAULT_TOKENS: ThemeTokens = {
brand: { name: 'Helix Engage', hospitalName: 'Global Hospital', logo: '/helix-logo.png', favicon: '/favicon.ico' },
colors: { brand: {
'25': 'rgb(239 246 255)', '50': 'rgb(219 234 254)', '100': 'rgb(191 219 254)',
'200': 'rgb(147 197 253)', '300': 'rgb(96 165 250)', '400': 'rgb(59 130 246)',
'500': 'rgb(37 99 235)', '600': 'rgb(29 78 216)', '700': 'rgb(30 64 175)',
'800': 'rgb(30 58 138)', '900': 'rgb(23 37 84)', '950': 'rgb(15 23 42)',
} },
typography: {
body: "'Satoshi', 'Inter', -apple-system, sans-serif",
display: "'General Sans', 'Inter', -apple-system, sans-serif",
},
login: { title: 'Sign in to Helix Engage', subtitle: 'Global Hospital', showGoogleSignIn: true, showForgotPassword: true, poweredBy: { label: 'Powered by F0rty2.ai', url: 'https://f0rty2.ai' } },
sidebar: { title: 'Helix Engage', subtitle: 'Global Hospital \u00b7 {role}' },
ai: { quickActions: [
{ label: 'Doctor availability', prompt: 'What doctors are available and what are their visiting hours?' },
{ label: 'Clinic timings', prompt: 'What are the clinic locations and timings?' },
{ label: 'Patient history', prompt: "Can you summarize this patient's history?" },
{ label: 'Treatment packages', prompt: 'What treatment packages are available?' },
] },
};
type ThemeTokenContextType = {
tokens: ThemeTokens;
refresh: () => Promise<void>;
};
const ThemeTokenContext = createContext<ThemeTokenContextType>({ tokens: DEFAULT_TOKENS, refresh: async () => {} });
export const useThemeTokens = () => useContext(ThemeTokenContext);
const applyColorTokens = (brandColors: Record<string, string>) => {
const root = document.documentElement;
for (const [stop, value] of Object.entries(brandColors)) {
root.style.setProperty(`--color-brand-${stop}`, value);
}
};
const applyTypographyTokens = (typography: { body: string; display: string }) => {
const root = document.documentElement;
if (typography.body) root.style.setProperty('--font-body', typography.body);
if (typography.display) root.style.setProperty('--font-display', typography.display);
};
export const ThemeTokenProvider = ({ children }: { children: ReactNode }) => {
const [tokens, setTokens] = useState<ThemeTokens>(DEFAULT_TOKENS);
const fetchTheme = useCallback(async () => {
try {
const res = await fetch(`${THEME_API_URL}/api/config/theme`);
if (res.ok) {
const data: ThemeTokens = await res.json();
setTokens(data);
if (data.colors?.brand && Object.keys(data.colors.brand).length > 0) {
applyColorTokens(data.colors.brand);
}
if (data.typography) {
applyTypographyTokens(data.typography);
}
}
} catch {
// Use defaults silently
}
}, []);
useEffect(() => { fetchTheme(); }, [fetchTheme]);
return (
<ThemeTokenContext.Provider value={{ tokens, refresh: fetchTheme }}>
{children}
</ThemeTokenContext.Provider>
);
};