mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-12 02:38:15 +00:00
Merge branch 'dev-main' into dev-kartik
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
87
src/providers/theme-token-provider.tsx
Normal file
87
src/providers/theme-token-provider.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user