mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
feat: wire frontend to platform data, migrate to Jotai + Vercel AI SDK
- Replace mock DataProvider with real GraphQL queries through sidecar - Add queries.ts and transforms.ts for platform field name mapping - Migrate SIP state from React Context to Jotai atoms (React 19 compat) - Add singleton SIP manager to survive StrictMode remounts - Remove hardcoded Olivia/Sienna accounts from nav menu - Add password eye toggle, remember me checkbox, forgot password link - Fix worklist hook to transform platform field names - Add seed scripts for clinics, health packages, lab tests - Update test harness for new doctor→clinic relation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,24 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { createContext, useContext, useState } from 'react';
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import {
|
||||
LEADS_QUERY,
|
||||
CAMPAIGNS_QUERY,
|
||||
ADS_QUERY,
|
||||
FOLLOW_UPS_QUERY,
|
||||
LEAD_ACTIVITIES_QUERY,
|
||||
CALLS_QUERY,
|
||||
} from '@/lib/queries';
|
||||
import {
|
||||
transformLeads,
|
||||
transformCampaigns,
|
||||
transformAds,
|
||||
transformFollowUps,
|
||||
transformLeadActivities,
|
||||
transformCalls,
|
||||
} from '@/lib/transforms';
|
||||
|
||||
import type { Lead, Campaign, Ad, LeadActivity, FollowUp, WhatsAppTemplate, Agent, Call, LeadIngestionSource } from '@/types/entities';
|
||||
import { mockLeads, mockCampaigns, mockAds, mockFollowUps, mockLeadActivities, mockTemplates, mockAgents, mockCalls, mockIngestionSources } from '@/mocks';
|
||||
|
||||
type DataContextType = {
|
||||
leads: Lead[];
|
||||
@@ -14,8 +30,11 @@ type DataContextType = {
|
||||
agents: Agent[];
|
||||
calls: Call[];
|
||||
ingestionSources: LeadIngestionSource[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
updateLead: (id: string, updates: Partial<Lead>) => void;
|
||||
addCall: (call: Call) => void;
|
||||
refresh: () => void;
|
||||
};
|
||||
|
||||
const DataContext = createContext<DataContextType | undefined>(undefined);
|
||||
@@ -35,15 +54,56 @@ interface DataProviderProps {
|
||||
}
|
||||
|
||||
export const DataProvider = ({ children }: DataProviderProps) => {
|
||||
const [leads, setLeads] = useState<Lead[]>(mockLeads);
|
||||
const [campaigns] = useState<Campaign[]>(mockCampaigns);
|
||||
const [ads] = useState<Ad[]>(mockAds);
|
||||
const [followUps] = useState<FollowUp[]>(mockFollowUps);
|
||||
const [leadActivities] = useState<LeadActivity[]>(mockLeadActivities);
|
||||
const [templates] = useState<WhatsAppTemplate[]>(mockTemplates);
|
||||
const [agents] = useState<Agent[]>(mockAgents);
|
||||
const [calls, setCalls] = useState<Call[]>(mockCalls);
|
||||
const [ingestionSources] = useState<LeadIngestionSource[]>(mockIngestionSources);
|
||||
const [leads, setLeads] = useState<Lead[]>([]);
|
||||
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
||||
const [ads, setAds] = useState<Ad[]>([]);
|
||||
const [followUps, setFollowUps] = useState<FollowUp[]>([]);
|
||||
const [leadActivities, setLeadActivities] = useState<LeadActivity[]>([]);
|
||||
const [calls, setCalls] = useState<Call[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// These don't have platform entities yet — empty for now
|
||||
const [templates] = useState<WhatsAppTemplate[]>([]);
|
||||
const [agents] = useState<Agent[]>([]);
|
||||
const [ingestionSources] = useState<LeadIngestionSource[]>([]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!apiClient.isAuthenticated()) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData] = await Promise.all([
|
||||
apiClient.graphql<any>(LEADS_QUERY),
|
||||
apiClient.graphql<any>(CAMPAIGNS_QUERY),
|
||||
apiClient.graphql<any>(ADS_QUERY),
|
||||
apiClient.graphql<any>(FOLLOW_UPS_QUERY),
|
||||
apiClient.graphql<any>(LEAD_ACTIVITIES_QUERY),
|
||||
apiClient.graphql<any>(CALLS_QUERY),
|
||||
]);
|
||||
|
||||
setLeads(transformLeads(leadsData));
|
||||
setCampaigns(transformCampaigns(campaignsData));
|
||||
setAds(transformAds(adsData));
|
||||
setFollowUps(transformFollowUps(followUpsData));
|
||||
setLeadActivities(transformLeadActivities(activitiesData));
|
||||
setCalls(transformCalls(callsData));
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch platform data:', err);
|
||||
setError(err.message ?? 'Failed to load data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const updateLead = (id: string, updates: Partial<Lead>) => {
|
||||
setLeads((prev) => prev.map((lead) => (lead.id === id ? { ...lead, ...updates } : lead)));
|
||||
@@ -54,7 +114,11 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<DataContext.Provider value={{ leads, campaigns, ads, followUps, leadActivities, templates, agents, calls, ingestionSources, updateLead, addCall }}>
|
||||
<DataContext.Provider value={{
|
||||
leads, campaigns, ads, followUps, leadActivities, templates, agents, calls, ingestionSources,
|
||||
loading, error,
|
||||
updateLead, addCall, refresh: fetchData,
|
||||
}}>
|
||||
{children}
|
||||
</DataContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,43 +1,135 @@
|
||||
import { createContext, useContext, useEffect, useState, type PropsWithChildren } from 'react';
|
||||
import { useSipPhone } from '@/hooks/use-sip-phone';
|
||||
import { useEffect, useCallback, type PropsWithChildren } from 'react';
|
||||
import { useAtom, useSetAtom } from 'jotai';
|
||||
import {
|
||||
sipConnectionStatusAtom,
|
||||
sipCallStateAtom,
|
||||
sipCallerNumberAtom,
|
||||
sipIsMutedAtom,
|
||||
sipIsOnHoldAtom,
|
||||
sipCallDurationAtom,
|
||||
sipCallStartTimeAtom,
|
||||
} from '@/state/sip-state';
|
||||
import { registerSipStateUpdater, connectSip, disconnectSip, getSipClient } from '@/state/sip-manager';
|
||||
import type { SIPConfig } from '@/types/sip';
|
||||
|
||||
type SipContextType = ReturnType<typeof useSipPhone> & {
|
||||
ozonetelStatus: 'idle' | 'logging-in' | 'logged-in' | 'error';
|
||||
ozonetelError: string | null;
|
||||
const DEFAULT_CONFIG: SIPConfig = {
|
||||
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',
|
||||
};
|
||||
|
||||
const SipContext = createContext<SipContextType | null>(null);
|
||||
|
||||
// Module-level flag — survives React StrictMode double-mount
|
||||
let sipConnectedGlobal = false;
|
||||
|
||||
export const SipProvider = ({ children }: PropsWithChildren) => {
|
||||
const sipPhone = useSipPhone();
|
||||
const [ozonetelStatus, setOzonetelStatus] = useState<'idle' | 'logging-in' | 'logged-in' | 'error'>('idle');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [ozonetelError, _setOzonetelError] = useState<string | null>(null);
|
||||
const [, setConnectionStatus] = useAtom(sipConnectionStatusAtom);
|
||||
const [callState, setCallState] = useAtom(sipCallStateAtom);
|
||||
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
||||
const setCallDuration = useSetAtom(sipCallDurationAtom);
|
||||
const setCallStartTime = useSetAtom(sipCallStartTimeAtom);
|
||||
|
||||
// Auto-connect SIP on mount — module-level guard prevents duplicate connections
|
||||
// Register Jotai setters so the singleton SIP manager can update atoms
|
||||
useEffect(() => {
|
||||
if (!sipConnectedGlobal) {
|
||||
sipConnectedGlobal = true;
|
||||
sipPhone.connect();
|
||||
setOzonetelStatus('logged-in');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
registerSipStateUpdater({
|
||||
setConnectionStatus,
|
||||
setCallState,
|
||||
setCallerNumber,
|
||||
});
|
||||
}, [setConnectionStatus, setCallState, setCallerNumber]);
|
||||
|
||||
// Auto-connect SIP on mount
|
||||
useEffect(() => {
|
||||
connectSip(DEFAULT_CONFIG);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SipContext.Provider value={{ ...sipPhone, ozonetelStatus, ozonetelError }}>
|
||||
{children}
|
||||
</SipContext.Provider>
|
||||
);
|
||||
// Call duration timer
|
||||
useEffect(() => {
|
||||
if (callState === 'active') {
|
||||
const start = new Date();
|
||||
setCallStartTime(start);
|
||||
const interval = window.setInterval(() => {
|
||||
setCallDuration(Math.floor((Date.now() - start.getTime()) / 1000));
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
setCallDuration(0);
|
||||
setCallStartTime(null);
|
||||
}, [callState, setCallDuration, setCallStartTime]);
|
||||
|
||||
// Auto-reset to idle after ended/failed
|
||||
useEffect(() => {
|
||||
if (callState === 'ended' || callState === 'failed') {
|
||||
const timer = setTimeout(() => {
|
||||
setCallState('idle');
|
||||
setCallerNumber(null);
|
||||
}, 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [callState, setCallState, setCallerNumber]);
|
||||
|
||||
// Cleanup on page unload
|
||||
useEffect(() => {
|
||||
const handleUnload = () => disconnectSip();
|
||||
window.addEventListener('beforeunload', handleUnload);
|
||||
return () => window.removeEventListener('beforeunload', handleUnload);
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export const useSip = (): SipContextType => {
|
||||
const ctx = useContext(SipContext);
|
||||
if (ctx === null) {
|
||||
throw new Error('useSip must be used within a SipProvider');
|
||||
}
|
||||
return ctx;
|
||||
// Hook for components to access SIP actions + state
|
||||
export const useSip = () => {
|
||||
const [connectionStatus] = useAtom(sipConnectionStatusAtom);
|
||||
const [callState] = useAtom(sipCallStateAtom);
|
||||
const [callerNumber, setCallerNumber] = useAtom(sipCallerNumberAtom);
|
||||
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 answer = useCallback(() => getSipClient()?.answer(), []);
|
||||
const reject = useCallback(() => getSipClient()?.reject(), []);
|
||||
const hangup = useCallback(() => getSipClient()?.hangup(), []);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
if (isMuted) {
|
||||
getSipClient()?.unmute();
|
||||
} else {
|
||||
getSipClient()?.mute();
|
||||
}
|
||||
setIsMuted(!isMuted);
|
||||
}, [isMuted, setIsMuted]);
|
||||
|
||||
const toggleHold = useCallback(() => {
|
||||
if (isOnHold) {
|
||||
getSipClient()?.unhold();
|
||||
} else {
|
||||
getSipClient()?.hold();
|
||||
}
|
||||
setIsOnHold(!isOnHold);
|
||||
}, [isOnHold, setIsOnHold]);
|
||||
|
||||
return {
|
||||
connectionStatus,
|
||||
callState,
|
||||
callerNumber,
|
||||
isMuted,
|
||||
isOnHold,
|
||||
callDuration,
|
||||
isRegistered: connectionStatus === 'registered',
|
||||
isInCall: ['ringing-in', 'ringing-out', 'active'].includes(callState),
|
||||
ozonetelStatus: 'logged-in' as const,
|
||||
ozonetelError: null as string | null,
|
||||
connect: () => connectSip(DEFAULT_CONFIG),
|
||||
disconnect: disconnectSip,
|
||||
makeCall,
|
||||
answer,
|
||||
reject,
|
||||
hangup,
|
||||
toggleMute,
|
||||
toggleHold,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user