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>
442 lines
17 KiB
TypeScript
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>
|
|
);
|
|
};
|
|
|