import { useCallback, useEffect, useMemo, useState } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faHeadset, faTrash } from '@fortawesome/pro-duotone-svg-icons'; import { WizardStep } from './wizard-step'; import { TelephonyRightPane, type SipSeatSummary } from './wizard-right-panes'; import { Select } from '@/components/base/select/select'; import { Button } from '@/components/base/buttons/button'; import { apiClient } from '@/lib/api-client'; import { notify } from '@/lib/toast'; import type { WizardStepComponentProps } from './wizard-step-types'; // Telephony step (post-3-pane rework). The middle pane is now an // assign/unassign editor: pick a SIP seat, pick a workspace member, // click Assign — or pick an already-mapped seat and click Unassign. // The right pane shows the live current state (read-only mapping // summary). Editing here calls updateAgent to set/clear // workspaceMemberId, then refetches. // // SIP seats themselves are pre-provisioned by onboard-hospital.sh // (see step 5b) — admins can't add or delete seats from this UI, // only link them to people. To add a new seat, contact support. type AgentRow = { id: string; name: string | null; sipExtension: string | null; ozonetelAgentId: string | null; workspaceMemberId: string | null; workspaceMember: { id: string; name: { firstName: string | null; lastName: string | null } | null; userEmail: string; } | null; }; type WorkspaceMemberRow = { id: string; userEmail: string; name: { firstName: string | null; lastName: string | null } | null; }; const AI_EMAIL_SUFFIX = '@ai.fortytwo.local'; const memberDisplayName = (m: { name: { firstName: string | null; lastName: string | null } | null; userEmail: string; }): string => { const first = m.name?.firstName?.trim() ?? ''; const last = m.name?.lastName?.trim() ?? ''; const full = `${first} ${last}`.trim(); return full.length > 0 ? full : m.userEmail; }; export const WizardStepTelephony = (props: WizardStepComponentProps) => { const [agents, setAgents] = useState([]); const [members, setMembers] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); // Editor state — which seat is selected, which member to assign. const [selectedSeatId, setSelectedSeatId] = useState(''); const [selectedMemberId, setSelectedMemberId] = useState(''); const fetchData = useCallback(async () => { try { const data = await apiClient.graphql<{ agents: { edges: { node: AgentRow }[] }; workspaceMembers: { edges: { node: WorkspaceMemberRow }[] }; }>( `{ agents(first: 100) { edges { node { id name sipExtension ozonetelAgentId workspaceMemberId workspaceMember { id name { firstName lastName } userEmail } } } } workspaceMembers(first: 200) { edges { node { id userEmail name { firstName lastName } } } } }`, undefined, { silent: true }, ); setAgents(data.agents.edges.map((e) => e.node)); setMembers( data.workspaceMembers.edges .map((e) => e.node) .filter((m) => !m.userEmail.endsWith(AI_EMAIL_SUFFIX)), ); } catch (err) { console.error('[wizard/telephony] fetch failed', err); } finally { setLoading(false); } }, []); useEffect(() => { fetchData(); }, [fetchData]); // Map every agent to a SipSeatSummary for the right pane. Single // source of truth — both panes read from `agents`. const seatSummaries = useMemo( () => agents.map((a) => ({ id: a.id, sipExtension: a.sipExtension, ozonetelAgentId: a.ozonetelAgentId, workspaceMember: a.workspaceMember, })), [agents], ); // Pre-compute lookups for the editor — which member already owns // each seat, and which members are already taken (so the dropdown // can hide them). const takenMemberIds = useMemo( () => new Set( agents .filter((a) => a.workspaceMemberId !== null) .map((a) => a.workspaceMemberId!), ), [agents], ); const seatItems = useMemo( () => agents.map((a) => ({ id: a.id, label: `Ext ${a.sipExtension ?? '—'}`, supportingText: a.workspaceMember ? `Currently: ${memberDisplayName(a.workspaceMember)}` : 'Unassigned', })), [agents], ); // Members dropdown — when a seat is selected and the seat is // currently mapped, force the member field to show the current // owner so the admin can see who they're displacing. When seat // is unassigned, only show free members (the takenMemberIds // filter). const memberItems = useMemo(() => { const selectedSeat = agents.find((a) => a.id === selectedSeatId); const currentOwnerId = selectedSeat?.workspaceMemberId ?? null; return members .filter((m) => m.id === currentOwnerId || !takenMemberIds.has(m.id)) .map((m) => ({ id: m.id, label: memberDisplayName(m), supportingText: m.userEmail, })); }, [members, agents, selectedSeatId, takenMemberIds]); // When the admin picks a seat, default the member dropdown to // whoever currently owns it (if anyone) so Unassign just works. useEffect(() => { if (!selectedSeatId) { setSelectedMemberId(''); return; } const seat = agents.find((a) => a.id === selectedSeatId); setSelectedMemberId(seat?.workspaceMemberId ?? ''); }, [selectedSeatId, agents]); const selectedSeat = agents.find((a) => a.id === selectedSeatId); const isCurrentlyMapped = selectedSeat?.workspaceMemberId !== null && selectedSeat?.workspaceMemberId !== undefined; const updateSeat = async (seatId: string, workspaceMemberId: string | null) => { setSaving(true); try { await apiClient.graphql( `mutation UpdateAgent($id: UUID!, $data: AgentUpdateInput!) { updateAgent(id: $id, data: $data) { id workspaceMemberId } }`, { id: seatId, data: { workspaceMemberId } }, ); await fetchData(); // Mark the step complete on first successful action so // the wizard can advance. Subsequent edits don't re-mark. if (!props.isCompleted) { await props.onComplete('telephony'); } // Clear editor selection so the admin starts the next // assign from scratch. setSelectedSeatId(''); setSelectedMemberId(''); } catch (err) { console.error('[wizard/telephony] updateAgent failed', err); } finally { setSaving(false); } }; const handleAssign = () => { if (!selectedSeatId || !selectedMemberId) { notify.error('Pick a seat and a member to assign'); return; } updateSeat(selectedSeatId, selectedMemberId); }; const handleUnassign = () => { if (!selectedSeatId) return; updateSeat(selectedSeatId, null); }; const pretendCompleted = props.isCompleted || agents.some((a) => a.workspaceMemberId !== null); return ( { if (!props.isCompleted) { await props.onComplete('telephony'); } props.onAdvance(); }} onFinish={props.onFinish} saving={saving} rightPane={} > {loading ? (

Loading SIP seats…

) : agents.length === 0 ? (

No SIP seats configured

This hospital has no pre-provisioned agent profiles. Contact support to add SIP seats, then come back to finish setup.

) : (

Pick a SIP seat and assign it to a workspace member. To free up a seat, select it and click Unassign. The right pane shows the live mapping — what you change here updates there immediately.

{isCurrentlyMapped && ( )}
)}
); };