Files
helix-engage/src/components/setup/wizard-step-telephony.tsx
saridsa2 f57fbc1f24 feat(onboarding/phase-6): setup wizard polish, seed script alignment, doctor visit slots
- Setup wizard: 3-pane layout with right-side live previews, resume
  banner, edit/copy icons on team step, AI prompt configuration
- Forms: employee-create replaces invite-member (no email invites),
  clinic form with address/hours/payment, doctor form with visit slots
- Seed script: aligned to current SDK schema — doctors created as
  workspace members (HelixEngage Manager role), visitingHours replaced
  by doctorVisitSlot entity, clinics seeded, portalUserId linked
  dynamically, SUB/ORIGIN/GQL configurable via env vars
- Pages: clinics + doctors CRUD updated for new schema, team settings
  with temp password + role assignment
- New components: time-picker, day-selector, wizard-right-panes,
  wizard-layout-context, resume-setup-banner
- Removed: invite-member-form (replaced by employee-create-form per
  no-email-invites rule)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:37:34 +05:30

323 lines
13 KiB
TypeScript

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<AgentRow[]>([]);
const [members, setMembers] = useState<WorkspaceMemberRow[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
// Editor state — which seat is selected, which member to assign.
const [selectedSeatId, setSelectedSeatId] = useState<string>('');
const [selectedMemberId, setSelectedMemberId] = useState<string>('');
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<SipSeatSummary[]>(
() =>
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 (
<WizardStep
step="telephony"
isCompleted={pretendCompleted}
isLast={props.isLast}
onPrev={props.onPrev}
onNext={props.onNext}
onMarkComplete={async () => {
if (!props.isCompleted) {
await props.onComplete('telephony');
}
props.onAdvance();
}}
onFinish={props.onFinish}
saving={saving}
rightPane={<TelephonyRightPane seats={seatSummaries} />}
>
{loading ? (
<p className="text-sm text-tertiary">Loading SIP seats</p>
) : agents.length === 0 ? (
<div className="rounded-lg border border-secondary bg-secondary p-6 text-sm text-tertiary">
<p className="font-medium text-primary">No SIP seats configured</p>
<p className="mt-1">
This hospital has no pre-provisioned agent profiles. Contact support to
add SIP seats, then come back to finish setup.
</p>
</div>
) : (
<div className="flex flex-col gap-5">
<div className="rounded-lg border border-secondary bg-secondary p-4 text-xs text-tertiary">
<p>
Pick a SIP seat and assign it to a workspace member. To free up a seat,
select it and click <b>Unassign</b>. The right pane shows the live
mapping what you change here updates there immediately.
</p>
</div>
<Select
label="SIP seat"
placeholder="Select a seat"
items={seatItems}
selectedKey={selectedSeatId || null}
onSelectionChange={(key) => setSelectedSeatId((key as string) || '')}
>
{(item) => (
<Select.Item
id={item.id}
label={item.label}
supportingText={item.supportingText}
/>
)}
</Select>
<Select
label="Workspace member"
placeholder={
!selectedSeatId
? 'Pick a seat first'
: memberItems.length === 0
? 'No available members'
: 'Select a member'
}
isDisabled={!selectedSeatId || memberItems.length === 0}
items={memberItems}
selectedKey={selectedMemberId || null}
onSelectionChange={(key) => setSelectedMemberId((key as string) || '')}
>
{(item) => (
<Select.Item
id={item.id}
label={item.label}
supportingText={item.supportingText}
/>
)}
</Select>
<div className="flex items-center justify-end gap-3">
{isCurrentlyMapped && (
<Button
color="secondary-destructive"
size="md"
isDisabled={saving}
onClick={handleUnassign}
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faTrash} className={className} />
)}
>
Unassign
</Button>
)}
<Button
color="primary"
size="md"
isDisabled={saving || !selectedSeatId || !selectedMemberId}
onClick={handleAssign}
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faHeadset} className={className} />
)}
>
{selectedSeat?.workspaceMemberId === selectedMemberId
? 'Already assigned'
: isCurrentlyMapped
? 'Reassign'
: 'Assign'}
</Button>
</div>
</div>
)}
</WizardStep>
);
};