mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28: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,100 +1,441 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { WizardStep } from './wizard-step';
|
||||
import { TeamRightPane, type TeamMemberSummary } from './wizard-right-panes';
|
||||
import {
|
||||
InviteMemberForm,
|
||||
type InviteMemberFormValues,
|
||||
EmployeeCreateForm,
|
||||
emptyEmployeeCreateFormValues,
|
||||
generateTempPassword,
|
||||
type EmployeeCreateFormValues,
|
||||
type RoleOption,
|
||||
} from '@/components/forms/invite-member-form';
|
||||
} from '@/components/forms/employee-create-form';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import type { WizardStepComponentProps } from './wizard-step-types';
|
||||
|
||||
// Team step — fetch roles from the platform and present the invite form.
|
||||
// The admin types one or more emails and picks a role. sendInvitations
|
||||
// fires, the backend emails them, and the wizard advances on success.
|
||||
// Team step (post-rework) — creates workspace members directly from
|
||||
// the portal via the sidecar's /api/team/members endpoint. The admin
|
||||
// enters name + email + temp password + role. SIP seat assignment is
|
||||
// NOT done here — it lives exclusively in the Telephony wizard step
|
||||
// so admins manage one thing in one place.
|
||||
//
|
||||
// Role assignment itself happens AFTER the invitee accepts (since we only
|
||||
// have a workspaceMemberId once they've joined the workspace). For now we
|
||||
// just send the invitations — the admin can finalise role assignments
|
||||
// from /settings/team once everyone has accepted.
|
||||
export const WizardStepTeam = (props: WizardStepComponentProps) => {
|
||||
const [values, setValues] = useState<InviteMemberFormValues>({ emails: [], roleId: '' });
|
||||
const [roles, setRoles] = useState<RoleOption[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
// Edit mode: clicking the pencil icon on an employee row in the right
|
||||
// pane loads that member back into the form (name + role only — email,
|
||||
// password and SIP seat are not editable here). Save in edit mode
|
||||
// fires PUT /api/team/members/:id instead of POST.
|
||||
//
|
||||
// Email invitations are NOT used anywhere in this flow. The admin is
|
||||
// expected to share the temp password with the employee directly.
|
||||
// Recently-created employees keep their plaintext password in
|
||||
// component state so the right pane's copy icon can paste a
|
||||
// shareable credentials block to the clipboard. Page reload clears
|
||||
// that state — only employees created in the current session show
|
||||
// the copy icon. Older members get only the edit icon.
|
||||
|
||||
useEffect(() => {
|
||||
apiClient
|
||||
.graphql<{
|
||||
getRoles: {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string | null;
|
||||
canBeAssignedToUsers: boolean;
|
||||
}[];
|
||||
}>(`{ getRoles { id label description canBeAssignedToUsers } }`, undefined, { silent: true })
|
||||
.then((data) =>
|
||||
setRoles(
|
||||
data.getRoles
|
||||
.filter((r) => r.canBeAssignedToUsers)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
label: r.label,
|
||||
supportingText: r.description ?? undefined,
|
||||
})),
|
||||
),
|
||||
)
|
||||
.catch(() => setRoles([]));
|
||||
// In-memory record of an employee the admin just created in this
|
||||
// session. Holds the plaintext temp password so the copy-icon flow
|
||||
// works without ever sending the password back from the server.
|
||||
type CreatedMemberRecord = {
|
||||
id: string;
|
||||
userEmail: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
roleId: string;
|
||||
tempPassword: string;
|
||||
};
|
||||
|
||||
type RoleRow = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string | null;
|
||||
canBeAssignedToUsers: boolean;
|
||||
};
|
||||
|
||||
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;
|
||||
// Platform returns `null` (not an empty array) for members with no
|
||||
// role assigned — touching `.roles[0]` directly throws. Always
|
||||
// optional-chain reads.
|
||||
roles: { id: string; label: string }[] | null;
|
||||
};
|
||||
|
||||
const AI_EMAIL_SUFFIX = '@ai.fortytwo.local';
|
||||
|
||||
// Build the credentials block that gets copied to the clipboard. Two
|
||||
// lines (login url + email) plus the temp password — formatted so
|
||||
// the admin can paste it straight into WhatsApp / SMS. Login URL is
|
||||
// derived from the current browser origin since the wizard is always
|
||||
// loaded from the workspace's own URL (or Vite dev), so this matches
|
||||
// what the employee will use.
|
||||
const buildCredentialsBlock = (email: string, tempPassword: string): string => {
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
return `Login: ${origin}/login\nEmail: ${email}\nTemporary password: ${tempPassword}`;
|
||||
};
|
||||
|
||||
export const WizardStepTeam = (props: WizardStepComponentProps) => {
|
||||
const { user } = useAuth();
|
||||
const currentUserEmail = user?.email ?? null;
|
||||
|
||||
// Initialise the form with a fresh temp password so the admin
|
||||
// doesn't have to click "regenerate" before saving the very first
|
||||
// employee.
|
||||
const [values, setValues] = useState<EmployeeCreateFormValues>(() => ({
|
||||
...emptyEmployeeCreateFormValues,
|
||||
password: generateTempPassword(),
|
||||
}));
|
||||
const [editingMemberId, setEditingMemberId] = useState<string | null>(null);
|
||||
const [roles, setRoles] = useState<RoleOption[]>([]);
|
||||
// Agents are still fetched (even though we don't show a SIP seat
|
||||
// picker here) because the right-pane summary needs each member's
|
||||
// current SIP extension to show the green badge.
|
||||
const [agents, setAgents] = useState<AgentRow[]>([]);
|
||||
const [members, setMembers] = useState<WorkspaceMemberRow[]>([]);
|
||||
const [createdMembers, setCreatedMembers] = useState<CreatedMemberRecord[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const isEditing = editingMemberId !== null;
|
||||
|
||||
const fetchRolesAndAgents = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiClient.graphql<{
|
||||
getRoles: RoleRow[];
|
||||
agents: { edges: { node: AgentRow }[] };
|
||||
workspaceMembers: { edges: { node: WorkspaceMemberRow }[] };
|
||||
}>(
|
||||
`{
|
||||
getRoles { id label description canBeAssignedToUsers }
|
||||
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 }
|
||||
roles { id label }
|
||||
} }
|
||||
}
|
||||
}`,
|
||||
undefined,
|
||||
{ silent: true },
|
||||
);
|
||||
const assignable = data.getRoles.filter((r) => r.canBeAssignedToUsers);
|
||||
setRoles(
|
||||
assignable.map((r) => ({
|
||||
id: r.id,
|
||||
label: r.label,
|
||||
supportingText: r.description ?? undefined,
|
||||
})),
|
||||
);
|
||||
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/team] fetch roles/agents failed', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (values.emails.length === 0) {
|
||||
notify.error('Add at least one email');
|
||||
useEffect(() => {
|
||||
fetchRolesAndAgents();
|
||||
}, [fetchRolesAndAgents]);
|
||||
|
||||
// Reset form back to a fresh "create" state with a new auto-gen
|
||||
// password. Used after both create-success and edit-cancel.
|
||||
const resetForm = () => {
|
||||
setEditingMemberId(null);
|
||||
setValues({
|
||||
...emptyEmployeeCreateFormValues,
|
||||
password: generateTempPassword(),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveCreate = async () => {
|
||||
const firstName = values.firstName.trim();
|
||||
const email = values.email.trim();
|
||||
if (!firstName) {
|
||||
notify.error('First name is required');
|
||||
return;
|
||||
}
|
||||
if (!email) {
|
||||
notify.error('Email is required');
|
||||
return;
|
||||
}
|
||||
if (!values.password) {
|
||||
notify.error('Temporary password is required');
|
||||
return;
|
||||
}
|
||||
if (!values.roleId) {
|
||||
notify.error('Pick a role');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiClient.graphql(
|
||||
`mutation SendInvitations($emails: [String!]!) {
|
||||
sendInvitations(emails: $emails) { success errors }
|
||||
}`,
|
||||
{ emails: values.emails },
|
||||
);
|
||||
const created = await apiClient.post<{
|
||||
id: string;
|
||||
userEmail: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
roleId: string;
|
||||
}>('/api/team/members', {
|
||||
firstName,
|
||||
lastName: values.lastName.trim(),
|
||||
email,
|
||||
password: values.password,
|
||||
roleId: values.roleId,
|
||||
});
|
||||
|
||||
// Stash the plaintext temp password alongside the created
|
||||
// member so the copy-icon can build a credentials block
|
||||
// later. The password is NOT sent back from the server —
|
||||
// we hold the only copy in this component's memory.
|
||||
setCreatedMembers((prev) => [
|
||||
...prev,
|
||||
{ ...created, tempPassword: values.password },
|
||||
]);
|
||||
notify.success(
|
||||
'Invitations sent',
|
||||
`${values.emails.length} invitation${values.emails.length === 1 ? '' : 's'} sent.`,
|
||||
'Employee created',
|
||||
`${firstName} ${values.lastName.trim()}`.trim() || email,
|
||||
);
|
||||
await props.onComplete('team');
|
||||
setValues({ emails: [], roleId: '' });
|
||||
props.onAdvance();
|
||||
await fetchRolesAndAgents();
|
||||
resetForm();
|
||||
if (!props.isCompleted) {
|
||||
await props.onComplete('team');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[wizard/team] invite failed', err);
|
||||
console.error('[wizard/team] create failed', err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveUpdate = async () => {
|
||||
if (!editingMemberId) return;
|
||||
const firstName = values.firstName.trim();
|
||||
if (!firstName) {
|
||||
notify.error('First name is required');
|
||||
return;
|
||||
}
|
||||
if (!values.roleId) {
|
||||
notify.error('Pick a role');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiClient.put(`/api/team/members/${editingMemberId}`, {
|
||||
firstName,
|
||||
lastName: values.lastName.trim(),
|
||||
roleId: values.roleId,
|
||||
});
|
||||
notify.success(
|
||||
'Employee updated',
|
||||
`${firstName} ${values.lastName.trim()}`.trim() || values.email,
|
||||
);
|
||||
await fetchRolesAndAgents();
|
||||
resetForm();
|
||||
} catch (err) {
|
||||
console.error('[wizard/team] update failed', err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = isEditing ? handleSaveUpdate : handleSaveCreate;
|
||||
|
||||
// Right-pane edit handler — populate the form with the picked
|
||||
// member's data and switch into edit mode. Email is preserved as
|
||||
// the row's email (read-only in edit mode); password is cleared
|
||||
// since the form hides the field anyway.
|
||||
const handleEditMember = (memberId: string) => {
|
||||
const member = members.find((m) => m.id === memberId);
|
||||
if (!member) return;
|
||||
const firstRole = member.roles?.[0] ?? null;
|
||||
setEditingMemberId(memberId);
|
||||
setValues({
|
||||
firstName: member.name?.firstName ?? '',
|
||||
lastName: member.name?.lastName ?? '',
|
||||
email: member.userEmail,
|
||||
password: '',
|
||||
roleId: firstRole?.id ?? '',
|
||||
});
|
||||
};
|
||||
|
||||
// Right-pane copy handler — build the shareable credentials block
|
||||
// and put it on the clipboard. Only fires for members in the
|
||||
// createdMembers in-memory map; rows without a known temp password
|
||||
// don't show the icon at all.
|
||||
const handleCopyCredentials = async (memberId: string) => {
|
||||
const member = members.find((m) => m.id === memberId);
|
||||
if (!member) return;
|
||||
|
||||
// Three-tier fallback:
|
||||
// 1. In-browser memory (createdMembers state) — populated when
|
||||
// the admin created this employee in the current session,
|
||||
// survives until refresh. Fastest path, no network call.
|
||||
// 2. Sidecar Redis cache via GET /api/team/members/:id/temp-password
|
||||
// — populated for any member created via this endpoint
|
||||
// within the last 24h, survives reloads.
|
||||
// 3. Cache miss → tell the admin the password is no longer
|
||||
// recoverable and direct them to the platform reset flow.
|
||||
const fromMemory =
|
||||
createdMembers.find(
|
||||
(c) => c.userEmail.toLowerCase() === member.userEmail.toLowerCase(),
|
||||
) ?? createdMembers.find((c) => c.id === memberId);
|
||||
let tempPassword = fromMemory?.tempPassword ?? null;
|
||||
|
||||
if (!tempPassword) {
|
||||
try {
|
||||
const res = await apiClient.get<{ password: string | null }>(
|
||||
`/api/team/members/${memberId}/temp-password`,
|
||||
{ silent: true },
|
||||
);
|
||||
tempPassword = res.password;
|
||||
} catch (err) {
|
||||
console.error('[wizard/team] temp-password fetch failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!tempPassword) {
|
||||
notify.error(
|
||||
'Password unavailable',
|
||||
'The temp password expired (>24h). Reset the password from settings to mint a new one.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const block = buildCredentialsBlock(member.userEmail, tempPassword);
|
||||
try {
|
||||
await navigator.clipboard.writeText(block);
|
||||
notify.success('Copied', 'Credentials copied to clipboard');
|
||||
} catch (err) {
|
||||
console.error('[wizard/team] clipboard write failed', err);
|
||||
notify.error('Copy failed', 'Could not write to clipboard');
|
||||
}
|
||||
};
|
||||
|
||||
// Trick: we lie to WizardStep about isCompleted so that once at
|
||||
// least one employee exists, the primary wizard button flips to
|
||||
// "Continue" and the create form stays available below for more
|
||||
// adds.
|
||||
const pretendCompleted = props.isCompleted || members.length > 0 || createdMembers.length > 0;
|
||||
|
||||
// Build the right pane summary. Every non-admin row gets the
|
||||
// copy icon — `canCopyCredentials: true` unconditionally — and
|
||||
// the click handler figures out at action time whether to read
|
||||
// from in-browser memory or the sidecar's Redis cache. If both
|
||||
// are empty (>24h old), the click toasts a "password expired"
|
||||
// message instead of silently failing.
|
||||
const teamSummaries = useMemo<TeamMemberSummary[]>(
|
||||
() =>
|
||||
members.map((m) => {
|
||||
const seat = agents.find((a) => a.workspaceMemberId === m.id);
|
||||
const firstRole = m.roles?.[0] ?? null;
|
||||
return {
|
||||
id: m.id,
|
||||
userEmail: m.userEmail,
|
||||
name: m.name,
|
||||
roleLabel: firstRole?.label ?? null,
|
||||
sipExtension: seat?.sipExtension ?? null,
|
||||
isCurrentUser: currentUserEmail !== null && m.userEmail === currentUserEmail,
|
||||
canCopyCredentials: true,
|
||||
};
|
||||
}),
|
||||
[members, agents, currentUserEmail],
|
||||
);
|
||||
|
||||
return (
|
||||
<WizardStep
|
||||
step="team"
|
||||
isCompleted={props.isCompleted}
|
||||
isCompleted={pretendCompleted}
|
||||
isLast={props.isLast}
|
||||
onPrev={props.onPrev}
|
||||
onNext={props.onNext}
|
||||
onMarkComplete={handleSave}
|
||||
onFinish={props.onFinish}
|
||||
saving={saving}
|
||||
rightPane={
|
||||
<TeamRightPane
|
||||
members={teamSummaries}
|
||||
onEdit={handleEditMember}
|
||||
onCopy={handleCopyCredentials}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{props.isCompleted && (
|
||||
<div className="mb-5 rounded-lg border border-secondary bg-secondary p-4 text-xs text-tertiary">
|
||||
Invitations already sent. Add more emails below to invite additional members, or click{' '}
|
||||
<b>Next</b> to continue.
|
||||
{loading ? (
|
||||
<p className="text-sm text-tertiary">Loading team settings…</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="rounded-lg border border-secondary bg-secondary p-4 text-xs text-tertiary">
|
||||
{isEditing ? (
|
||||
<p>
|
||||
Editing an existing employee. You can change their name and role.
|
||||
To change their SIP seat, go to the <b>Telephony</b> step.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
Create employees in-place. Each person gets an auto-generated
|
||||
temporary password that you share directly — no email
|
||||
invitations are sent. Click the eye icon to reveal it before
|
||||
you save. After creating CC agents, head to the <b>Telephony</b>{' '}
|
||||
step to assign them SIP seats.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<EmployeeCreateForm
|
||||
value={values}
|
||||
onChange={setValues}
|
||||
roles={roles}
|
||||
mode={isEditing ? 'edit' : 'create'}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{isEditing && (
|
||||
<Button size="md" color="secondary" isDisabled={saving} onClick={resetForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={handleSave}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-brand-solid px-4 py-2 text-sm font-semibold text-primary_on-brand shadow-xs transition hover:bg-brand-solid_hover disabled:opacity-60"
|
||||
>
|
||||
{saving
|
||||
? isEditing
|
||||
? 'Updating…'
|
||||
: 'Creating…'
|
||||
: isEditing
|
||||
? 'Update employee'
|
||||
: 'Create employee'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<InviteMemberForm value={values} onChange={setValues} roles={roles} />
|
||||
<p className="mt-4 text-xs text-tertiary">
|
||||
Invited members receive an email with a link to set their password. Fine-tune role assignments
|
||||
from the Team page after they join.
|
||||
</p>
|
||||
</WizardStep>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user