mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
- 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>
323 lines
13 KiB
TypeScript
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>
|
|
);
|
|
};
|