mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-12 02:38:15 +00:00
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>
This commit is contained in:
@@ -1,101 +1,321 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
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 {
|
||||
TelephonyForm,
|
||||
emptyTelephonyFormValues,
|
||||
type TelephonyFormValues,
|
||||
} from '@/components/forms/telephony-form';
|
||||
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 — loads the existing masked config from the sidecar and
|
||||
// lets the admin fill in the Ozonetel/SIP/Exotel credentials. On save, PUTs
|
||||
// the full form (the backend treats '***masked***' as "no change") and
|
||||
// marks the step complete.
|
||||
// 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.
|
||||
//
|
||||
// Unlike the entity steps, this is a single-doc config so we always load the
|
||||
// current state rather than treating the form as "add new".
|
||||
// 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 [values, setValues] = useState<TelephonyFormValues>(emptyTelephonyFormValues);
|
||||
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>('');
|
||||
|
||||
useEffect(() => {
|
||||
apiClient
|
||||
.get<TelephonyFormValues>('/api/config/telephony', { silent: true })
|
||||
.then((data) => {
|
||||
setValues({
|
||||
ozonetel: {
|
||||
agentId: data.ozonetel?.agentId ?? '',
|
||||
agentPassword: data.ozonetel?.agentPassword ?? '',
|
||||
did: data.ozonetel?.did ?? '',
|
||||
sipId: data.ozonetel?.sipId ?? '',
|
||||
campaignName: data.ozonetel?.campaignName ?? '',
|
||||
},
|
||||
sip: {
|
||||
domain: data.sip?.domain ?? 'blr-pub-rtc4.ozonetel.com',
|
||||
wsPort: data.sip?.wsPort ?? '444',
|
||||
},
|
||||
exotel: {
|
||||
apiKey: data.exotel?.apiKey ?? '',
|
||||
apiToken: data.exotel?.apiToken ?? '',
|
||||
accountSid: data.exotel?.accountSid ?? '',
|
||||
subdomain: data.exotel?.subdomain ?? 'api.exotel.com',
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// If the endpoint is unreachable, fall back to defaults so the
|
||||
// admin can at least fill out the form.
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
// Required fields for a working Ozonetel setup.
|
||||
if (
|
||||
!values.ozonetel.agentId.trim() ||
|
||||
!values.ozonetel.did.trim() ||
|
||||
!values.ozonetel.sipId.trim() ||
|
||||
!values.ozonetel.campaignName.trim()
|
||||
) {
|
||||
notify.error('Missing required fields', 'Agent ID, DID, SIP ID, and campaign name are all required.');
|
||||
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.put('/api/config/telephony', {
|
||||
ozonetel: values.ozonetel,
|
||||
sip: values.sip,
|
||||
exotel: values.exotel,
|
||||
});
|
||||
notify.success('Telephony saved', 'Changes are live — no restart needed.');
|
||||
await props.onComplete('telephony');
|
||||
props.onAdvance();
|
||||
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] save failed', 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={props.isCompleted}
|
||||
isCompleted={pretendCompleted}
|
||||
isLast={props.isLast}
|
||||
onPrev={props.onPrev}
|
||||
onNext={props.onNext}
|
||||
onMarkComplete={handleSave}
|
||||
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 telephony settings…</p>
|
||||
<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>
|
||||
) : (
|
||||
<TelephonyForm value={values} onChange={setValues} />
|
||||
<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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user