Files
helix-engage/src/components/setup/wizard-step-team.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

442 lines
17 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react';
import { WizardStep } from './wizard-step';
import { TeamRightPane, type TeamMemberSummary } from './wizard-right-panes';
import {
EmployeeCreateForm,
emptyEmployeeCreateFormValues,
generateTempPassword,
type EmployeeCreateFormValues,
type RoleOption,
} 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 (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.
//
// 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.
// 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);
}
}, []);
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 {
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(
'Employee created',
`${firstName} ${values.lastName.trim()}`.trim() || email,
);
await fetchRolesAndAgents();
resetForm();
if (!props.isCompleted) {
await props.onComplete('team');
}
} catch (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={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}
/>
}
>
{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>
)}
</WizardStep>
);
};