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:
2026-03-18 16:44:45 +05:30
parent e01a4d7747
commit 61901eb8fb
14 changed files with 1208 additions and 108 deletions

View File

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